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 = { 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('pulse_dash_granularity', 'week'); const [rank, setRank] = usePersistentState('pulse_dash_rank', 'month'); const { data, error, loading, reload } = useLoader(() => dashboardApi.get(granularity, rank), [granularity, rank]); if (error && !data) return ; if (!data) return ; 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 (

Hallo {user!.name.split(' ')[0]} – so sieht {PERIOD_NOUN[granularity]} aus.

{data.scope === 'all' ? 'Gesamte Organisation' : 'Mein Team'} · {data.team_size} {data.team_size === 1 ? 'Person' : 'Personen'} {data.kpis.period_label}
formatNumberDe(v, 1)} delta={delta(cur.minutes, prev?.minutes)} hint={prev ? `vs. ${data.kpis.previous_label}` : undefined} /> formatNumberDe(v, 1)} delta={delta(cur.billable_minutes, prev?.billable_minutes)} hint={`${Math.round(share)} % Anteil`} /> compactEuro(v)} delta={delta(cur.amount, prev?.amount)} accent="var(--color-sky-700)" /> `${Math.round(v)} %`} hint={`Kapazität ${formatNumberDe(cur.capacity_minutes / 60, 0)} h bis heute`} accent="var(--color-violet-700)" />
g.value === granularity)?.label}`} action={} > ({ 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} />
); }; 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

Noch keine Buchungen in diesem Zeitraum.

; return (
{formatNumberDe(total / 60, 0)} Stunden } />
    {data.projects.slice(0, 6).map((p, i) => (
  • {p.name} {formatHours(p.minutes, 1)} h {Math.round((p.minutes / total) * 100)} %
  • ))}
); }; 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('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 ( } > {data.ranking.length === 0 ? (

Keine Mitarbeiter im Team.

) : (
{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 (
{pos + 1}
{formatHours(value(r), 2)} h
{util != null && ( 110 ? 'text-red-600' : util >= 80 ? 'text-brand-700' : 'text-ink-400'}`} title="Auslastung (alle Stunden / Kapazität)" > {util} % )}
); })}
)}
); }; const RecentFeed: React.FC<{ data: DashboardData }> = ({ data }) => ( {data.recent.length === 0 ? (

Noch keine Buchungen.

) : (
    {data.recent.map((e, i) => (
  1. {e.user_name}

    {formatDuration(e.minutes)}

    {e.project_display} {e.task_name && · {e.task_name}}

    für {formatDate(e.entry_date, 'day')} · {relativeTime(e.created_at)}

  2. ))}
)}
);