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 = '') => ( ); if (rows.length === 0) return

Keine Buchungen im Zeitraum.

; return (
{head('name', label, 'left', 'pl-4')} {head('minutes', 'Stunden', 'right', 'w-[108px]')} {sorted.map((r, i) => { const share = r.minutes ? Math.round((r.billable_minutes / r.minutes) * 100) : 0; const clickable = Boolean(onRowClick); return ( 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} > {showUtil && ( )} {showAmounts && } ); })} {totals && ( {showAmounts && } )}
{showUtil && head('utilization', 'Auslastung', 'right', 'w-[116px]')} {head('billable_minutes', 'Billable Std.', 'right', 'w-[160px]')} {showAmounts && head('amount', 'Billable Betrag', 'right', 'w-[150px] pr-4')}
{kind === 'users' || kind === 'team' ? ( ) : kind !== 'tasks' ? ( ) : null} {r.name} {r.customer_name && {r.customer_name}{r.code ? ` · ${r.code}` : ''}}
{formatHours(r.minutes)} 105 ? 'text-red-600' : r.utilization >= 75 ? 'text-brand-700' : 'text-ink-600'}`}> {r.utilization == null ? '–' : `${r.utilization} %`} {formatHours(r.billable_minutes)} ({share} %) {formatEuro(r.amount, 2)}
Summe {formatHours(sum.minutes)} {showUtil && } {formatHours(sum.billable)} ({sum.minutes ? Math.round((sum.billable / sum.minutes) * 100) : 0} %) {formatEuro(sum.amount, 2)}
); };