import React, { useMemo, useState } from 'react'; import { AvatarStack, Badge, BudgetBar } from '../ui/controls'; import { Icon } from '../ui/Icon'; import { TYPE_TONE } from './Projects'; import { colorFor, formatEuro, formatHours, formatNumber } from '../../lib/format'; import { PROJECT_TYPE_LABELS, type Project } from '../../types'; type SortKey = 'name' | 'customer' | 'type' | 'used' | 'budget' | 'percent' | 'open'; const percentOf = (p: Project) => (p.budget_hours ? (p.budget_used_minutes / 60 / p.budget_hours) * 100 : null); /** * Listenansicht der Projekte: dieselben Kennzahlen wie auf den Kacheln, nur * flach ueber alle Kunden und sortierbar. */ export const ProjectTable: React.FC<{ projects: Project[]; selectedId: number | null; showAmounts: boolean; hoursPerDay: number; warnAt: number; onOpen: (id: number) => void; }> = ({ projects, selectedId, showAmounts, hoursPerDay, warnAt, onOpen }) => { const [sort, setSort] = useState<{ key: SortKey; dir: 1 | -1 }>({ key: 'used', dir: -1 }); const sorted = useMemo(() => { const value = (p: Project): string | number => { switch (sort.key) { case 'name': return p.display_name; case 'customer': return p.customer.name; case 'type': return PROJECT_TYPE_LABELS[p.type]; case 'budget': return p.budget_hours ?? -1; case 'percent': return percentOf(p) ?? -1; case 'open': return p.open ?? -1; default: return p.used_minutes; } }; return [...projects].sort((a, b) => { const av = value(a); const bv = value(b); const cmp = typeof av === 'string' ? av.localeCompare(bv as string, 'de') : av - (bv as number); return (cmp || a.display_name.localeCompare(b.display_name, 'de')) * sort.dir; }); }, [projects, sort]); const sum = projects.reduce( (s, p) => ({ minutes: s.minutes + p.used_minutes, budget: s.budget + (p.budget_hours ?? 0), open: s.open + (p.open ?? 0) }), { minutes: 0, budget: 0, open: 0 } ); const head = (key: SortKey, text: string, align: 'left' | 'right' = 'right', className = '') => ( ); return (
{head('name', 'Projekt', 'left', 'pl-4')} {head('customer', 'Kunde', 'left')} {head('type', 'Typ', 'left')} {head('used', 'Gebucht')} {head('budget', 'Budget')} {head('percent', 'Verbrauch', 'left', 'w-[15%]')} {showAmounts && head('open', 'Offen')} {sorted.map((p, i) => { const percent = percentOf(p); const warning = p.pots.find((pot) => pot.warning && pot.status !== 'expired'); return ( onOpen(p.id)} className={`animate-rise cursor-pointer border-b border-ink-100 last:border-0 hover:bg-brand-50/50 ${ p.id === selectedId ? 'bg-brand-50/70' : '' } ${p.is_active ? '' : 'opacity-60'}`} style={{ '--i': Math.min(i, 15) } as React.CSSProperties} > {showAmounts && ( )} ); })} }
Team
{p.display_name} {!p.is_active && Archiv} {warning && ( )} {p.code} {p.pots.length} {p.tasks.filter((t) => t.is_active).length} {p.customer.name} {PROJECT_TYPE_LABELS[p.type]} {formatHours(p.used_minutes, 1)} h {formatNumber(p.used_minutes / 60 / hoursPerDay, 1)} Tage {p.budget_hours != null ? ( <> {formatNumber(p.budget_hours, 0)} h {formatNumber(p.remaining_hours ?? 0, 1)} h frei ) : p.type === 'abruf' ? ( <> nach Aufwand {p.rate != null ? `${formatEuro(p.rate, 2)} / ${p.billing_unit === 'day' ? 'Tag' : 'Std.'}` : 'kein Satz'} ) : ( ohne Limit )} {percent == null ? '–' : `${Math.round(percent)} %`} {p.type === 'abruf' || p.budget_value ? ( <> {formatEuro(p.open ?? 0)} {formatEuro(p.earned ?? 0)} erbracht ) : ( – )}
Summe · {projects.length} {projects.length === 1 ? 'Projekt' : 'Projekte'} {formatHours(sum.minutes, 1)} h {sum.budget ? `${formatNumber(sum.budget, 0)} h` : '–'} {showAmounts && {formatEuro(sum.open)}
); };