Files
perinco-Pulse/src/components/reports/ReportTable.tsx
T
schakal00783andClaude Opus 5 c3aed8a3d3 fix(reports): lange Projektnamen umbrechen statt abschneiden
Die Reporttabelle lief mit table-auto: ein langer Projektname wie
"HAYS - Globales Template und Implementierung: ... _Fresenius -
01.10.2025-31.07.2026" zog die Namensspalte auf und schob Stunden und
Betrag aus dem sichtbaren Bereich. Das truncate am Namen half nicht,
weil die Spalte keine Breite hatte, an der es haette greifen koennen.

Die Tabelle steht jetzt auf table-fixed mit festen Breiten fuer die
Zahlenspalten; der Name bekommt den Rest und bricht um (break-words).
Die Zahlen selbst bleiben einzeilig.

Dazu die Kennzahlenkarte: ihre vier Spalten mit den grossen Betraegen
passten ab lg nicht mehr in die Breite, der rechte Rand wurde von
overflow-x-hidden abgeschnitten. Sie bricht jetzt auf zwei Spalten um
und zeigt vier erst ab xl.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 17:16:13 +02:00

125 lines
6.5 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 { 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 = '') => (
<th className={`px-3 py-3 ${align === 'right' ? 'text-right' : 'text-left'} ${className}`}>
<button
onClick={() => setSort({ key, dir: sort.key === key ? ((sort.dir * -1) as 1 | -1) : key === 'name' ? 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>
);
if (rows.length === 0) return <p className="px-4 py-10 text-center text-sm text-ink-400">Keine Buchungen im Zeitraum.</p>;
return (
<div className="scrollbar-slim overflow-x-auto">
<table className="w-full min-w-[900px] table-fixed text-sm">
<thead>
<tr className="border-b border-ink-200 text-[11px] text-ink-400">
{head('name', label, 'left', 'pl-4')}
{head('minutes', 'Stunden', 'right', 'w-[108px]')}
<th className="w-[20%] px-3 py-3" />
{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')}
</tr>
</thead>
<tbody>
{sorted.map((r, i) => {
const share = r.minutes ? Math.round((r.billable_minutes / r.minutes) * 100) : 0;
const clickable = Boolean(onRowClick);
return (
<tr
key={String(r.key)}
onClick={() => 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}
>
<td className="py-3 pl-4 pr-3">
<div className="flex min-w-0 items-center gap-2.5">
{kind === 'users' || kind === 'team' ? (
<Avatar id={r.user_id} name={r.name} color={r.color} avatar={r.avatar} size={28} external={r.is_external} />
) : kind !== 'tasks' ? (
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: colorFor(r.customer_id ?? r.project_id ?? r.name, r.color) }} />
) : null}
<span className="min-w-0">
<span className={`block break-words font-bold ${clickable ? 'text-ink-900 underline decoration-ink-200 underline-offset-4 group-hover:decoration-brand-400' : 'text-ink-800'}`}>{r.name}</span>
{r.customer_name && <span className="block break-words text-[11px] text-ink-400">{r.customer_name}{r.code ? ` · ${r.code}` : ''}</span>}
</span>
</div>
</td>
<td className="tabular whitespace-nowrap px-3 py-3 text-right font-extrabold text-ink-900">{formatHours(r.minutes)}</td>
<td className="px-3 py-3">
<SplitBar a={r.billable_minutes} b={r.non_billable_minutes} max={max} delay={i * 30} />
</td>
{showUtil && (
<td className={`tabular px-3 py-3 text-right font-bold ${r.utilization == null ? 'text-ink-300' : r.utilization > 105 ? 'text-red-600' : r.utilization >= 75 ? 'text-brand-700' : 'text-ink-600'}`}>
{r.utilization == null ? '–' : `${r.utilization} %`}
</td>
)}
<td className="tabular whitespace-nowrap px-3 py-3 text-right text-ink-700">
{formatHours(r.billable_minutes)} <span className="inline-block w-14 whitespace-nowrap text-ink-400">({share} %)</span>
</td>
{showAmounts && <td className="tabular whitespace-nowrap py-3 pl-3 pr-4 text-right font-extrabold text-ink-900">{formatEuro(r.amount, 2)}</td>}
</tr>
);
})}
</tbody>
{totals && (
<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">Summe</td>
<td className="tabular px-3 py-3 text-right">{formatHours(sum.minutes)}</td>
<td />
{showUtil && <td />}
<td className="tabular px-3 py-3 text-right">
{formatHours(sum.billable)} <span className="inline-block w-14 whitespace-nowrap font-normal text-ink-400">({sum.minutes ? Math.round((sum.billable / sum.minutes) * 100) : 0} %)</span>
</td>
{showAmounts && <td className="tabular py-3 pl-3 pr-4 text-right">{formatEuro(sum.amount, 2)}</td>}
</tr>
</tfoot>
)}
</table>
</div>
);
};