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>
266 lines
14 KiB
TypeScript
266 lines
14 KiB
TypeScript
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>
|
||
);
|