Files
perinco-Pulse/src/components/dashboard/Dashboard.tsx
T
schakal00783andClaude Opus 5 ef676a95f4 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>
2026-09-17 22:52:20 +02:00

266 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);