Files
perinco-Pulse/src/components/projects/ProjectTable.tsx
T
schakal00783andClaude Opus 5 be37d89d53 feat(projekte): Listenansicht der Projektuebersicht
Umschalter Karten/Liste neben dem Statusfilter, die Wahl merkt sich der
Browser. Die Liste zeigt alle gefilterten Projekte flach ueber die Kunden
hinweg und sortierbar: Projekt mit Code, Toepfen und Tasks, Kunde, Typ,
gebuchte Stunden und Tage, Budget mit freiem Rest, Verbrauchsbalken,
offener Betrag mit erbrachtem Umsatz und das Team. Beträge nur ab
Vorgesetztem.

Ein Klick auf eine Zeile oeffnet dieselbe Detailansicht wie eine Kachel,
in der Liste unterhalb der Tabelle. Dafuer ist der Detailblock einmal
aufgebaut und wird in beiden Ansichten verwendet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 11:28:42 +02:00

193 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = '') => (
<th className={`px-2.5 py-3 ${align === 'right' ? 'text-right' : 'text-left'} ${className}`}>
<button
onClick={() => setSort({ key, dir: sort.key === key ? ((sort.dir * -1) as 1 | -1) : key === 'used' || key === 'budget' || key === 'percent' || key === 'open' ? -1 : 1 })}
className={`inline-flex items-center gap-1 font-extrabold uppercase tracking-wider hover:text-ink-700 ${sort.key === key ? 'text-ink-700' : ''}`}
>
{text}
{sort.key === key && <Icon name={sort.dir === 1 ? 'chevronUp' : 'chevronDown'} className="h-3 w-3" strokeWidth={2.5} />}
</button>
</th>
);
return (
<div className="scrollbar-slim overflow-x-auto">
<table className="w-full min-w-[1000px] text-sm">
<thead>
<tr className="border-b border-ink-200 text-[11px] text-ink-400">
{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')}
<th className="w-[1%] whitespace-nowrap py-3 pl-3 pr-4 text-left font-extrabold uppercase tracking-wider">Team</th>
</tr>
</thead>
<tbody>
{sorted.map((p, i) => {
const percent = percentOf(p);
const warning = p.pots.find((pot) => pot.warning && pot.status !== 'expired');
return (
<tr
key={p.id}
onClick={() => 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}
>
<td className="py-2.5 pl-4 pr-3">
<span className="flex min-w-0 items-start gap-2.5">
<span aria-hidden className="mt-1.5 h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: colorFor(p.customer_id, p.customer.color) }} />
<span className="min-w-0">
<span className="flex items-center gap-1.5">
<span className="max-w-[300px] truncate font-bold text-ink-900">{p.display_name}</span>
{!p.is_active && <Badge>Archiv</Badge>}
{warning && (
<span title={warning.warning ?? undefined}>
<Icon name="warning" className="h-3.5 w-3.5 shrink-0 text-amber-600" />
</span>
)}
</span>
<span className="tabular flex items-center gap-2 text-[11px] text-ink-400">
<span className="truncate">{p.code}</span>
<span className="inline-flex shrink-0 items-center gap-1" title={`${p.pots.length} Budgettöpfe`}>
<Icon name="pot" className="h-3 w-3" />
{p.pots.length}
</span>
<span className="inline-flex shrink-0 items-center gap-1" title="Aktive Tasks">
<Icon name="list" className="h-3 w-3" />
{p.tasks.filter((t) => t.is_active).length}
</span>
</span>
</span>
</span>
</td>
<td className="px-2.5 py-2.5 text-ink-600">
<span className="block max-w-[220px] truncate">{p.customer.name}</span>
</td>
<td className="px-2.5 py-2.5">
<Badge tone={TYPE_TONE[p.type]}>{PROJECT_TYPE_LABELS[p.type]}</Badge>
</td>
<td className="tabular whitespace-nowrap px-2.5 py-2.5 text-right font-extrabold text-ink-900">
{formatHours(p.used_minutes, 1)} h
<span className="block text-[11px] font-semibold text-ink-400">{formatNumber(p.used_minutes / 60 / hoursPerDay, 1)} Tage</span>
</td>
<td className="tabular whitespace-nowrap px-2.5 py-2.5 text-right text-ink-700">
{p.budget_hours != null ? (
<>
{formatNumber(p.budget_hours, 0)} h
<span className="block text-[11px] font-semibold text-ink-400">{formatNumber(p.remaining_hours ?? 0, 1)} h frei</span>
</>
) : p.type === 'abruf' ? (
<>
nach Aufwand
<span className="block text-[11px] font-semibold text-ink-400">
{p.rate != null ? `${formatEuro(p.rate, 2)} / ${p.billing_unit === 'day' ? 'Tag' : 'Std.'}` : 'kein Satz'}
</span>
</>
) : (
<span className="whitespace-nowrap text-ink-400">ohne Limit</span>
)}
</td>
<td className="px-2.5 py-2.5">
<BudgetBar percent={percent} warnAt={warnAt} />
<span className="tabular mt-1 block text-[11px] font-semibold text-ink-400">{percent == null ? '–' : `${Math.round(percent)} %`}</span>
</td>
{showAmounts && (
<td className="tabular whitespace-nowrap px-2.5 py-2.5 text-right text-ink-700">
{p.type === 'abruf' || p.budget_value ? (
<>
{formatEuro(p.open ?? 0)}
<span className="block text-[11px] font-semibold text-ink-400">{formatEuro(p.earned ?? 0)} erbracht</span>
</>
) : (
<span className="text-ink-300">–</span>
)}
</td>
)}
<td className="py-2.5 pl-3 pr-4">
<span className="block w-max">
<AvatarStack people={p.members} max={5} size={22} />
</span>
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t-2 border-ink-200 bg-ink-50/60 font-extrabold text-ink-900">
<td className="py-3 pl-4 pr-3" colSpan={3}>
Summe · {projects.length} {projects.length === 1 ? 'Projekt' : 'Projekte'}
</td>
<td className="tabular whitespace-nowrap px-2.5 py-3 text-right">{formatHours(sum.minutes, 1)} h</td>
<td className="tabular whitespace-nowrap px-2.5 py-3 text-right">{sum.budget ? `${formatNumber(sum.budget, 0)} h` : '–'}</td>
<td />
{showAmounts && <td className="tabular whitespace-nowrap px-2.5 py-3 text-right">{formatEuro(sum.open)}</td>}
<td />
</tr>
</tfoot>
</table>
</div>
);
};