feat: perinco Pulse - Zeiterfassung fuer perinco

Erste Fassung, live unter https://pulse.perinco.de.

Frontend React 19 + TypeScript + Tailwind 4 (Vite), Backend Express mit
mysql2 auf MariaDB, Exporte als PDF (pdfkit), Excel (exceljs) und CSV.
Aufbau, Designsystem und die sieben Themes kommen aus dem perinco CRM.

Fachlich:
- Buchung mit Wochen-, Monats- und Jahresansicht, Zeiten als 1:23 oder 1,5
- Projekte mit n Budgettoepfen: Laufzeit, Volumen, Sperre bei Ablauf oder
  aufgebrauchtem Budget, Buchung nur auf den passenden Topf
- Kunden, Team mit Hierarchie, Reports im Harvest-Stil, Monatsexporte
- Rollen user/lead/management/admin, doppelt geprueft (Menue und Backend)
- SSO gegen Microsoft Entra ID, Uebernahme der AD-Benutzer per Drag & Drop

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-17 22:52:20 +02:00
co-authored by Claude Opus 5
commit ef676a95f4
104 changed files with 21030 additions and 0 deletions
+265
View File
@@ -0,0 +1,265 @@
import React, { useMemo, useState } from 'react';
import { Avatar, Badge, ErrorBox, Panel, Segmented, Spinner, Stat } from '../ui/controls';
import { Icon } from '../ui/Icon';
import { BILLABLE, Donut, Legend, NON_BILLABLE, SegmentRing, SplitBar, StackedBars } from '../ui/charts';
import { useAuth } from '../../lib/auth';
import { dashboardApi, type DashboardData, type Granularity, type RankPeriod } from '../../lib/api';
import { useLoader, usePersistentState } from '../../lib/hooks';
import { colorFor, compactEuro, formatDate, formatDuration, formatHours, relativeTime } from '../../lib/format';
import { useRouter } from '../../lib/router';
const GRANULARITY: { value: Granularity; label: string }[] = [
{ value: 'day', label: 'Tag' },
{ value: 'week', label: 'Woche' },
{ value: 'month', label: 'Monat' },
{ value: 'quarter', label: 'Quartal' },
{ value: 'year', label: 'Jahr' },
];
const PERIOD_NOUN: Record<Granularity, string> = { day: 'heute', week: 'diese Woche', month: 'diesen Monat', quarter: 'dieses Quartal', year: 'dieses Jahr' };
export const Dashboard: React.FC = () => {
const { user } = useAuth();
const [granularity, setGranularity] = usePersistentState<Granularity>('pulse_dash_granularity', 'week');
const [rank, setRank] = usePersistentState<RankPeriod>('pulse_dash_rank', 'month');
const { data, error, loading, reload } = useLoader(() => dashboardApi.get(granularity, rank), [granularity, rank]);
if (error && !data) return <ErrorBox message={error} onRetry={reload} />;
if (!data) return <Spinner label="Lade Dashboard…" />;
const cur = data.kpis.current;
const prev = data.kpis.previous;
const delta = (a: number, b: number | undefined) => (b ? ((a - b) / b) * 100 : null);
const share = cur.minutes ? (cur.billable_minutes / cur.minutes) * 100 : 0;
const utilization = cur.capacity_minutes ? (cur.minutes / cur.capacity_minutes) * 100 : 0;
return (
<div className={`mx-auto max-w-[1500px] space-y-4 transition-opacity ${loading ? 'opacity-70' : ''}`}>
<div className="flex flex-wrap items-center gap-3">
<div className="min-w-0">
<p className="text-sm text-ink-500">
Hallo <b className="text-ink-900">{user!.name.split(' ')[0]}</b> – so sieht {PERIOD_NOUN[granularity]} aus.
</p>
<div className="mt-1 flex flex-wrap items-center gap-2">
<Badge tone={data.scope === 'all' ? 'brand' : 'sky'}>
<Icon name="team" className="h-3 w-3" />
{data.scope === 'all' ? 'Gesamte Organisation' : 'Mein Team'} · {data.team_size} {data.team_size === 1 ? 'Person' : 'Personen'}
</Badge>
<Badge>{data.kpis.period_label}</Badge>
</div>
</div>
<Segmented className="ml-auto w-full sm:w-auto" value={granularity} onChange={setGranularity} options={GRANULARITY} />
</div>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<Stat index={0} label="Gebuchte Stunden" icon="clock" value={cur.minutes / 60} format={(v) => formatNumberDe(v, 1)} delta={delta(cur.minutes, prev?.minutes)} hint={prev ? `vs. ${data.kpis.previous_label}` : undefined} />
<Stat index={1} label="Billable" icon="check" value={cur.billable_minutes / 60} format={(v) => formatNumberDe(v, 1)} delta={delta(cur.billable_minutes, prev?.billable_minutes)} hint={`${Math.round(share)} % Anteil`} />
<Stat index={2} label="Billable Betrag" icon="euro" value={cur.amount} format={(v) => compactEuro(v)} delta={delta(cur.amount, prev?.amount)} accent="var(--color-sky-700)" />
<Stat index={3} label="Auslastung" icon="chart" value={utilization} format={(v) => `${Math.round(v)} %`} hint={`Kapazität ${formatNumberDe(cur.capacity_minutes / 60, 0)} h bis heute`} accent="var(--color-violet-700)" />
</div>
<div className="grid gap-4 xl:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]">
<Panel
index={4}
title="Gebuchte Zeiten"
hint={`Billable und non-billable je ${GRANULARITY.find((g) => g.value === granularity)?.label}`}
action={<Legend items={[{ color: BILLABLE, label: 'Billable' }, { color: NON_BILLABLE, label: 'Non-billable' }]} />}
>
<StackedBars
points={data.series.map((s, i) => ({ key: s.from, label: s.label, a: s.billable_minutes, b: s.non_billable_minutes, highlight: i === data.series.length - 1 }))}
format={(m) => `${formatHours(m, 1)} h`}
axisFormat={(m) => formatNumberDe(m / 60, 0)}
height={260}
scale={60}
/>
</Panel>
<Panel index={5} title="Verteilung" hint={`Projekte ${PERIOD_NOUN[granularity]}`}>
<Distribution data={data} share={share} />
</Panel>
</div>
<div className="grid gap-4 xl:grid-cols-[minmax(0,3fr)_minmax(320px,2fr)]">
<Ranking data={data} rank={rank} onRank={setRank} />
<RecentFeed data={data} />
</div>
</div>
);
};
function formatNumberDe(value: number, digits: number) {
return value.toLocaleString('de-DE', { minimumFractionDigits: digits, maximumFractionDigits: digits });
}
const Distribution: React.FC<{ data: DashboardData; share: number }> = ({ data, share }) => {
const total = data.projects.reduce((s, p) => s + p.minutes, 0);
const segments = data.projects.map((p) => ({ label: p.name, value: p.minutes, color: colorFor(p.project_id, p.customer_color) }));
if (!total) return <p className="py-10 text-center text-xs font-semibold text-ink-400">Noch keine Buchungen in diesem Zeitraum.</p>;
return (
<div>
<div className="flex items-center justify-center gap-6">
<SegmentRing
segments={segments}
size={150}
center={
<>
<span className="tabular text-2xl font-extrabold text-ink-900">{formatNumberDe(total / 60, 0)}</span>
<span className="text-[10px] font-bold uppercase tracking-wider text-ink-400">Stunden</span>
</>
}
/>
<div className="flex flex-col items-center gap-1">
<Donut percent={share} size={84} stroke={10} label="billable" />
</div>
</div>
<ul className="mt-5 space-y-2">
{data.projects.slice(0, 6).map((p, i) => (
<li key={p.project_id} className="animate-rise flex items-center gap-2 text-xs" style={{ '--i': i } as React.CSSProperties}>
<span className="h-2.5 w-2.5 shrink-0 rounded-sm" style={{ background: segments[i].color }} />
<span className="min-w-0 flex-1 truncate font-bold text-ink-700">{p.name}</span>
<span className="tabular font-extrabold text-ink-900">{formatHours(p.minutes, 1)} h</span>
<span className="tabular w-10 text-right text-ink-400">{Math.round((p.minutes / total) * 100)} %</span>
</li>
))}
</ul>
</div>
);
};
type RankFilter = 'all' | 'billable' | 'non_billable';
const ROW = 60;
/**
* Mitarbeiterliste. Die Zeilen sind absolut positioniert und gleiten beim
* Umsortieren oder Filtern an ihren neuen Platz - man sieht, wer aufsteigt.
*/
const Ranking: React.FC<{ data: DashboardData; rank: RankPeriod; onRank: (r: RankPeriod) => void }> = ({ data, rank, onRank }) => {
const [filter, setFilter] = usePersistentState<RankFilter>('pulse_dash_filter', 'all');
const [order, setOrder] = usePersistentState<'desc' | 'asc'>('pulse_dash_order', 'desc');
const { navigate } = useRouter();
const value = (r: DashboardData['ranking'][number]) => (filter === 'billable' ? r.billable_minutes : filter === 'non_billable' ? r.non_billable_minutes : r.minutes);
const sorted = useMemo(
() => [...data.ranking].sort((a, b) => (order === 'desc' ? value(b) - value(a) : value(a) - value(b)) || a.name.localeCompare(b.name, 'de')),
// eslint-disable-next-line react-hooks/exhaustive-deps
[data.ranking, filter, order]
);
const positions = new Map(sorted.map((r, i) => [r.user_id, i]));
const max = Math.max(1, ...data.ranking.map((r) => (filter === 'all' ? r.minutes : value(r))));
const sum = sorted.reduce((s, r) => s + value(r), 0);
return (
<Panel
index={6}
title="Buchungen je Mitarbeiter"
hint={`${formatDate(data.rank_range.from)} – ${formatDate(data.rank_range.to)} · ${formatHours(sum, 1)} h gesamt`}
action={
<div className="flex flex-wrap items-center gap-2">
<select
value={rank}
onChange={(e) => onRank(e.target.value as RankPeriod)}
className="rounded-xl border border-ink-200 bg-surface px-3 py-1.5 text-xs font-bold text-ink-600 outline-none focus:border-brand-500"
>
<option value="days">Letzte 7 Tage</option>
<option value="week">Diese Woche</option>
<option value="month">Dieser Monat</option>
<option value="quarter">Dieses Quartal</option>
<option value="year">Dieses Jahr</option>
</select>
<Segmented
size="sm"
value={filter}
onChange={setFilter}
options={[
{ value: 'all', label: 'Alle' },
{ value: 'billable', label: 'Billable' },
{ value: 'non_billable', label: 'Non-billable' },
]}
/>
<button
onClick={() => setOrder(order === 'desc' ? 'asc' : 'desc')}
className="flex items-center gap-1 rounded-xl border border-ink-200 px-2.5 py-1.5 text-xs font-bold text-ink-600 transition hover:border-brand-300 hover:text-brand-700"
title="Sortierung umkehren"
>
<Icon name={order === 'desc' ? 'arrowDown' : 'arrowUp'} className="h-3.5 w-3.5" strokeWidth={2.4} />
{order === 'desc' ? 'Absteigend' : 'Aufsteigend'}
</button>
</div>
}
>
{data.ranking.length === 0 ? (
<p className="py-8 text-center text-xs text-ink-400">Keine Mitarbeiter im Team.</p>
) : (
<div className="relative" style={{ height: data.ranking.length * ROW }}>
{data.ranking.map((r) => {
const pos = positions.get(r.user_id) ?? 0;
const a = filter === 'non_billable' ? 0 : r.billable_minutes;
const b = filter === 'billable' ? 0 : r.non_billable_minutes;
const util = r.capacity_minutes ? Math.round((r.minutes / r.capacity_minutes) * 100) : null;
return (
<div
key={r.user_id}
className="absolute inset-x-0 flex items-center gap-3 rounded-xl px-1 transition-transform duration-500 ease-out hover:bg-ink-50"
style={{ height: ROW, transform: `translateY(${pos * ROW}px)` }}
>
<span className="tabular w-5 shrink-0 text-center text-xs font-extrabold text-ink-300">{pos + 1}</span>
<Avatar id={r.user_id} name={r.name} color={r.color} size={34} external={r.is_external} />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<button onClick={() => navigate('reports')} className="truncate text-left text-sm font-bold text-ink-800 hover:text-brand-700" title="Zu den Reports">
{r.name}
</button>
<span className="tabular shrink-0 text-sm font-extrabold text-ink-900">{formatHours(value(r), 2)} h</span>
</div>
<div className="mt-1.5 flex items-center gap-2">
<SplitBar a={a} b={b} max={max} className="flex-1" />
{util != null && (
<span
className={`tabular w-12 shrink-0 text-right text-[11px] font-bold ${util > 110 ? 'text-red-600' : util >= 80 ? 'text-brand-700' : 'text-ink-400'}`}
title="Auslastung (alle Stunden / Kapazität)"
>
{util} %
</span>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</Panel>
);
};
const RecentFeed: React.FC<{ data: DashboardData }> = ({ data }) => (
<Panel index={7} title="Letzte Buchungen" hint="Neueste Einträge zuerst">
{data.recent.length === 0 ? (
<p className="py-8 text-center text-xs text-ink-400">Noch keine Buchungen.</p>
) : (
<ol className="relative space-y-1 before:absolute before:bottom-3 before:left-[17px] before:top-3 before:w-px before:bg-ink-100">
{data.recent.map((e, i) => (
<li key={e.id} className="animate-rise relative flex gap-3 rounded-xl p-1.5 hover:bg-ink-50" style={{ '--i': i } as React.CSSProperties}>
<Avatar id={e.user_id} name={e.user_name} color={e.user_color} size={34} />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<p className="truncate text-sm font-bold text-ink-800">{e.user_name}</p>
<span className={`tabular shrink-0 rounded-full px-2 py-0.5 text-xs font-extrabold ${e.billable ? 'bg-brand-50 text-brand-700' : 'bg-ink-100 text-ink-600'}`}>
{formatDuration(e.minutes)}
</span>
</div>
<p className="truncate text-xs text-ink-500">
<span className="mr-1 inline-block h-2 w-2 rounded-full align-middle" style={{ background: colorFor(e.project_display, e.customer_color) }} />
{e.project_display}
{e.task_name && <span className="text-ink-400"> · {e.task_name}</span>}
</p>
<p className="text-[11px] text-ink-400">
für {formatDate(e.entry_date, 'day')} · {relativeTime(e.created_at)}
</p>
</div>
</li>
))}
</ol>
)}
</Panel>
);