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:
+84
@@ -0,0 +1,84 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Sidebar } from './components/layout/Sidebar';
|
||||
import { Header } from './components/layout/Header';
|
||||
import { MobileNav } from './components/layout/MobileNav';
|
||||
import { LoginScreen } from './components/layout/LoginScreen';
|
||||
import { PulseMark } from './components/layout/PulseMark';
|
||||
import { AccountDialog } from './components/settings/AccountDialog';
|
||||
import { Dashboard } from './components/dashboard/Dashboard';
|
||||
import { Booking } from './components/booking/Booking';
|
||||
import { Projects } from './components/projects/Projects';
|
||||
import { Team } from './components/team/Team';
|
||||
import { Customers } from './components/customers/Customers';
|
||||
import { Reports } from './components/reports/Reports';
|
||||
import { Settings } from './components/settings/Settings';
|
||||
import { useAuth } from './lib/auth';
|
||||
import { useIsMobile, usePersistentState } from './lib/hooks';
|
||||
import { RouterContext, useHashRoute } from './lib/router';
|
||||
import { homeFor, VIEW_LABELS, viewsFor, type ViewKey } from './types';
|
||||
|
||||
const SUBTITLES: Record<ViewKey, string> = {
|
||||
dashboard: 'Gebuchte Zeiten, Auslastung und letzte Buchungen',
|
||||
buchung: 'Zeiten erfassen, bearbeiten und exportieren',
|
||||
projekte: 'Projekte, Budgettöpfe und Tasks',
|
||||
team: 'Hierarchie, Benutzer und externe Mitarbeiter',
|
||||
kunden: 'Kundenstamm, Projekte und Umsatz',
|
||||
reports: 'Auswertungen nach Kunde, Projekt, Task und Mitarbeiter',
|
||||
einstellungen: 'Arbeitszeit, Export, SSO und Benutzerverwaltung',
|
||||
};
|
||||
|
||||
const Splash: React.FC = () => (
|
||||
<div className="flex h-[100dvh] w-screen flex-col items-center justify-center gap-6 bg-surface">
|
||||
<img src="/logo.png" alt="perinco" className="logo-adaptive h-10 w-auto" />
|
||||
<PulseMark size={44} />
|
||||
<p className="text-xs font-bold uppercase tracking-[0.2em] text-ink-400">Lade Pulse…</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const App: React.FC = () => {
|
||||
const { user, isLoading } = useAuth();
|
||||
const { route, navigate } = useHashRoute();
|
||||
const isMobile = useIsMobile();
|
||||
const [collapsed, setCollapsed] = usePersistentState('pulse_sidebar_collapsed', false);
|
||||
const [accountOpen, setAccountOpen] = useState(false);
|
||||
|
||||
const allowed = user ? viewsFor(user.role) : [];
|
||||
const view: ViewKey | null = user ? (route.view && allowed.includes(route.view) ? route.view : homeFor(user.role)) : null;
|
||||
|
||||
// Unbekannte oder nicht erlaubte Adresse: still auf die Startseite.
|
||||
useEffect(() => {
|
||||
if (user && view && route.view !== view) navigate(view, true);
|
||||
}, [user, view, route.view, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (view) document.title = `${VIEW_LABELS[view]} · Pulse`;
|
||||
}, [view]);
|
||||
|
||||
if (isLoading) return <Splash />;
|
||||
if (!user) return <LoginScreen />;
|
||||
if (!view) return <Splash />;
|
||||
|
||||
return (
|
||||
<RouterContext.Provider value={{ route, navigate }}>
|
||||
<div className="flex h-[100dvh] overflow-hidden bg-canvas text-ink-900">
|
||||
<Sidebar current={view} onNavigate={(v) => navigate(v)} collapsed={collapsed} onAccount={() => setAccountOpen(true)} />
|
||||
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<Header title={VIEW_LABELS[view]} subtitle={isMobile ? undefined : SUBTITLES[view]} onToggleSidebar={() => setCollapsed((c) => !c)} isMobile={isMobile} />
|
||||
<div id="scroll-root" className="scrollbar-slim flex-1 overflow-y-auto overflow-x-hidden p-3 pb-28 sm:p-4 md:p-6 md:pb-6">
|
||||
{view === 'dashboard' && <Dashboard />}
|
||||
{view === 'buchung' && <Booking />}
|
||||
{view === 'projekte' && <Projects />}
|
||||
{view === 'team' && <Team />}
|
||||
{view === 'kunden' && <Customers />}
|
||||
{view === 'reports' && <Reports />}
|
||||
{view === 'einstellungen' && <Settings />}
|
||||
</div>
|
||||
</main>
|
||||
<MobileNav current={view} onNavigate={(v) => navigate(v)} onAccount={() => setAccountOpen(true)} />
|
||||
{accountOpen && <AccountDialog onClose={() => setAccountOpen(false)} />}
|
||||
</div>
|
||||
</RouterContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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!;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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" />;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 – 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 & 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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]" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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" />;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,102 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { Avatar, Button, Field, inputClass } from '../ui/controls';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
import { authApi } from '../../lib/api';
|
||||
import { useToast } from '../Toast';
|
||||
import { ROLE_LABELS } from '../../types';
|
||||
|
||||
const COLORS = ['#6ba04a', '#0ea5e9', '#8b5cf6', '#f97316', '#14b8a6', '#e11d48', '#eab308', '#6366f1', '#10b981', '#ec4899'];
|
||||
|
||||
/** Mein Konto: Farbe, Passwort, Abmelden - fuer jede Rolle. */
|
||||
export const AccountDialog: React.FC<{ onClose: () => void }> = ({ onClose }) => {
|
||||
const { user, updateUser, logout, settings } = useAuth();
|
||||
const { addToast } = useToast();
|
||||
const [current, setCurrent] = useState('');
|
||||
const [next, setNext] = useState('');
|
||||
const [repeat, setRepeat] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
if (!user) return null;
|
||||
|
||||
const changeColor = async (color: string) => {
|
||||
try {
|
||||
const { user: saved } = await authApi.updateMe({ color });
|
||||
updateUser(saved);
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const changePassword = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (next !== repeat) return addToast('Die neuen Passwörter stimmen nicht überein.', 'error');
|
||||
setBusy(true);
|
||||
try {
|
||||
await authApi.changePassword(current, next);
|
||||
addToast('Passwort geändert.', 'success');
|
||||
setCurrent('');
|
||||
setNext('');
|
||||
setRepeat('');
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Ändern fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title="Mein Konto" subtitle={`${ROLE_LABELS[user.role]} · ${user.username}`} onClose={onClose}>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar name={user.name} color={user.color} id={user.id} size={56} external={user.is_external} />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-lg font-extrabold text-ink-900">{user.name}</p>
|
||||
<p className="truncate text-sm text-ink-500">{user.email || '–'}</p>
|
||||
<p className="text-xs text-ink-400">
|
||||
{user.is_external ? `Extern${user.company ? ` · ${user.company}` : ''}` : settings.company_name} · Soll {user.weekly_hours ?? settings.weekly_hours} h/Woche
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-[11px] font-bold uppercase tracking-wider text-ink-500">Meine Farbe</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
onClick={() => changeColor(color)}
|
||||
aria-label={`Farbe ${color}`}
|
||||
className={`h-8 w-8 rounded-full transition hover:scale-110 ${user.color === color ? 'ring-2 ring-ink-800 ring-offset-2 ring-offset-surface' : ''}`}
|
||||
style={{ background: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{user.source === 'local' ? (
|
||||
<form onSubmit={changePassword} className="mt-8 space-y-4 rounded-2xl border border-ink-200 p-4">
|
||||
<p className="text-sm font-extrabold text-ink-900">Passwort ändern</p>
|
||||
<Field label="Aktuelles Passwort">
|
||||
<input type="password" autoComplete="current-password" required className={inputClass} value={current} onChange={(e) => setCurrent(e.target.value)} />
|
||||
</Field>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Neues Passwort" hint="Mindestens 8 Zeichen">
|
||||
<input type="password" autoComplete="new-password" minLength={8} required className={inputClass} value={next} onChange={(e) => setNext(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Wiederholen">
|
||||
<input type="password" autoComplete="new-password" minLength={8} required className={inputClass} value={repeat} onChange={(e) => setRepeat(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
<Button type="submit" busy={busy} icon="key">
|
||||
Passwort speichern
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="mt-8 rounded-xl bg-ink-50 p-4 text-sm text-ink-500">Dein Konto meldet sich über Microsoft (SSO) an – das Passwort verwaltest du dort.</p>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button variant="secondary" icon="logout" onClick={logout}>
|
||||
Abmelden
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,437 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Card, ErrorBox, Field, Spinner, Toggle, inputClass } from '../ui/controls';
|
||||
import { Icon, type IconName } from '../ui/Icon';
|
||||
import { Team } from '../team/Team';
|
||||
import { SsoDirectory } from './SsoDirectory';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
import { download, settingsApi, type AdminSettings } from '../../lib/api';
|
||||
import { useLoader } from '../../lib/hooks';
|
||||
import { useRouter } from '../../lib/router';
|
||||
import { useToast } from '../Toast';
|
||||
|
||||
type Tab = 'allgemein' | 'export' | 'sso' | 'sso-verwaltung' | 'benutzer';
|
||||
const TABS: { key: Tab; label: string; icon: IconName; adminOnly: boolean }[] = [
|
||||
{ key: 'allgemein', label: 'Allgemein', icon: 'settings', adminOnly: true },
|
||||
{ key: 'export', label: 'Export: Kopf & Fuß', icon: 'document', adminOnly: true },
|
||||
{ key: 'sso', label: 'SSO / Entra ID', icon: 'key', adminOnly: true },
|
||||
// Management sieht in den Einstellungen nur diesen Reiter.
|
||||
{ key: 'sso-verwaltung', label: 'SSO-Verwaltung', icon: 'globe', adminOnly: false },
|
||||
{ key: 'benutzer', label: 'Benutzerverwaltung', icon: 'team', adminOnly: true },
|
||||
];
|
||||
|
||||
export const Settings: React.FC = () => {
|
||||
const { route, navigate } = useRouter();
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const tabs = TABS.filter((t) => isAdmin || !t.adminOnly);
|
||||
const tab = (tabs.find((t) => t.key === route.params[0])?.key ?? tabs[0].key) as Tab;
|
||||
const { data, error, reload, setData } = useLoader(() => (isAdmin ? settingsApi.all() : Promise.resolve(null)), [isAdmin]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto grid max-w-[1400px] gap-4 lg:grid-cols-[240px_1fr]">
|
||||
<nav className="no-scrollbar flex gap-1 overflow-x-auto lg:flex-col">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => navigate(`einstellungen/${t.key}`)}
|
||||
className={`flex shrink-0 items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm font-bold transition ${
|
||||
tab === t.key ? 'bg-surface text-brand-700 shadow-card' : 'text-ink-500 hover:bg-surface hover:text-ink-800'
|
||||
}`}
|
||||
>
|
||||
<Icon name={t.icon} className="h-4 w-4" />
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="min-w-0">
|
||||
{tab === 'sso-verwaltung' ? (
|
||||
<SsoDirectory />
|
||||
) : tab === 'benutzer' ? (
|
||||
<Team embedded />
|
||||
) : error && !data ? (
|
||||
<ErrorBox message={error} onRetry={reload} />
|
||||
) : !data ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<div key={tab} className="animate-fade">
|
||||
{tab === 'allgemein' && <General settings={data} onSaved={(s) => setData(s)} />}
|
||||
{tab === 'export' && <ExportSettings settings={data} onSaved={(s) => setData(s)} />}
|
||||
{tab === 'sso' && <SsoSettings settings={data} onSaved={reload} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function useSaver() {
|
||||
const { addToast } = useToast();
|
||||
const { reloadSettings } = useAuth();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const save = async (changes: Parameters<typeof settingsApi.save>[0], after?: () => void) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await settingsApi.save(changes);
|
||||
addToast('Einstellungen gespeichert.', 'success');
|
||||
reloadSettings();
|
||||
after?.();
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return { busy, save };
|
||||
}
|
||||
|
||||
const Section: React.FC<{ title: string; hint?: string; children: React.ReactNode; footer?: React.ReactNode }> = ({ title, hint, children, footer }) => (
|
||||
<Card className="animate-rise overflow-hidden">
|
||||
<div className="border-b border-ink-100 px-5 py-4">
|
||||
<h2 className="text-base text-ink-900">{title}</h2>
|
||||
{hint && <p className="mt-0.5 text-xs text-ink-400">{hint}</p>}
|
||||
</div>
|
||||
<div className="space-y-4 p-5">{children}</div>
|
||||
{footer && <div className="flex justify-end gap-2 border-t border-ink-100 bg-ink-50/60 px-5 py-3">{footer}</div>}
|
||||
</Card>
|
||||
);
|
||||
|
||||
const General: React.FC<{ settings: AdminSettings; onSaved: (s: AdminSettings) => void }> = ({ settings, onSaved }) => {
|
||||
const [form, setForm] = useState({
|
||||
weekly_hours: String(settings.weekly_hours),
|
||||
hours_per_day: String(settings.hours_per_day),
|
||||
budget_warn_percent: String(settings.budget_warn_percent),
|
||||
budget_warn_days: String(settings.budget_warn_days),
|
||||
company_name: settings.company_name,
|
||||
project_code_pattern: settings.project_code_pattern,
|
||||
project_name_pattern: settings.project_name_pattern,
|
||||
});
|
||||
const { busy, save } = useSaver();
|
||||
const set = (patch: Partial<typeof form>) => setForm({ ...form, ...patch });
|
||||
const n = (v: string) => Number(v.replace(',', '.'));
|
||||
const example = form.project_code_pattern.replaceAll('{KUERZEL}', 'KUKA').replaceAll('{JAHR}', '2026').replaceAll('{JJ}', '26').replaceAll('{NR}', '003');
|
||||
const exampleName = form.project_name_pattern.replaceAll('{KUNDE}', 'KUKA').replaceAll('{KUNDENNAME}', 'KUKA AG').replaceAll('{PROJEKT}', 'PMO').replaceAll('{CODE}', example);
|
||||
|
||||
const submit = () => {
|
||||
const changes = {
|
||||
weekly_hours: n(form.weekly_hours),
|
||||
hours_per_day: n(form.hours_per_day),
|
||||
budget_warn_percent: n(form.budget_warn_percent),
|
||||
budget_warn_days: n(form.budget_warn_days),
|
||||
company_name: form.company_name,
|
||||
project_code_pattern: form.project_code_pattern,
|
||||
project_name_pattern: form.project_name_pattern,
|
||||
};
|
||||
save(changes, () => onSaved({ ...settings, ...changes }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Section title="Arbeitszeit" hint="Grundlage für Soll-Stunden, Auslastung und die Umrechnung Stunden ↔ Tage">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Field label="Arbeitsstunden pro Woche" hint="Standard für alle, pro Benutzer überschreibbar">
|
||||
<input inputMode="decimal" className={inputClass} value={form.weekly_hours} onChange={(e) => set({ weekly_hours: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Stunden pro Arbeitstag" hint="Für Tagessätze und Budget in Tagen">
|
||||
<input inputMode="decimal" className={inputClass} value={form.hours_per_day} onChange={(e) => set({ hours_per_day: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Firmenname">
|
||||
<input className={inputClass} value={form.company_name} onChange={(e) => set({ company_name: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
</Section>
|
||||
<Section title="Budgetwarnungen" hint="Ab wann Pulse beim Buchen und in der Projektübersicht warnt. Gesperrt wird immer erst bei 100 % oder nach Ablauf.">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Warnen ab Verbrauch (%)">
|
||||
<input inputMode="numeric" className={inputClass} value={form.budget_warn_percent} onChange={(e) => set({ budget_warn_percent: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Warnen ab Tagen vor Ablauf">
|
||||
<input inputMode="numeric" className={inputClass} value={form.budget_warn_days} onChange={(e) => set({ budget_warn_days: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
</Section>
|
||||
<Section
|
||||
title="Projektbezeichnungen"
|
||||
hint="Muster für den automatisch vergebenen Projektcode und den Anzeigenamen"
|
||||
footer={
|
||||
<Button icon="check" busy={busy} onClick={submit}>
|
||||
Speichern
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Projektcode" hint={<>Platzhalter: {'{KUERZEL} {JAHR} {JJ} {NR}'} · Beispiel: <b className="text-ink-700">{example}</b></>}>
|
||||
<input className={`${inputClass} tabular`} value={form.project_code_pattern} onChange={(e) => set({ project_code_pattern: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Anzeigename" hint={<>Platzhalter: {'{KUNDE} {KUNDENNAME} {PROJEKT} {CODE}'} · Beispiel: <b className="text-ink-700">{exampleName}</b></>}>
|
||||
<input className={inputClass} value={form.project_name_pattern} onChange={(e) => set({ project_name_pattern: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ExportSettings: React.FC<{ settings: AdminSettings; onSaved: (s: AdminSettings) => void }> = ({ settings, onSaved }) => {
|
||||
const [ex, setEx] = useState(settings.export);
|
||||
const { busy, save } = useSaver();
|
||||
const { addToast } = useToast();
|
||||
const set = (patch: Partial<typeof ex>) => setEx({ ...ex, ...patch });
|
||||
|
||||
const onLogo = (file: File | undefined) => {
|
||||
if (!file) return;
|
||||
if (!/image\/(png|jpe?g)/.test(file.type)) return addToast('Bitte PNG oder JPG wählen.', 'error');
|
||||
if (file.size > 1_800_000) return addToast('Das Logo ist zu groß (max. 1,8 MB).', 'error');
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => set({ logo: String(reader.result) });
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const preview = async (format: 'pdf' | 'xlsx') => {
|
||||
try {
|
||||
await download(`/settings/export-preview?format=${format}`, `Vorschau.${format}`, format === 'pdf');
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Vorschau fehlgeschlagen.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const text = (key: keyof typeof ex, label: string, hint?: string) => (
|
||||
<Field label={label} hint={hint}>
|
||||
<input className={inputClass} value={String(ex[key] ?? '')} onChange={(e) => set({ [key]: e.target.value })} />
|
||||
</Field>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 2xl:grid-cols-[1fr_520px]">
|
||||
<Section
|
||||
title="Kopf und Fuß der Exporte"
|
||||
hint="Gilt für Stundennachweise und Reports in PDF und Excel"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" icon="document" onClick={() => preview('pdf')}>
|
||||
PDF-Vorschau
|
||||
</Button>
|
||||
<Button variant="secondary" icon="grid" onClick={() => preview('xlsx')}>
|
||||
Excel-Vorschau
|
||||
</Button>
|
||||
<Button icon="check" busy={busy} onClick={() => save({ export: ex }, () => onSaved({ ...settings, export: ex }))}>
|
||||
Speichern
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-xs text-ink-400">Die Vorschau zeigt den zuletzt gespeicherten Stand.</p>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<span className="field-label mb-1.5 block text-[11px] font-bold uppercase tracking-wider text-ink-500">Logo</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-16 w-40 items-center justify-center rounded-xl border border-ink-200 bg-white p-2">
|
||||
<img src={ex.logo || '/logo.png'} alt="Logo" className="max-h-full max-w-full object-contain" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="block cursor-pointer text-xs font-bold text-brand-700 underline">
|
||||
Hochladen
|
||||
<input type="file" accept="image/png,image/jpeg" className="hidden" onChange={(e) => onLogo(e.target.files?.[0])} />
|
||||
</label>
|
||||
{ex.logo && (
|
||||
<button className="block text-xs font-bold text-ink-400 underline" onClick={() => set({ logo: null })}>
|
||||
perinco Logo verwenden
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Field label="Akzentfarbe">
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="color" className="h-10 w-14 cursor-pointer rounded-lg border border-ink-200 bg-surface" value={ex.accent} onChange={(e) => set({ accent: e.target.value })} />
|
||||
<input className={`${inputClass} tabular`} value={ex.accent} onChange={(e) => set({ accent: e.target.value })} />
|
||||
</div>
|
||||
</Field>
|
||||
{text('title', 'Titel', 'z. B. Stundennachweis, Leistungsnachweis')}
|
||||
{text('subtitle', 'Kopfzeile rechts (1. Zeile)')}
|
||||
{text('header_right', 'Kopfzeile rechts (2. Zeile)')}
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Field label="Fuß links">
|
||||
<textarea className={`${inputClass} h-20`} value={ex.footer_left} onChange={(e) => set({ footer_left: e.target.value })} placeholder={'perinco GmbH\nStraße 1 · 12345 Ort'} />
|
||||
</Field>
|
||||
<Field label="Fuß Mitte">
|
||||
<textarea className={`${inputClass} h-20`} value={ex.footer_center} onChange={(e) => set({ footer_center: e.target.value })} placeholder={'Geschäftsführer …\nHRB …'} />
|
||||
</Field>
|
||||
<Field label="Fuß rechts">
|
||||
<textarea className={`${inputClass} h-20`} value={ex.footer_right} onChange={(e) => set({ footer_right: e.target.value })} placeholder={'www.perinco.de\nBank · IBAN'} />
|
||||
</Field>
|
||||
</div>
|
||||
<Toggle checked={ex.show_signature} onChange={(v) => set({ show_signature: v })} label="Unterschriftenfelder" hint="Am Ende des Stundennachweises – zur Abzeichnung durch den Kunden." />
|
||||
{ex.show_signature && (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{text('signature_left', 'Unterschrift links')}
|
||||
{text('signature_right', 'Unterschrift rechts')}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Card className="animate-rise h-fit p-4">
|
||||
<p className="mb-3 text-[11px] font-extrabold uppercase tracking-wider text-ink-400">Live-Skizze (A4)</p>
|
||||
<div className="mx-auto aspect-[210/297] w-full max-w-[420px] rounded-md bg-white p-[6%] text-[#1f1f1f] shadow-lift" style={{ fontFamily: 'Helvetica, Arial, sans-serif' }}>
|
||||
<div className="flex items-start justify-between">
|
||||
<img src={ex.logo || '/logo.png'} alt="" className="h-8 w-auto max-w-[35%] object-contain" />
|
||||
<div className="text-right text-[7px] leading-tight text-[#6e6e6e]">
|
||||
<div>{ex.subtitle}</div>
|
||||
<div>{ex.header_right}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 h-[1.5px]" style={{ background: ex.accent }} />
|
||||
<p className="mt-3 text-[13px] font-bold">{ex.title || 'Stundennachweis'}</p>
|
||||
<p className="text-[8px]" style={{ color: ex.accent }}>
|
||||
September 2026
|
||||
</p>
|
||||
<div className="mt-2 grid grid-cols-4 gap-1">
|
||||
{['72,50', '72:30', '11', '34,00'].map((v) => (
|
||||
<div key={v} className="rounded-sm border-l-2 bg-[#f4f7f1] px-1 py-1 text-[7px] font-bold" style={{ borderColor: ex.accent }}>
|
||||
{v}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 h-2.5 rounded-[1px]" style={{ background: ex.accent }} />
|
||||
{Array.from({ length: 9 }).map((_, i) => (
|
||||
<div key={i} className={`h-2.5 border-b border-[#eee] ${i % 2 ? 'bg-[#f4f7f1]' : ''}`} />
|
||||
))}
|
||||
{ex.show_signature && (
|
||||
<div className="mt-6 grid grid-cols-2 gap-4 text-[6px] text-[#6e6e6e]">
|
||||
<div className="border-t border-[#9a9a9a] pt-0.5">{ex.signature_left}</div>
|
||||
<div className="border-t border-[#9a9a9a] pt-0.5">{ex.signature_right}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-auto" />
|
||||
<div className="mt-8 grid grid-cols-3 border-t border-[#ddd] pt-1 text-[6px] leading-tight text-[#6e6e6e]">
|
||||
<div className="whitespace-pre-line">{ex.footer_left}</div>
|
||||
<div className="whitespace-pre-line text-center">{ex.footer_center}</div>
|
||||
<div className="whitespace-pre-line text-right">
|
||||
{ex.footer_right}
|
||||
{'\n'}Seite 1 von 1
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SsoSettings: React.FC<{ settings: AdminSettings; onSaved: () => void }> = ({ settings, onSaved }) => {
|
||||
const [sso, setSso] = useState({ ...settings.sso, client_secret: '' });
|
||||
const { busy, save } = useSaver();
|
||||
const { addToast } = useToast();
|
||||
const { navigate } = useRouter();
|
||||
const [testing, setTesting] = useState(false);
|
||||
const set = (patch: Partial<typeof sso>) => setSso({ ...sso, ...patch });
|
||||
|
||||
useEffect(() => setSso({ ...settings.sso, client_secret: '' }), [settings.sso]);
|
||||
|
||||
const test = async () => {
|
||||
setTesting(true);
|
||||
try {
|
||||
const r = await settingsApi.ssoTest();
|
||||
addToast(`Verbindung ok – ${r.eligible} von ${r.checked} geprüften Konten zulässig. Beispiel: ${r.sample.join(', ') || '–'}`, 'success');
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Test fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const text = (key: 'tenant_id' | 'client_id' | 'redirect_uri' | 'button_label', label: string, hint?: React.ReactNode, placeholder?: string) => (
|
||||
<Field label={label} hint={hint}>
|
||||
<input
|
||||
name={`pulse-sso-${key}`}
|
||||
autoComplete="off"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
className={`${inputClass} font-mono text-xs`}
|
||||
value={sso[key]}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => set({ [key]: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Section
|
||||
title="Microsoft Entra ID (M365)"
|
||||
hint="Anmeldung per OpenID Connect, Mitarbeiterliste über Microsoft Graph"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" icon="refresh" busy={testing} onClick={test} disabled={!settings.sso.configured}>
|
||||
Verbindung testen
|
||||
</Button>
|
||||
<Button icon="check" busy={busy} onClick={() => save({ sso: { ...sso, client_secret: sso.client_secret || undefined } }, onSaved)}>
|
||||
Speichern
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={`flex items-center gap-3 rounded-xl px-4 py-3 text-sm font-semibold ${settings.sso.enabled ? 'bg-brand-50 text-brand-800' : 'bg-ink-50 text-ink-600'}`}>
|
||||
<Icon name={settings.sso.enabled ? 'check' : 'info'} className="h-5 w-5" />
|
||||
{settings.sso.enabled
|
||||
? 'SSO ist aktiv – der Anmeldebildschirm zeigt „Mit Microsoft anmelden“.'
|
||||
: settings.sso.configured
|
||||
? 'Zugangsdaten hinterlegt, SSO ist noch nicht eingeschaltet.'
|
||||
: 'Noch nicht eingerichtet – bis dahin melden sich alle lokal an.'}
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{text('tenant_id', 'Tenant-ID (Verzeichnis-ID)', undefined, '00000000-0000-0000-0000-000000000000')}
|
||||
{text('client_id', 'Client-ID (Anwendungs-ID)', undefined, '00000000-0000-0000-0000-000000000000')}
|
||||
<Field label="Client Secret (Wert)" hint={settings.sso.client_secret_set ? 'Hinterlegt (verschlüsselt). Leer lassen = unverändert.' : 'Der „Wert“, nicht die Secret-ID. Wird verschlüsselt gespeichert und nie wieder angezeigt.'}>
|
||||
{/* Kein type="password" - Passwortmanager (Brave) sollen hier nicht anspringen. */}
|
||||
<input
|
||||
name="pulse-sso-client-credential"
|
||||
autoComplete="off"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
className={`${inputClass} font-mono text-xs`}
|
||||
style={{ WebkitTextSecurity: 'disc' } as React.CSSProperties}
|
||||
value={sso.client_secret}
|
||||
onChange={(e) => set({ client_secret: e.target.value })}
|
||||
placeholder={settings.sso.client_secret_set ? '••••••••••••' : ''}
|
||||
/>
|
||||
</Field>
|
||||
{text('redirect_uri', 'Redirect-URI', 'In der App-Registrierung als Web-Plattform eintragen')}
|
||||
{text('button_label', 'Beschriftung des Anmeldeknopfs')}
|
||||
</div>
|
||||
<div className="space-y-3 rounded-2xl border border-ink-200 p-4">
|
||||
<Toggle checked={sso.enabled} onChange={(v) => set({ enabled: v })} label="SSO aktivieren" hint="Zeigt den Microsoft-Anmeldeknopf. Anmelden können sich nur Konten, die in der SSO-Verwaltung übernommen wurden." />
|
||||
<Toggle
|
||||
checked={sso.local_login}
|
||||
onChange={(v) => set({ local_login: v })}
|
||||
label="Lokale Anmeldung direkt anzeigen"
|
||||
hint="Aus: das Formular für externe Benutzer und lokale Konten liegt hinter einem Link. Externe melden sich immer lokal an."
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Mitarbeiter übernehmen">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="min-w-0 flex-1 text-sm text-ink-600">
|
||||
Angezeigt werden nur Konten im Format <code className="rounded bg-ink-100 px-1 text-xs">vorname.nachname@perinco.de</code> bzw.{' '}
|
||||
<code className="rounded bg-ink-100 px-1 text-xs">@icon-experts.de</code> – ohne Admin-Konten, freigegebene Postfächer und Gäste. Übernehmen und Rollen vergeben per Drag & Drop, auch für Manager.
|
||||
</p>
|
||||
<Button icon="globe" onClick={() => navigate('einstellungen/sso-verwaltung')}>
|
||||
Zur SSO-Verwaltung
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Einrichtung in Entra ID">
|
||||
<ol className="list-decimal space-y-1.5 pl-5 text-sm text-ink-600">
|
||||
<li>entra.microsoft.com → App-Registrierungen → Neue Registrierung, Name „Pulse“, Kontotyp „Nur Konten in diesem Organisationsverzeichnis“.</li>
|
||||
<li>
|
||||
Umleitungs-URI: Plattform „Web“, <code className="rounded bg-ink-100 px-1 text-xs">{sso.redirect_uri}</code>
|
||||
</li>
|
||||
<li>Zertifikate & Geheimnisse → Neuer geheimer Clientschlüssel → den <b>Wert</b> hier eintragen (Ablaufdatum notieren, max. 24 Monate).</li>
|
||||
<li>API-Berechtigungen → Microsoft Graph: delegiert <code className="rounded bg-ink-100 px-1 text-xs">openid profile email</code>, Anwendung <code className="rounded bg-ink-100 px-1 text-xs">User.Read.All</code> → „Administratorzustimmung erteilen“.</li>
|
||||
<li>Tenant- und Client-ID aus der Übersicht kopieren, speichern, „Verbindung testen“, Mitarbeiter in der SSO-Verwaltung übernehmen, dann SSO aktivieren.</li>
|
||||
</ol>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,332 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Avatar, Badge, Button, Card, EmptyState, ErrorBox, Segmented, Spinner, inputClass } from '../ui/controls';
|
||||
import { Icon, type IconName } from '../ui/Icon';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
import { directoryApi, usersApi, type AdUser } from '../../lib/api';
|
||||
import { useLoader } from '../../lib/hooks';
|
||||
import { relativeTime } from '../../lib/format';
|
||||
import { useToast } from '../Toast';
|
||||
import { ROLE_LABELS, type DirectoryUser, type Role } from '../../types';
|
||||
|
||||
type ImportRole = Exclude<Role, 'admin'>;
|
||||
|
||||
const ZONES: { role: ImportRole; hint: string; icon: IconName; tone: string }[] = [
|
||||
{ role: 'user', hint: 'Bucht Zeiten auf zugewiesene Projekte', icon: 'user', tone: 'border-ink-300 bg-ink-50 text-ink-700' },
|
||||
{ role: 'lead', hint: 'Sieht und verwaltet sein Team', icon: 'team', tone: 'border-sky-200 bg-sky-50 text-sky-700' },
|
||||
{ role: 'management', hint: 'Sieht alles, verwaltet SSO', icon: 'shield', tone: 'border-violet-200 bg-violet-50 text-violet-700' },
|
||||
];
|
||||
|
||||
const DRAG_TYPE = 'application/x-pulse-ad-users';
|
||||
|
||||
/**
|
||||
* SSO-Verwaltung: links alle zulaessigen AD-Konten, rechts die Rollen. Ziehen
|
||||
* auf eine Rolle uebernimmt das Konto nach Pulse bzw. aendert seine Rolle.
|
||||
* Fuer Touch und Tastatur gibt es dasselbe ueber Auswahl + Knopf.
|
||||
*/
|
||||
export const SsoDirectory: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const { addToast } = useToast();
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const { data, error, loading, reload } = useLoader(() => directoryApi.list(refresh > 0), [refresh]);
|
||||
const [managers, setManagers] = useState<DirectoryUser[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [filter, setFilter] = useState<'open' | 'imported' | 'all'>('open');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [managerId, setManagerId] = useState<number | ''>('');
|
||||
const [dragOver, setDragOver] = useState<ImportRole | null>(null);
|
||||
const [busy, setBusy] = useState<ImportRole | 'sync' | null>(null);
|
||||
|
||||
const loadManagers = () =>
|
||||
usersApi
|
||||
.directory()
|
||||
.then((list) => setManagers(list.filter((u) => u.is_active && (u.role === 'lead' || u.role === 'management'))))
|
||||
.catch(() => undefined);
|
||||
useEffect(() => {
|
||||
loadManagers();
|
||||
}, []);
|
||||
|
||||
const users = data?.users ?? [];
|
||||
const visible = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return users.filter((u) => {
|
||||
if (filter === 'open' && u.pulse_user_id) return false;
|
||||
if (filter === 'imported' && !u.pulse_user_id) return false;
|
||||
return !q || `${u.name} ${u.email} ${u.job_title ?? ''} ${u.department ?? ''}`.toLowerCase().includes(q);
|
||||
});
|
||||
}, [users, search, filter]);
|
||||
|
||||
const byRole = (role: ImportRole) => users.filter((u) => u.pulse_user_id && u.pulse_role === role);
|
||||
const importedCount = users.filter((u) => u.pulse_user_id).length;
|
||||
const selectable = visible.filter((u) => u.pulse_role !== 'admin');
|
||||
const allSelected = selectable.length > 0 && selectable.every((u) => selected.has(u.id));
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setSelected((cur) => {
|
||||
const next = new Set(cur);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const assign = async (ids: string[], role: ImportRole) => {
|
||||
const list = ids.filter((id) => users.find((u) => u.id === id)?.pulse_role !== 'admin');
|
||||
if (!list.length) return;
|
||||
setBusy(role);
|
||||
try {
|
||||
const r = await directoryApi.import(list, role, managerId || null);
|
||||
const parts = [];
|
||||
if (r.created.length) parts.push(`${r.created.length} übernommen`);
|
||||
if (r.linked.length) parts.push(`${r.linked.length} aktualisiert`);
|
||||
if (r.skipped.length) parts.push(`${r.skipped.length} nicht mehr im AD`);
|
||||
addToast(`${ROLE_LABELS[role]}: ${parts.join(', ') || 'keine Änderung'}.`, 'success');
|
||||
setSelected(new Set());
|
||||
await reload();
|
||||
if (role !== 'user') loadManagers();
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Übernehmen fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const sync = async () => {
|
||||
setBusy('sync');
|
||||
try {
|
||||
const r = await directoryApi.sync();
|
||||
addToast(`Abgleich: ${r.updated} aktualisiert, ${r.deactivated} deaktiviert (${r.eligible} zulässige von ${r.total} AD-Konten).`, 'success');
|
||||
setRefresh((n) => n + 1);
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Abgleich fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const onDragStart = (event: React.DragEvent, u: AdUser) => {
|
||||
// Gezogene Zeile gehoert zur Auswahl? Dann die ganze Auswahl mitnehmen.
|
||||
const ids = selected.has(u.id) ? [...selected] : [u.id];
|
||||
event.dataTransfer.setData(DRAG_TYPE, JSON.stringify(ids));
|
||||
event.dataTransfer.setData('text/plain', ids.length > 1 ? `${ids.length} Benutzer` : u.name);
|
||||
event.dataTransfer.effectAllowed = 'copyMove';
|
||||
};
|
||||
|
||||
const onDrop = (event: React.DragEvent, role: ImportRole) => {
|
||||
event.preventDefault();
|
||||
setDragOver(null);
|
||||
try {
|
||||
const ids = JSON.parse(event.dataTransfer.getData(DRAG_TYPE) || '[]');
|
||||
if (Array.isArray(ids) && ids.length) assign(ids, role);
|
||||
} catch {
|
||||
/* fremdes Drag-Objekt */
|
||||
}
|
||||
};
|
||||
|
||||
if (error && !data) return <ErrorBox message={error} onRetry={reload} />;
|
||||
if (!data) return <Spinner label="Lade Active Directory…" />;
|
||||
if (!data.configured) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="key"
|
||||
title="SSO ist noch nicht eingerichtet"
|
||||
text={
|
||||
user?.role === 'admin'
|
||||
? 'Trage zuerst Tenant-ID, Client-ID und Client Secret unter „SSO / Entra ID“ ein.'
|
||||
: 'Ein Administrator muss zuerst die Zugangsdaten zu Microsoft Entra ID hinterlegen.'
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card className="animate-rise flex flex-wrap items-center gap-3 p-4">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-xl bg-brand-50 text-brand-600">
|
||||
<Icon name="key" className="h-5 w-5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-extrabold text-ink-900">
|
||||
{users.length} Mitarbeiterkonten im AD · {importedCount} in Pulse
|
||||
</p>
|
||||
<p className="text-xs text-ink-400">
|
||||
Nur vorname.nachname@{data.domains.join(' / @')} – ohne Admin-Konten, freigegebene Postfächer und Gäste
|
||||
{data.fetched_at && ` · Stand ${relativeTime(data.fetched_at)}`}
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" variant="secondary" icon="refresh" busy={loading && refresh > 0} onClick={() => setRefresh((n) => n + 1)}>
|
||||
Neu laden
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" icon="refresh" busy={busy === 'sync'} onClick={sync} title="Namen/Mail aktualisieren, im AD deaktivierte Konten sperren">
|
||||
Abgleich
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
{/* Active Directory */}
|
||||
<Card className="animate-rise flex min-h-[480px] flex-col overflow-hidden" style={{ '--i': 1 } as React.CSSProperties}>
|
||||
<div className="space-y-3 border-b border-ink-100 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="flex items-center gap-2 whitespace-nowrap text-base text-ink-900">
|
||||
<Icon name="globe" className="h-4 w-4 text-brand-600" />
|
||||
Active Directory
|
||||
</h3>
|
||||
<Segmented
|
||||
size="sm"
|
||||
value={filter}
|
||||
onChange={(v) => {
|
||||
setFilter(v);
|
||||
setSelected(new Set());
|
||||
}}
|
||||
options={[
|
||||
{ value: 'open', label: 'Nicht übernommen' },
|
||||
{ value: 'imported', label: 'In Pulse' },
|
||||
{ value: 'all', label: 'Alle' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Icon name="search" className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-ink-400" />
|
||||
<input className={`${inputClass} pl-9`} placeholder="Name, E-Mail, Abteilung…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs font-bold text-ink-500">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={() => setSelected(allSelected ? new Set() : new Set(selectable.map((u) => u.id)))}
|
||||
className="h-4 w-4 accent-[var(--color-brand-500)]"
|
||||
/>
|
||||
Alle {visible.length} auswählen
|
||||
<span className="ml-auto hidden font-semibold text-ink-400 sm:inline">Ziehen auf eine Rolle →</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<ul className="scrollbar-slim max-h-[620px] flex-1 divide-y divide-ink-100 overflow-y-auto">
|
||||
{visible.length === 0 && <li className="p-8 text-center text-sm text-ink-400">Keine Konten für diesen Filter.</li>}
|
||||
{visible.map((u, i) => {
|
||||
const isAdmin = u.pulse_role === 'admin';
|
||||
const checked = selected.has(u.id);
|
||||
return (
|
||||
<li
|
||||
key={u.id}
|
||||
draggable={!isAdmin}
|
||||
onDragStart={(e) => onDragStart(e, u)}
|
||||
className={`animate-rise flex items-center gap-3 px-4 py-2.5 transition ${isAdmin ? 'opacity-60' : 'cursor-grab active:cursor-grabbing'} ${
|
||||
checked ? 'bg-brand-50' : 'hover:bg-ink-50'
|
||||
}`}
|
||||
style={{ '--i': Math.min(i, 12) } as React.CSSProperties}
|
||||
>
|
||||
<input type="checkbox" disabled={isAdmin} checked={checked} onChange={() => toggle(u.id)} className="h-4 w-4 shrink-0 accent-[var(--color-brand-500)]" aria-label={`${u.name} auswählen`} />
|
||||
<Avatar name={u.name} size={34} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-bold text-ink-900">{u.name}</p>
|
||||
<p className="truncate text-xs text-ink-400">
|
||||
{u.email}
|
||||
{(u.job_title || u.department) && ` · ${[u.job_title, u.department].filter(Boolean).join(', ')}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
{u.pulse_role && <Badge tone={u.pulse_role === 'user' ? 'neutral' : u.pulse_role === 'lead' ? 'sky' : u.pulse_role === 'management' ? 'violet' : 'brand'}>{ROLE_LABELS[u.pulse_role]}</Badge>}
|
||||
{u.pulse_active === false && <Badge tone="red">inaktiv</Badge>}
|
||||
{!u.pulse_user_id && u.local_match_id && (
|
||||
<Badge tone="amber" title="Ein lokales Konto mit dieser Mailadresse wird beim Übernehmen verknüpft">
|
||||
lokal vorhanden
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{!isAdmin && <Icon name="more" className="hidden h-4 w-4 shrink-0 rotate-90 text-ink-300 sm:block" />}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{selected.size > 0 && (
|
||||
<div className="animate-sheet safe-bottom flex flex-wrap items-center gap-2 border-t border-ink-200 bg-surface p-3">
|
||||
<span className="text-xs font-extrabold text-ink-700">{selected.size} ausgewählt – übernehmen als</span>
|
||||
{ZONES.map((z) => (
|
||||
<Button key={z.role} size="sm" variant="secondary" icon={z.icon} busy={busy === z.role} onClick={() => assign([...selected], z.role)}>
|
||||
{ROLE_LABELS[z.role]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Rollen */}
|
||||
<div className="animate-rise space-y-3" style={{ '--i': 2 } as React.CSSProperties}>
|
||||
<Card className="flex flex-wrap items-center gap-3 p-4">
|
||||
<Icon name="team" className="h-5 w-5 text-ink-400" />
|
||||
<label className="min-w-0 flex-1 text-xs font-bold text-ink-500" htmlFor="sso-manager">
|
||||
Vorgesetzter für übernommene Benutzer
|
||||
</label>
|
||||
<select id="sso-manager" className={`${inputClass} !w-auto max-w-[240px]`} value={managerId} onChange={(e) => setManagerId(e.target.value ? Number(e.target.value) : '')}>
|
||||
<option value="">– nicht ändern –</option>
|
||||
{managers.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name} ({ROLE_LABELS[m.role]})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Card>
|
||||
|
||||
{ZONES.map((zone) => {
|
||||
const members = byRole(zone.role);
|
||||
const over = dragOver === zone.role;
|
||||
return (
|
||||
<div
|
||||
key={zone.role}
|
||||
onDragOver={(e) => {
|
||||
if (!e.dataTransfer.types.includes(DRAG_TYPE)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
if (dragOver !== zone.role) setDragOver(zone.role);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOver(null);
|
||||
}}
|
||||
onDrop={(e) => onDrop(e, zone.role)}
|
||||
className={`rounded-2xl border-2 border-dashed p-4 transition-all duration-200 ${
|
||||
over ? 'scale-[1.01] border-brand-400 bg-brand-50 shadow-lift' : 'border-ink-200 bg-surface'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`flex h-10 w-10 items-center justify-center rounded-xl border ${zone.tone}`}>
|
||||
<Icon name={zone.icon} className="h-5 w-5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-extrabold text-ink-900">
|
||||
{ROLE_LABELS[zone.role]} <span className="text-ink-400">({members.length})</span>
|
||||
</p>
|
||||
<p className="text-xs text-ink-400">{over ? 'Loslassen zum Übernehmen' : zone.hint}</p>
|
||||
</div>
|
||||
{busy === zone.role && <span className="h-5 w-5 animate-spin rounded-full border-2 border-brand-200 border-t-brand-500" />}
|
||||
</div>
|
||||
{members.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{members.map((m) => (
|
||||
<span
|
||||
key={m.id}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, m)}
|
||||
title="Auf eine andere Rolle ziehen, um sie zu ändern"
|
||||
className={`flex cursor-grab items-center gap-1.5 rounded-full border border-ink-200 bg-surface py-0.5 pl-0.5 pr-2.5 text-xs font-bold text-ink-700 active:cursor-grabbing ${
|
||||
m.pulse_active === false ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<Avatar name={m.name} size={22} />
|
||||
{m.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<p className="px-1 text-xs text-ink-400">
|
||||
Anmelden per Microsoft können sich nur übernommene Konten. Rollen werden ausschließlich hier bzw. unter Team vergeben – AD-Gruppen spielen keine Rolle.
|
||||
Hierarchie, Wochenstunden und Deaktivieren unter <b>Team</b>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
+1392
File diff suppressed because it is too large
Load Diff
+378
@@ -0,0 +1,378 @@
|
||||
import type {
|
||||
AppUser,
|
||||
BudgetPot,
|
||||
Customer,
|
||||
DirectoryUser,
|
||||
ManagedUser,
|
||||
Project,
|
||||
PublicSettings,
|
||||
Role,
|
||||
TimeEntry,
|
||||
} from '../types';
|
||||
|
||||
const TOKEN_KEY = 'pulse_token';
|
||||
|
||||
export function getToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export function setToken(token: string) {
|
||||
try {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
} catch {
|
||||
/* ohne localStorage bleibt die Sitzung nur bis zum Neuladen */
|
||||
}
|
||||
}
|
||||
export function clearToken() {
|
||||
try {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
} catch {
|
||||
/* egal */
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
let onUnauthorized: (() => void) | null = null;
|
||||
export function setUnauthorizedHandler(handler: (() => void) | null) {
|
||||
onUnauthorized = handler;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(options.headers);
|
||||
if (options.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
|
||||
const token = getToken();
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`/api${path}`, { ...options, headers });
|
||||
} catch {
|
||||
throw new ApiError('Server nicht erreichbar. Bitte Verbindung prüfen.', 0);
|
||||
}
|
||||
if (response.status === 401 && !path.startsWith('/auth/login')) {
|
||||
clearToken();
|
||||
onUnauthorized?.();
|
||||
}
|
||||
const isJson = response.headers.get('content-type')?.includes('application/json');
|
||||
const payload = isJson ? await response.json().catch(() => null) : null;
|
||||
if (!response.ok) {
|
||||
throw new ApiError(payload?.error || `Anfrage fehlgeschlagen (HTTP ${response.status}).`, response.status);
|
||||
}
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
const get = <T>(path: string) => request<T>(path);
|
||||
const send = <T>(method: string, path: string, body?: unknown) =>
|
||||
request<T>(path, { method, body: body === undefined ? undefined : JSON.stringify(body) });
|
||||
|
||||
function qs(params: Record<string, string | number | boolean | null | undefined>) {
|
||||
const search = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v === undefined || v === null || v === '') continue;
|
||||
search.set(k, String(v));
|
||||
}
|
||||
const s = search.toString();
|
||||
return s ? `?${s}` : '';
|
||||
}
|
||||
|
||||
/** Laedt eine Datei (Export) mit Token und stoesst den Download an. */
|
||||
export async function download(path: string, fallbackName: string, open = false) {
|
||||
const token = getToken();
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`/api${path}`, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
|
||||
} catch {
|
||||
throw new ApiError('Server nicht erreichbar.', 0);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
throw new ApiError(payload?.error || `Export fehlgeschlagen (HTTP ${response.status}).`, response.status);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const disposition = response.headers.get('content-disposition') || '';
|
||||
const match = disposition.match(/filename\*=UTF-8''([^;]+)/);
|
||||
const name = match ? decodeURIComponent(match[1]) : fallbackName;
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (open && blob.type === 'application/pdf') {
|
||||
window.open(url, '_blank', 'noopener');
|
||||
} else {
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
}
|
||||
|
||||
export { qs };
|
||||
|
||||
/* ------------------------------------------------------------ Endpunkte -- */
|
||||
|
||||
export const authApi = {
|
||||
config: () => get<{ sso_enabled: boolean; sso_label: string; local_login: boolean }>('/auth/config'),
|
||||
login: (identifier: string, password: string) =>
|
||||
send<{ token: string; user: AppUser }>('POST', '/auth/login', { identifier, password }),
|
||||
me: () => get<{ user: AppUser }>('/auth/me'),
|
||||
updateMe: (changes: Partial<Pick<AppUser, 'theme' | 'theme_mode' | 'color'>>) =>
|
||||
send<{ user: AppUser }>('PATCH', '/auth/me', changes),
|
||||
changePassword: (current_password: string, new_password: string) =>
|
||||
send<{ ok: true }>('POST', '/auth/change-password', { current_password, new_password }),
|
||||
};
|
||||
|
||||
export const settingsApi = {
|
||||
public: () => get<PublicSettings>('/settings/public'),
|
||||
all: () => get<AdminSettings>('/settings'),
|
||||
save: (changes: Partial<AdminSettingsInput>) => send<{ ok: true }>('PUT', '/settings', changes),
|
||||
ssoTest: () => send<{ ok: true; sample: string[]; checked: number; eligible: number }>('POST', '/settings/sso/test'),
|
||||
};
|
||||
|
||||
export interface ExportSettings {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
accent: string;
|
||||
logo: string | null;
|
||||
header_right: string;
|
||||
footer_left: string;
|
||||
footer_center: string;
|
||||
footer_right: string;
|
||||
show_signature: boolean;
|
||||
signature_left: string;
|
||||
signature_right: string;
|
||||
}
|
||||
export interface SsoSettings {
|
||||
enabled: boolean;
|
||||
button_label: string;
|
||||
tenant_id: string;
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
client_secret_set?: boolean;
|
||||
configured?: boolean;
|
||||
redirect_uri: string;
|
||||
local_login: boolean;
|
||||
}
|
||||
export interface AdminSettings extends PublicSettings {
|
||||
export: ExportSettings;
|
||||
sso: SsoSettings;
|
||||
}
|
||||
export type AdminSettingsInput = Omit<AdminSettings, 'export' | 'sso'> & {
|
||||
export: Partial<ExportSettings>;
|
||||
sso: Partial<SsoSettings> & { clear_secret?: boolean };
|
||||
};
|
||||
|
||||
export interface AdUser {
|
||||
id: string;
|
||||
name: string;
|
||||
given_name: string | null;
|
||||
surname: string | null;
|
||||
email: string;
|
||||
job_title: string | null;
|
||||
department: string | null;
|
||||
pulse_user_id: number | null;
|
||||
pulse_role: Role | null;
|
||||
pulse_active: boolean | null;
|
||||
local_match_id: number | null;
|
||||
}
|
||||
|
||||
export const directoryApi = {
|
||||
list: (refresh = false) =>
|
||||
get<{ configured: boolean; domains: string[]; fetched_at: string | null; users: AdUser[] }>(`/directory/ad-users${refresh ? '?refresh=1' : ''}`),
|
||||
import: (ad_ids: string[], role: Role, manager_id: number | null) =>
|
||||
send<{ created: number[]; linked: number[]; skipped: string[] }>('POST', '/directory/import', { ad_ids, role, manager_id }),
|
||||
sync: () => send<{ total: number; eligible: number; linked: number; updated: number; deactivated: number }>('POST', '/directory/sync'),
|
||||
};
|
||||
|
||||
export const usersApi = {
|
||||
directory: () => get<DirectoryUser[]>('/users/directory'),
|
||||
list: () => get<{ users: ManagedUser[]; assignable_roles: Role[] }>('/users'),
|
||||
create: (data: Record<string, unknown>) => send<AppUser>('POST', '/users', data),
|
||||
update: (id: number, data: Record<string, unknown>) => send<AppUser>('PUT', `/users/${id}`, data),
|
||||
remove: (id: number) => send<{ ok: true }>('DELETE', `/users/${id}`),
|
||||
};
|
||||
|
||||
export interface CustomerOverview extends Customer {
|
||||
projects: Array<
|
||||
Pick<Project, 'id' | 'code' | 'name' | 'display_name' | 'type' | 'is_active' | 'used_minutes' | 'budget_hours' | 'earned' | 'open' | 'budget_value' | 'members' | 'end_date'>
|
||||
>;
|
||||
members: Project['members'];
|
||||
used_minutes: number;
|
||||
earned: number;
|
||||
open: number;
|
||||
monthly: number[];
|
||||
}
|
||||
|
||||
export const customersApi = {
|
||||
list: () => get<Customer[]>('/customers'),
|
||||
overview: () => get<{ months: string[]; customers: CustomerOverview[] }>('/customers/overview'),
|
||||
create: (data: Partial<Customer>) => send<Customer>('POST', '/customers', data),
|
||||
update: (id: number, data: Partial<Customer>) => send<Customer>('PUT', `/customers/${id}`, data),
|
||||
remove: (id: number) => send<{ ok: true }>('DELETE', `/customers/${id}`),
|
||||
};
|
||||
|
||||
export type PotInput = Partial<Pick<BudgetPot, 'label' | 'valid_from' | 'valid_to' | 'budget_amount' | 'rate' | 'budget_hours' | 'notes'>>;
|
||||
|
||||
export const projectsApi = {
|
||||
list: (scope?: 'mine') => get<Project[]>(`/projects${qs({ scope })}`),
|
||||
get: (id: number) => get<Project>(`/projects/${id}`),
|
||||
create: (data: Record<string, unknown>) => send<Project>('POST', '/projects', data),
|
||||
update: (id: number, data: Record<string, unknown>) => send<Project>('PUT', `/projects/${id}`, data),
|
||||
remove: (id: number) => send<{ ok: true }>('DELETE', `/projects/${id}`),
|
||||
setMembers: (id: number, user_ids: number[]) => send<Project>('PUT', `/projects/${id}/members`, { user_ids }),
|
||||
addPot: (id: number, pot: PotInput) => send<Project>('POST', `/projects/${id}/pots`, pot),
|
||||
updatePot: (id: number, potId: number, pot: PotInput) => send<Project>('PUT', `/projects/${id}/pots/${potId}`, pot),
|
||||
removePot: (id: number, potId: number) => send<Project>('DELETE', `/projects/${id}/pots/${potId}`),
|
||||
addTask: (id: number, task: { name: string; billable?: boolean }) => send<Project>('POST', `/projects/${id}/tasks`, task),
|
||||
updateTask: (id: number, taskId: number, task: Partial<{ name: string; billable: boolean; is_active: boolean }>) =>
|
||||
send<Project>('PUT', `/projects/${id}/tasks/${taskId}`, task),
|
||||
removeTask: (id: number, taskId: number) => send<Project>('DELETE', `/projects/${id}/tasks/${taskId}`),
|
||||
};
|
||||
|
||||
export const entriesApi = {
|
||||
list: (from: string, to: string, userId?: number) => get<TimeEntry[]>(`/entries${qs({ from, to, user_id: userId })}`),
|
||||
create: (data: { project_id: number; task_id: number | null; entry_date: string; minutes: number; note: string }) =>
|
||||
send<{ entry: TimeEntry; warning: string | null }>('POST', '/entries', data),
|
||||
update: (id: number, data: Partial<{ project_id: number; task_id: number | null; entry_date: string; minutes: number; note: string }>) =>
|
||||
send<{ entry: TimeEntry; warning: string | null }>('PUT', `/entries/${id}`, data),
|
||||
remove: (id: number) => send<{ ok: true }>('DELETE', `/entries/${id}`),
|
||||
};
|
||||
|
||||
export type Granularity = 'day' | 'week' | 'month' | 'quarter' | 'year';
|
||||
export type RankPeriod = 'days' | 'week' | 'month' | 'quarter' | 'year';
|
||||
|
||||
export interface SeriesBucket {
|
||||
from: string;
|
||||
to: string;
|
||||
label: string;
|
||||
minutes: number;
|
||||
billable_minutes: number;
|
||||
non_billable_minutes: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface DashboardData {
|
||||
granularity: Granularity;
|
||||
rank: RankPeriod;
|
||||
rank_range: { from: string; to: string };
|
||||
scope: 'all' | 'team';
|
||||
team_size: number;
|
||||
series: SeriesBucket[];
|
||||
kpis: {
|
||||
current: SeriesBucket & { capacity_minutes: number };
|
||||
previous: SeriesBucket | null;
|
||||
period_label: string;
|
||||
previous_label: string | null;
|
||||
};
|
||||
ranking: Array<{
|
||||
user_id: number;
|
||||
name: string;
|
||||
color: string | null;
|
||||
job_title: string | null;
|
||||
is_external: boolean;
|
||||
minutes: number;
|
||||
billable_minutes: number;
|
||||
non_billable_minutes: number;
|
||||
amount: number;
|
||||
entries: number;
|
||||
capacity_minutes: number;
|
||||
}>;
|
||||
projects: Array<{ project_id: number; name: string; customer_color: string | null; minutes: number; billable_minutes: number }>;
|
||||
recent: Array<{
|
||||
id: number;
|
||||
user_id: number;
|
||||
user_name: string;
|
||||
user_color: string | null;
|
||||
project_display: string;
|
||||
customer_color: string | null;
|
||||
task_name: string | null;
|
||||
note: string | null;
|
||||
entry_date: string;
|
||||
minutes: number;
|
||||
billable: boolean;
|
||||
created_at: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const dashboardApi = {
|
||||
get: (granularity: Granularity, rank: RankPeriod) => get<DashboardData>(`/dashboard${qs({ granularity, rank })}`),
|
||||
};
|
||||
|
||||
export interface GroupRow {
|
||||
key: string | number;
|
||||
name: string;
|
||||
color?: string | null;
|
||||
minutes: number;
|
||||
billable_minutes: number;
|
||||
non_billable_minutes: number;
|
||||
amount: number;
|
||||
user_id?: number;
|
||||
customer_id?: number;
|
||||
project_id?: number;
|
||||
customer_name?: string;
|
||||
code?: string;
|
||||
type?: string;
|
||||
is_external?: boolean;
|
||||
capacity_minutes?: number;
|
||||
utilization?: number | null;
|
||||
}
|
||||
|
||||
export interface TimeReport {
|
||||
range: { from: string; to: string };
|
||||
include_fixed: boolean;
|
||||
summary: {
|
||||
minutes: number;
|
||||
billable_minutes: number;
|
||||
non_billable_minutes: number;
|
||||
amount: number;
|
||||
amount_without_fixed: number;
|
||||
fixed_amount: number;
|
||||
entries: number;
|
||||
};
|
||||
clients: GroupRow[];
|
||||
projects: GroupRow[];
|
||||
tasks: GroupRow[];
|
||||
users: GroupRow[];
|
||||
}
|
||||
|
||||
export interface ProjectDetail {
|
||||
project: Project;
|
||||
totals: { minutes: number; billable_minutes: number; non_billable_minutes: number; amount: number };
|
||||
weekly: Array<{ week_start: string; label: string; minutes: number; billable_minutes: number; cumulative_minutes: number | null }>;
|
||||
tasks: GroupRow[];
|
||||
team: GroupRow[];
|
||||
pots: Array<BudgetPot & { tasks: GroupRow[]; team: GroupRow[] }>;
|
||||
}
|
||||
|
||||
export interface CustomerReport {
|
||||
customer: Customer;
|
||||
totals: {
|
||||
minutes: number;
|
||||
billable_minutes: number;
|
||||
non_billable_minutes: number;
|
||||
amount: number;
|
||||
earned: number;
|
||||
open: number;
|
||||
budget_value: number;
|
||||
};
|
||||
series: Array<{ month: string; minutes: number; billable_minutes: number; amount: number }>;
|
||||
projects: Array<Omit<ProjectDetail, 'weekly'>>;
|
||||
team: GroupRow[];
|
||||
tasks: GroupRow[];
|
||||
}
|
||||
|
||||
export const reportsApi = {
|
||||
time: (params: { from: string; to: string; customer_id?: number | null; team?: number | null; include_fixed?: boolean }) =>
|
||||
get<TimeReport>(`/reports/time${qs({ ...params, include_fixed: params.include_fixed ? 1 : undefined })}`),
|
||||
project: (id: number) => get<ProjectDetail>(`/reports/project/${id}`),
|
||||
customer: (id: number) => get<CustomerReport>(`/reports/customer/${id}`),
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import type { AppUser, PublicSettings } from '../types';
|
||||
import { authApi, clearToken, getToken, setToken, setUnauthorizedHandler, settingsApi } from './api';
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AppUser | null;
|
||||
settings: PublicSettings;
|
||||
isLoading: boolean;
|
||||
login: (identifier: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
updateUser: (user: AppUser) => void;
|
||||
reloadSettings: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: PublicSettings = {
|
||||
weekly_hours: 40,
|
||||
hours_per_day: 8,
|
||||
budget_warn_percent: 80,
|
||||
budget_warn_days: 14,
|
||||
company_name: 'perinco GmbH',
|
||||
project_code_pattern: '{KUERZEL}-{JAHR}-{NR}',
|
||||
project_name_pattern: '{KUNDE} - {PROJEKT}',
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* SSO: der Callback des Backends leitet auf /#sso_token=... zurueck. Das
|
||||
* Fragment wird sofort aus der Adresszeile entfernt, damit das Token weder im
|
||||
* Verlauf noch in einem Lesezeichen landet.
|
||||
*/
|
||||
function takeSsoToken() {
|
||||
const match = window.location.hash.match(/sso_token=([^&]+)/);
|
||||
if (!match) return;
|
||||
setToken(decodeURIComponent(match[1]));
|
||||
resetRoute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Nach einer Anmeldung startet jeder auf der Startseite seiner Rolle - auch
|
||||
* wenn noch eine Seite vom letzten Mal in der Adresse steht. Ein Neuladen mit
|
||||
* gueltiger Sitzung bleibt dagegen, wo es ist.
|
||||
*/
|
||||
function resetRoute() {
|
||||
window.history.replaceState(null, '', window.location.pathname);
|
||||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
}
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<AppUser | null>(null);
|
||||
const [settings, setSettings] = useState<PublicSettings>(DEFAULT_SETTINGS);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearToken();
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setUnauthorizedHandler(() => setUser(null));
|
||||
return () => setUnauthorizedHandler(null);
|
||||
}, []);
|
||||
|
||||
const reloadSettings = useCallback(() => {
|
||||
settingsApi
|
||||
.public()
|
||||
.then(setSettings)
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
takeSsoToken();
|
||||
(async () => {
|
||||
if (!getToken()) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { user: current } = await authApi.me();
|
||||
if (!cancelled) setUser(current);
|
||||
} catch {
|
||||
clearToken();
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) reloadSettings();
|
||||
}, [user?.id, reloadSettings]);
|
||||
|
||||
const login = useCallback(async (identifier: string, password: string) => {
|
||||
const { token, user: loggedIn } = await authApi.login(identifier, password);
|
||||
setToken(token);
|
||||
resetRoute();
|
||||
setUser(loggedIn);
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({ user, settings, isLoading, login, logout, updateUser: setUser, reloadSettings }),
|
||||
[user, settings, isLoading, login, logout, reloadSettings]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth muss innerhalb von <AuthProvider> verwendet werden.');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* Zeit- und Datumsformate. Datumswerte laufen als 'YYYY-MM-DD' durch die App
|
||||
* und werden nur zur Anzeige umgewandelt - so verrutscht nichts durch Zeitzonen.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Liest eine Zeiteingabe in Minuten.
|
||||
* "1:23" -> 83 Stunden:Minuten
|
||||
* "1,5" -> 90 Dezimalstunden mit Komma
|
||||
* "1.5" -> 90 ... oder Punkt
|
||||
* "2" -> 120 ganze Stunden
|
||||
* ":45" -> 45
|
||||
* "1h 30m", "90m", "45 min" -> werden ebenfalls verstanden
|
||||
* Liefert null bei ungueltiger Eingabe.
|
||||
*/
|
||||
export function parseDuration(input: string): number | null {
|
||||
const raw = input.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
if (!raw) return null;
|
||||
|
||||
let m = raw.match(/^(\d{0,2}):(\d{1,2})$/);
|
||||
if (m) {
|
||||
const hours = Number(m[1] || 0);
|
||||
const minutes = Number(m[2]);
|
||||
if (minutes >= 60) return null;
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
m = raw.match(/^(\d{1,2})(?:[.,](\d{1,2}))?\s*h?$/);
|
||||
if (m) {
|
||||
const value = Number(`${m[1]}.${m[2] || 0}`);
|
||||
return Math.round(value * 60);
|
||||
}
|
||||
m = raw.match(/^[.,](\d{1,2})\s*h?$/);
|
||||
if (m) return Math.round(Number(`0.${m[1]}`) * 60);
|
||||
m = raw.match(/^(?:(\d{1,2})\s*h)?\s*(?:(\d{1,3})\s*(?:m|min))?$/);
|
||||
if (m && (m[1] || m[2])) return Number(m[1] || 0) * 60 + Number(m[2] || 0);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 83 -> "1:23" */
|
||||
export function formatDuration(minutes: number): string {
|
||||
const total = Math.round(Math.max(0, minutes));
|
||||
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** 90 -> "1,50" */
|
||||
export function formatHours(minutes: number, digits = 2): string {
|
||||
return (minutes / 60).toLocaleString('de-DE', { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||||
}
|
||||
|
||||
export function formatNumber(value: number, digits = 0): string {
|
||||
return value.toLocaleString('de-DE', { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||||
}
|
||||
|
||||
export function formatEuro(value: number | null | undefined, digits = 0): string {
|
||||
if (value == null) return '–';
|
||||
return `${value.toLocaleString('de-DE', { minimumFractionDigits: digits, maximumFractionDigits: digits })} €`;
|
||||
}
|
||||
|
||||
export function compactEuro(value: number): string {
|
||||
if (Math.abs(value) >= 1_000_000) return `${formatNumber(value / 1_000_000, 1)} Mio. €`;
|
||||
if (Math.abs(value) >= 10_000) return `${formatNumber(value / 1000, 0)} T€`;
|
||||
return formatEuro(value);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- Datum -- */
|
||||
|
||||
export function toIso(date: Date): string {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
export function fromIso(value: string): Date {
|
||||
const [y, m, d] = value.slice(0, 10).split('-').map(Number);
|
||||
return new Date(y, m - 1, d);
|
||||
}
|
||||
|
||||
export function todayIso(): string {
|
||||
return toIso(new Date());
|
||||
}
|
||||
|
||||
export function addDays(value: string, days: number): string {
|
||||
const date = fromIso(value);
|
||||
date.setDate(date.getDate() + days);
|
||||
return toIso(date);
|
||||
}
|
||||
|
||||
export function addMonths(value: string, months: number): string {
|
||||
const date = fromIso(value);
|
||||
date.setDate(1);
|
||||
date.setMonth(date.getMonth() + months);
|
||||
return toIso(date);
|
||||
}
|
||||
|
||||
export function mondayOf(value: string): string {
|
||||
const date = fromIso(value);
|
||||
const day = date.getDay() || 7;
|
||||
date.setDate(date.getDate() - day + 1);
|
||||
return toIso(date);
|
||||
}
|
||||
|
||||
export function monthStart(value: string): string {
|
||||
return `${value.slice(0, 7)}-01`;
|
||||
}
|
||||
|
||||
export function monthEnd(value: string): string {
|
||||
const date = fromIso(monthStart(value));
|
||||
date.setMonth(date.getMonth() + 1);
|
||||
date.setDate(0);
|
||||
return toIso(date);
|
||||
}
|
||||
|
||||
export function isoWeek(value: string): number {
|
||||
const date = fromIso(value);
|
||||
const day = date.getDay() || 7;
|
||||
date.setDate(date.getDate() + 4 - day);
|
||||
const yearStart = new Date(date.getFullYear(), 0, 1);
|
||||
return Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
||||
}
|
||||
|
||||
export const WEEKDAYS_SHORT = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||
export const WEEKDAYS_LONG = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'];
|
||||
export const MONTHS = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
|
||||
|
||||
/** 0 = Montag */
|
||||
export function weekdayIndex(value: string): number {
|
||||
return (fromIso(value).getDay() + 6) % 7;
|
||||
}
|
||||
|
||||
export function formatDate(value: string | null | undefined, style: 'short' | 'long' | 'day' = 'short'): string {
|
||||
if (!value) return '–';
|
||||
const date = fromIso(value);
|
||||
if (style === 'long') {
|
||||
return `${WEEKDAYS_LONG[weekdayIndex(value)]}, ${date.getDate()}. ${MONTHS[date.getMonth()]} ${date.getFullYear()}`;
|
||||
}
|
||||
if (style === 'day') return `${WEEKDAYS_SHORT[weekdayIndex(value)]} ${date.getDate()}.${date.getMonth() + 1}.`;
|
||||
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
export function relativeTime(value: string): string {
|
||||
const date = new Date(value.replace(' ', 'T'));
|
||||
const diff = (Date.now() - date.getTime()) / 1000;
|
||||
if (Number.isNaN(diff)) return '';
|
||||
if (diff < 60) return 'gerade eben';
|
||||
if (diff < 3600) return `vor ${Math.floor(diff / 60)} Min.`;
|
||||
if (diff < 86400) return `vor ${Math.floor(diff / 3600)} Std.`;
|
||||
const days = Math.floor(diff / 86400);
|
||||
return days === 1 ? 'gestern' : `vor ${days} Tagen`;
|
||||
}
|
||||
|
||||
export function initials(name: string): string {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase())
|
||||
.join('');
|
||||
}
|
||||
|
||||
/** Stabile Farbe aus einem Namen, wenn keine gesetzt ist. */
|
||||
const PALETTE = ['#6ba04a', '#0ea5e9', '#8b5cf6', '#f97316', '#14b8a6', '#e11d48', '#eab308', '#6366f1', '#10b981', '#ec4899'];
|
||||
export function colorFor(key: string | number, preferred?: string | null): string {
|
||||
if (preferred) return preferred;
|
||||
const text = String(key);
|
||||
let hash = 0;
|
||||
for (let i = 0; i < text.length; i++) hash = (hash * 31 + text.charCodeAt(i)) | 0;
|
||||
return PALETTE[Math.abs(hash) % PALETTE.length];
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Mobile Ansicht: schmales Fenster ODER ein Touch-Geraet mit Mobil-Kennung.
|
||||
* Beides zaehlt, damit ein Tablet quer die Desktopansicht bekommt, ein Handy
|
||||
* quer aber nicht.
|
||||
*/
|
||||
function detectMobile() {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const narrow = window.innerWidth < 768;
|
||||
const ua = /Android|iPhone|iPod|Mobile|Windows Phone/i.test(navigator.userAgent);
|
||||
const coarse = window.matchMedia?.('(pointer: coarse)').matches;
|
||||
return narrow || (ua && coarse && window.innerWidth < 1024);
|
||||
}
|
||||
|
||||
export function useIsMobile() {
|
||||
const [mobile, setMobile] = useState(detectMobile);
|
||||
useEffect(() => {
|
||||
const onResize = () => setMobile(detectMobile());
|
||||
window.addEventListener('resize', onResize);
|
||||
window.addEventListener('orientationchange', onResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('orientationchange', onResize);
|
||||
};
|
||||
}, []);
|
||||
return mobile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Laedt Daten und haelt Lade- und Fehlerzustand. `deps` loest neu aus,
|
||||
* `reload` laedt still (ohne Ladeanzeige) nach.
|
||||
*/
|
||||
export function useLoader<T>(load: () => Promise<T>, deps: unknown[]) {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const loadRef = useRef(load);
|
||||
loadRef.current = load;
|
||||
const counter = useRef(0);
|
||||
|
||||
const run = useCallback((silent = false) => {
|
||||
const id = ++counter.current;
|
||||
if (!silent) setLoading(true);
|
||||
return loadRef
|
||||
.current()
|
||||
.then((result) => {
|
||||
if (id !== counter.current) return;
|
||||
setData(result);
|
||||
setError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (id !== counter.current) return;
|
||||
setError(err instanceof Error ? err.message : 'Laden fehlgeschlagen.');
|
||||
})
|
||||
.finally(() => {
|
||||
if (id === counter.current) setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
run();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
|
||||
return { data, error, loading, reload: () => run(true), setData };
|
||||
}
|
||||
|
||||
/** Zaehlt eine Zahl weich hoch (ease-out), respektiert reduzierte Bewegung. */
|
||||
export function useCountUp(target: number, duration = 800): number {
|
||||
const [value, setValue] = useState(0);
|
||||
const fromRef = useRef(0);
|
||||
useEffect(() => {
|
||||
const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||
if (reduce || duration <= 0) {
|
||||
fromRef.current = target;
|
||||
setValue(target);
|
||||
return;
|
||||
}
|
||||
const from = fromRef.current;
|
||||
const start = performance.now();
|
||||
let frame = 0;
|
||||
const step = (now: number) => {
|
||||
const progress = Math.min(1, (now - start) / duration);
|
||||
const eased = 1 - (1 - progress) ** 3;
|
||||
setValue(from + (target - from) * eased);
|
||||
if (progress < 1) frame = requestAnimationFrame(step);
|
||||
else fromRef.current = target;
|
||||
};
|
||||
frame = requestAnimationFrame(step);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [target, duration]);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** true einen Frame nach dem Mount bzw. nach Aenderung von `key` - fuer Wachstumsanimationen. */
|
||||
export function useGrow(key: unknown) {
|
||||
const [grown, setGrown] = useState(false);
|
||||
useEffect(() => {
|
||||
setGrown(false);
|
||||
const timer = setTimeout(() => setGrown(true), 40);
|
||||
return () => clearTimeout(timer);
|
||||
}, [key]);
|
||||
return grown;
|
||||
}
|
||||
|
||||
export function usePersistentState<T>(key: string, initial: T) {
|
||||
const [value, setValue] = useState<T>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
return stored ? (JSON.parse(stored) as T) : initial;
|
||||
} catch {
|
||||
return initial;
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
/* egal */
|
||||
}
|
||||
}, [key, value]);
|
||||
return [value, setValue] as const;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import type { ViewKey } from '../types';
|
||||
|
||||
/*
|
||||
* Kleiner Hash-Router: #/reports/projekt/12. Reicht fuer eine SPA hinter
|
||||
* Apache ohne Serverkonfiguration, und der Zurueck-Knopf des Handys
|
||||
* funktioniert wie erwartet.
|
||||
*/
|
||||
export interface Route {
|
||||
view: ViewKey | null;
|
||||
params: string[];
|
||||
}
|
||||
|
||||
function parse(): Route {
|
||||
const hash = window.location.hash.replace(/^#\/?/, '');
|
||||
if (hash.startsWith('sso_token')) return { view: null, params: [] };
|
||||
const [view, ...params] = hash.split('/').filter(Boolean).map(decodeURIComponent);
|
||||
return { view: (view as ViewKey) || null, params };
|
||||
}
|
||||
|
||||
export function useHashRoute() {
|
||||
const [route, setRoute] = useState<Route>(parse);
|
||||
useEffect(() => {
|
||||
const onChange = () => setRoute(parse());
|
||||
window.addEventListener('hashchange', onChange);
|
||||
return () => window.removeEventListener('hashchange', onChange);
|
||||
}, []);
|
||||
const navigate = useCallback((path: string, replace = false) => {
|
||||
const target = `#/${path.replace(/^\/+/, '')}`;
|
||||
if (replace) {
|
||||
window.history.replaceState(null, '', target);
|
||||
setRoute(parse());
|
||||
} else if (window.location.hash !== target) {
|
||||
window.location.hash = target;
|
||||
}
|
||||
}, []);
|
||||
return { route, navigate };
|
||||
}
|
||||
|
||||
export const RouterContext = createContext<{ route: Route; navigate: (path: string, replace?: boolean) => void } | null>(null);
|
||||
|
||||
export function useRouter() {
|
||||
const ctx = useContext(RouterContext);
|
||||
if (!ctx) throw new Error('useRouter ausserhalb des RouterContext');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { authApi } from './api';
|
||||
import { useAuth } from './auth';
|
||||
import { THEME_PALETTES, type ThemePalette } from '../types';
|
||||
import { RainyWeather } from '../components/layout/RainyWeather';
|
||||
import { SnowWeather } from '../components/layout/SnowWeather';
|
||||
import { AuraSky } from '../components/layout/AuraSky';
|
||||
|
||||
/*
|
||||
* Darstellung der Oberflaeche. Sie hat zwei unabhaengige Achsen:
|
||||
*
|
||||
* Farbwelt (palette) perinco | pixel | outdoor | cyberpunk | rainy | snow | aura
|
||||
* -> Klasse theme-* am <html>
|
||||
* Modus (mode) hell | dunkel -> Klasse dark am <html>
|
||||
*
|
||||
* Beide gehoeren zum **Benutzer** und nicht zum Browser: sie stehen in
|
||||
* pulse_users.theme und pulse_users.theme_mode. Wer sich an einem anderen Rechner
|
||||
* anmeldet, findet seine Wahl wieder, und melden sich zwei Leute an derselben
|
||||
* Maschine an, sieht jeder seine eigene.
|
||||
*
|
||||
* Der localStorage bleibt als Zwischenspeicher daneben liegen, aber nicht als
|
||||
* Quelle der Wahrheit: er wird vom Inline-Skript in index.html gelesen, um die
|
||||
* Klassen vor dem ersten Frame zu setzen. Ohne das blitzt beim Laden das
|
||||
* Standarddesign auf, weil das Bundle erst danach laeuft. Er hat genau eine
|
||||
* sichtbare Folge: melden sich zwei Benutzer an demselben Rechner an, sieht der
|
||||
* zweite fuer einen Augenblick das Design des ersten.
|
||||
*/
|
||||
|
||||
export type ThemeMode = 'light' | 'dark';
|
||||
|
||||
/** null = der Systemeinstellung folgen */
|
||||
type ModePreference = ThemeMode | null;
|
||||
|
||||
const MODE_KEY = 'pulse_theme';
|
||||
const PALETTE_KEY = 'pulse_palette';
|
||||
|
||||
const DEFAULT_PALETTE: ThemePalette = 'perinco';
|
||||
|
||||
interface ThemeContextValue {
|
||||
/** Tatsaechlich aktive Darstellung. */
|
||||
theme: ThemeMode;
|
||||
/** Gespeicherte Wahl des Modus; null bedeutet "folgt dem System". */
|
||||
preference: ModePreference;
|
||||
/** Aktive Farbwelt. */
|
||||
palette: ThemePalette;
|
||||
/** Schaltet explizit auf den jeweils anderen Modus. */
|
||||
toggle: () => void;
|
||||
/** Zurueck zur Systemeinstellung. */
|
||||
useSystem: () => void;
|
||||
/** Farbwelt wechseln. */
|
||||
setPalette: (palette: ThemePalette) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
function readCachedMode(): ModePreference {
|
||||
try {
|
||||
const stored = localStorage.getItem(MODE_KEY);
|
||||
return stored === 'light' || stored === 'dark' ? stored : null;
|
||||
} catch {
|
||||
// Privater Modus ohne localStorage - dann eben ohne Zwischenspeicher.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readCachedPalette(): ThemePalette {
|
||||
try {
|
||||
const stored = localStorage.getItem(PALETTE_KEY);
|
||||
return THEME_PALETTES.includes(stored as ThemePalette)
|
||||
? (stored as ThemePalette)
|
||||
: DEFAULT_PALETTE;
|
||||
} catch {
|
||||
return DEFAULT_PALETTE;
|
||||
}
|
||||
}
|
||||
|
||||
/** Schreibt in den Zwischenspeicher fuers naechste Laden; Fehler sind egal. */
|
||||
function cache(key: string, value: string | null) {
|
||||
try {
|
||||
if (value === null) localStorage.removeItem(key);
|
||||
else localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// Nicht speicherbar - dann blitzt beim naechsten Laden eben kurz das
|
||||
// Standarddesign auf. Die Wahl selbst liegt in der Datenbank.
|
||||
}
|
||||
}
|
||||
|
||||
function systemTheme(): ThemeMode {
|
||||
return typeof window !== 'undefined' &&
|
||||
window.matchMedia?.('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt die Klassen, an denen die Farbwerte in index.css haengen. Das
|
||||
* Inline-Skript in index.html macht dasselbe schon vor dem ersten Frame.
|
||||
*
|
||||
* Die Klassen aller anderen Farbwelten werden entfernt, nicht nur die eigene
|
||||
* gesetzt: sonst bliebe beim Wechsel die vorige stehen und zwei Themes
|
||||
* ueberlagerten sich.
|
||||
*/
|
||||
function applyTheme(mode: ThemeMode, palette: ThemePalette) {
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle('dark', mode === 'dark');
|
||||
root.style.colorScheme = mode;
|
||||
|
||||
for (const candidate of THEME_PALETTES) {
|
||||
root.classList.toggle(`theme-${candidate}`, candidate === palette);
|
||||
}
|
||||
}
|
||||
|
||||
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { user, updateUser } = useAuth();
|
||||
|
||||
const [preference, setPreference] = useState<ModePreference>(() => readCachedMode());
|
||||
const [palette, setPaletteState] = useState<ThemePalette>(() => readCachedPalette());
|
||||
const [resolvedSystem, setResolvedSystem] = useState<ThemeMode>(() => systemTheme());
|
||||
|
||||
const theme = preference ?? resolvedSystem;
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme, palette);
|
||||
}, [theme, palette]);
|
||||
|
||||
// Ohne eigene Wahl folgt die App einer Aenderung im Betriebssystem sofort.
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia?.('(prefers-color-scheme: dark)');
|
||||
if (!query) return;
|
||||
const onChange = (event: MediaQueryListEvent) =>
|
||||
setResolvedSystem(event.matches ? 'dark' : 'light');
|
||||
query.addEventListener('change', onChange);
|
||||
return () => query.removeEventListener('change', onChange);
|
||||
}, []);
|
||||
|
||||
/*
|
||||
* Die Wahl des angemeldeten Benutzers uebernehmen - einmal je Anmeldung.
|
||||
*
|
||||
* Der Merker ist wichtig: `user` aendert sich auch, wenn jemand sein Foto
|
||||
* tauscht oder gerade das Design umstellt (updateUser). Ohne ihn wuerde
|
||||
* dieser Effekt bei jeder solchen Aenderung erneut laufen und koennte eine
|
||||
* gerade getroffene Wahl mit dem alten Stand ueberschreiben.
|
||||
*/
|
||||
const syncedForUser = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
// Abmelden: die Anzeige bleibt, wie sie ist. Der naechste Benutzer
|
||||
// bekommt beim Anmelden seine eigene Wahl.
|
||||
syncedForUser.current = null;
|
||||
return;
|
||||
}
|
||||
if (syncedForUser.current === user.id) return;
|
||||
syncedForUser.current = user.id;
|
||||
|
||||
const nextPalette = THEME_PALETTES.includes(user.theme as ThemePalette)
|
||||
? (user.theme as ThemePalette)
|
||||
: DEFAULT_PALETTE;
|
||||
const nextMode: ModePreference =
|
||||
user.theme_mode === 'light' || user.theme_mode === 'dark' ? user.theme_mode : null;
|
||||
|
||||
setPaletteState(nextPalette);
|
||||
setPreference(nextMode);
|
||||
cache(PALETTE_KEY, nextPalette === DEFAULT_PALETTE ? null : nextPalette);
|
||||
cache(MODE_KEY, nextMode);
|
||||
}, [user]);
|
||||
|
||||
/*
|
||||
* Speichern am Benutzer. Die Anzeige wechselt sofort und wartet nicht auf das
|
||||
* Backend - ein Design, das nach dem Klick eine halbe Sekunde ueberlegt,
|
||||
* fuehlt sich kaputt an. Schlaegt das Speichern fehl, gilt die Wahl fuer
|
||||
* diese Sitzung und ist nach dem naechsten Anmelden weg; das ist harmlos und
|
||||
* deshalb keine Fehlermeldung wert.
|
||||
*/
|
||||
const persist = useCallback(
|
||||
(changes: { theme?: ThemePalette | null; theme_mode?: ModePreference }) => {
|
||||
if (!user) return;
|
||||
authApi
|
||||
.updateMe(changes)
|
||||
.then(({ user: saved }) => updateUser(saved))
|
||||
.catch((err) => {
|
||||
console.warn('[Design] Konnte nicht gespeichert werden:', err);
|
||||
});
|
||||
},
|
||||
[user, updateUser]
|
||||
);
|
||||
|
||||
const storeMode = useCallback(
|
||||
(next: ModePreference) => {
|
||||
setPreference(next);
|
||||
cache(MODE_KEY, next);
|
||||
persist({ theme_mode: next });
|
||||
},
|
||||
[persist]
|
||||
);
|
||||
|
||||
const setPalette = useCallback(
|
||||
(next: ThemePalette) => {
|
||||
setPaletteState(next);
|
||||
cache(PALETTE_KEY, next === DEFAULT_PALETTE ? null : next);
|
||||
// Der Standard wird als null gespeichert, damit "keine Wahl getroffen"
|
||||
// und "ausdruecklich perinco" in der Datenbank dasselbe bedeuten.
|
||||
persist({ theme: next === DEFAULT_PALETTE ? null : next });
|
||||
},
|
||||
[persist]
|
||||
);
|
||||
|
||||
const value = useMemo<ThemeContextValue>(
|
||||
() => ({
|
||||
theme,
|
||||
preference,
|
||||
palette,
|
||||
toggle: () => storeMode(theme === 'dark' ? 'light' : 'dark'),
|
||||
useSystem: () => storeMode(null),
|
||||
setPalette,
|
||||
}),
|
||||
[theme, preference, palette, storeMode, setPalette]
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={value}>
|
||||
{children}
|
||||
{/* Wetter und Himmel gehoeren zum Theme, nicht zu einer Ansicht. */}
|
||||
{palette === 'rainy' && <RainyWeather />}
|
||||
{palette === 'snow' && <SnowWeather />}
|
||||
{palette === 'aura' && <AuraSky />}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error('useTheme muss innerhalb von <ThemeProvider> verwendet werden.');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import { ToastProvider } from './components/Toast';
|
||||
import { AuthProvider } from './lib/auth';
|
||||
import { ThemeProvider } from './lib/theme';
|
||||
|
||||
const container = document.getElementById('root');
|
||||
if (!container) throw new Error('Root-Element #root nicht gefunden.');
|
||||
|
||||
// ThemeProvider unter AuthProvider: das Design haengt am Benutzer.
|
||||
createRoot(container).render(
|
||||
<React.StrictMode>
|
||||
<ToastProvider>
|
||||
<AuthProvider>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</ToastProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
export const THEME_PALETTES = ['perinco', 'pixel', 'outdoor', 'cyberpunk', 'rainy', 'snow', 'aura'] as const;
|
||||
export type ThemePalette = (typeof THEME_PALETTES)[number];
|
||||
|
||||
export const THEME_PALETTE_LABELS: Record<ThemePalette, { name: string; hint: string }> = {
|
||||
perinco: { name: 'perinco', hint: 'Das Design des Hauses - Blattgrün auf neutralem Grau' },
|
||||
pixel: { name: 'Pixel Art', hint: '8-bit: eckige Kanten, harte Schatten, Pixelschrift' },
|
||||
outdoor: { name: 'Outdoor', hint: 'Wanderkarte: Sandpapier, Bergsee-Türkis, weiche Ecken' },
|
||||
cyberpunk: { name: 'Cyberpunk', hint: 'Neon in der Nacht: scharfe Kanten, violettes Leuchten' },
|
||||
rainy: { name: 'Rainy', hint: 'Regenwetter: Nieselregen, Wolken mit Wetterleuchten, Schieferblau' },
|
||||
snow: { name: 'Snow', hint: 'Winter: Schneefall, der sich unten sammelt, Eisblau' },
|
||||
aura: { name: 'Aura', hint: 'Hell Sunshine mit Sonnenstrahlen, dunkel Mond mit Sternen - ziehende Wolken' },
|
||||
};
|
||||
|
||||
export type Role = 'user' | 'lead' | 'management' | 'admin';
|
||||
|
||||
export const ROLE_LABELS: Record<Role, string> = {
|
||||
user: 'Mitarbeiter',
|
||||
lead: 'Vorgesetzter',
|
||||
management: 'Management',
|
||||
admin: 'Administrator',
|
||||
};
|
||||
|
||||
export const ROLE_RANK: Record<Role, number> = { user: 0, lead: 1, management: 2, admin: 3 };
|
||||
|
||||
export function atLeast(user: { role: Role } | null | undefined, role: Role) {
|
||||
return !!user && ROLE_RANK[user.role] >= ROLE_RANK[role];
|
||||
}
|
||||
|
||||
export interface AppUser {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string | null;
|
||||
name: string;
|
||||
role: Role;
|
||||
manager_id: number | null;
|
||||
job_title: string | null;
|
||||
is_external: boolean;
|
||||
company: string | null;
|
||||
source: 'local' | 'sso';
|
||||
weekly_hours: number | null;
|
||||
color: string | null;
|
||||
theme: ThemePalette | null;
|
||||
theme_mode: 'light' | 'dark' | null;
|
||||
is_active: boolean;
|
||||
last_login_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ManagedUser extends AppUser {
|
||||
entry_count: number;
|
||||
project_count: number;
|
||||
can_manage: boolean;
|
||||
in_team: boolean;
|
||||
}
|
||||
|
||||
export interface DirectoryUser {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string | null;
|
||||
role: Role;
|
||||
manager_id: number | null;
|
||||
is_external: boolean;
|
||||
company: string | null;
|
||||
job_title: string | null;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface Customer {
|
||||
id: number;
|
||||
name: string;
|
||||
short_name: string;
|
||||
contact_person: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
street: string | null;
|
||||
zip: string | null;
|
||||
city: string | null;
|
||||
country: string | null;
|
||||
notes: string | null;
|
||||
color: string | null;
|
||||
is_active: boolean | number;
|
||||
project_count?: number;
|
||||
}
|
||||
|
||||
export type ProjectType = 'billable' | 'non_billable' | 'fixed';
|
||||
|
||||
export const PROJECT_TYPE_LABELS: Record<ProjectType, string> = {
|
||||
billable: 'Billable',
|
||||
non_billable: 'Non-billable',
|
||||
fixed: 'Festpreis',
|
||||
};
|
||||
|
||||
export type PotStatus = 'upcoming' | 'active' | 'expired' | 'exhausted';
|
||||
|
||||
export interface BudgetPot {
|
||||
id: number;
|
||||
project_id: number;
|
||||
label: string;
|
||||
valid_from: string;
|
||||
valid_to: string;
|
||||
budget_amount: number | null;
|
||||
rate: number | null;
|
||||
budget_hours: number | null;
|
||||
notes: string | null;
|
||||
/** Minuten, die gegen das Volumen zaehlen (Billable-Projekt: nur abrechenbare). */
|
||||
used_minutes: number;
|
||||
/** Alle gebuchten Minuten auf dem Topf. */
|
||||
booked_minutes: number;
|
||||
used_hours: number;
|
||||
remaining_hours: number | null;
|
||||
percent_used: number | null;
|
||||
hourly_rate: number | null;
|
||||
virtual_rate: number | null;
|
||||
budget_value: number | null;
|
||||
earned: number | null;
|
||||
open: number | null;
|
||||
days_left: number;
|
||||
status: PotStatus;
|
||||
warning: string | null;
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: number;
|
||||
project_id: number;
|
||||
name: string;
|
||||
billable: boolean;
|
||||
is_active: boolean;
|
||||
used_minutes: number;
|
||||
}
|
||||
|
||||
export interface MemberRef {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string | null;
|
||||
is_external: boolean;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: number;
|
||||
customer_id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
type: ProjectType;
|
||||
billing_unit: 'hour' | 'day';
|
||||
start_date: string;
|
||||
end_date: string | null;
|
||||
tags: string[];
|
||||
notes: string | null;
|
||||
owner_id: number | null;
|
||||
owner_name: string | null;
|
||||
is_active: boolean;
|
||||
customer: { id: number; name: string; short_name: string; color: string | null };
|
||||
pots: BudgetPot[];
|
||||
tasks: Task[];
|
||||
members: MemberRef[];
|
||||
used_minutes: number;
|
||||
billable_minutes: number;
|
||||
budget_used_minutes: number;
|
||||
last_entry: string | null;
|
||||
budget_hours: number | null;
|
||||
remaining_hours: number | null;
|
||||
budget_value: number | null;
|
||||
earned: number | null;
|
||||
open: number | null;
|
||||
}
|
||||
|
||||
export interface TimeEntry {
|
||||
id: number;
|
||||
user_id: number;
|
||||
user_name: string;
|
||||
project_id: number;
|
||||
project_name: string;
|
||||
project_code: string;
|
||||
project_display: string;
|
||||
project_type: ProjectType;
|
||||
customer_id: number;
|
||||
customer_name: string;
|
||||
customer_color: string | null;
|
||||
task_id: number | null;
|
||||
task_name: string | null;
|
||||
pot_id: number | null;
|
||||
pot_label: string | null;
|
||||
entry_date: string;
|
||||
minutes: number;
|
||||
note: string | null;
|
||||
billable: boolean;
|
||||
amount: number | null;
|
||||
}
|
||||
|
||||
export interface PublicSettings {
|
||||
weekly_hours: number;
|
||||
hours_per_day: number;
|
||||
budget_warn_percent: number;
|
||||
budget_warn_days: number;
|
||||
company_name: string;
|
||||
project_code_pattern: string;
|
||||
project_name_pattern: string;
|
||||
}
|
||||
|
||||
export type ViewKey = 'dashboard' | 'buchung' | 'projekte' | 'team' | 'kunden' | 'reports' | 'einstellungen';
|
||||
|
||||
export const VIEW_LABELS: Record<ViewKey, string> = {
|
||||
dashboard: 'Dashboard',
|
||||
buchung: 'Buchung',
|
||||
projekte: 'Projekte',
|
||||
team: 'Team',
|
||||
kunden: 'Kunden',
|
||||
reports: 'Reports',
|
||||
einstellungen: 'Einstellungen',
|
||||
};
|
||||
|
||||
/** Welche Ansichten sieht eine Rolle - in Menuereihenfolge. */
|
||||
export function viewsFor(role: Role): ViewKey[] {
|
||||
switch (role) {
|
||||
case 'user':
|
||||
return ['buchung', 'projekte'];
|
||||
case 'lead':
|
||||
return ['dashboard', 'buchung', 'projekte', 'team', 'kunden', 'reports'];
|
||||
case 'management':
|
||||
// Einstellungen zeigen fuer Management nur die SSO-Verwaltung.
|
||||
return ['dashboard', 'buchung', 'projekte', 'team', 'kunden', 'reports', 'einstellungen'];
|
||||
case 'admin':
|
||||
// Administratoren sind keine Mitarbeiter und buchen keine Zeiten.
|
||||
return ['dashboard', 'projekte', 'team', 'kunden', 'reports', 'einstellungen'];
|
||||
}
|
||||
}
|
||||
|
||||
/** Startseite nach dem Login: Management das Dashboard, Admin die Einstellungen, alle anderen die Buchung. */
|
||||
export function homeFor(role: Role): ViewKey {
|
||||
if (role === 'management') return 'dashboard';
|
||||
if (role === 'admin') return 'einstellungen';
|
||||
return 'buchung';
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user