Files
perinco-Pulse/src/components/reports/Reports.tsx
T
schakal00783andClaude Opus 5 994f39daff fix(reports): Buchungsliste im Dialog nicht mehr rechts abschneiden
Der Dialog war 1152 Punkte breit, die Tabelle brauchte mehr - "Billable
Zeit" und "Non-billable Zeit" lagen ausserhalb und waren nur durch
seitliches Scrollen zu erreichen, was niemand sieht.

- Neue Dialogbreite 3xl: bis 1600 Punkte, hoechstens 95 % des Fensters.
- Die Tabelle liegt jetzt auf festen Spaltenbreiten (table-fixed): Datum,
  Mitarbeiter und die beiden Zeitspalten bekommen ihre Breite, Kunde und
  Projekt einen Anteil, den Rest nimmt der Task.
- Lange Kunden-, Projekt- und Tasknamen samt Notiz brechen um statt
  abgeschnitten zu werden; die Zahlen brechen nie.

Geprueft bei 1920, 1440 und 1280 Punkten: Tabelle passt ohne Querscrollen,
die letzte Spalte liegt im Dialog. Auf dem Handy scrollt die Tabelle wie
vorher innerhalb des Dialogs, die Seite selbst nicht.

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

321 lines
16 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, { useEffect, useMemo, useState } from 'react';
import { Avatar, Card, ErrorBox, IconButton, Spinner, inputClass } from '../ui/controls';
import { Modal } from '../ui/Modal';
import { Icon } from '../ui/Icon';
import { CountUp, Donut, BILLABLE, NON_BILLABLE } from '../ui/charts';
import { ReportTable } from './ReportTable';
import { EntryList } from '../booking/EntryList';
import { ExportMenu } from './ExportMenu';
import { CustomerReport } from './CustomerReport';
import { ProjectReport } from './ProjectReport';
import { useAuth } from '../../lib/auth';
import { customersApi, entriesApi, qs, reportsApi, usersApi, type GroupRow } from '../../lib/api';
import { useLoader, usePersistentState } from '../../lib/hooks';
import { MONTHS, addDays, addMonths, formatDate, formatEuro, formatHours, isoWeek, mondayOf, monthEnd, monthStart, todayIso } from '../../lib/format';
import { useRouter } from '../../lib/router';
import { atLeast, type Customer, type DirectoryUser } from '../../types';
type Period = 'week' | 'month' | 'quarter' | 'year' | 'custom';
type Tab = 'clients' | 'projects' | 'tasks' | 'users';
function periodRange(period: Period, anchor: string, custom: { from: string; to: string }) {
if (period === 'week') {
const from = mondayOf(anchor);
return { from, to: addDays(from, 6), label: `KW ${isoWeek(from)} · ${formatDate(from)} – ${formatDate(addDays(from, 6))}` };
}
if (period === 'month') return { from: monthStart(anchor), to: monthEnd(anchor), label: `${MONTHS[Number(anchor.slice(5, 7)) - 1]} ${anchor.slice(0, 4)}` };
if (period === 'quarter') {
const q = Math.floor((Number(anchor.slice(5, 7)) - 1) / 3);
const from = `${anchor.slice(0, 4)}-${String(q * 3 + 1).padStart(2, '0')}-01`;
return { from, to: monthEnd(addMonths(from, 2)), label: `Q${q + 1} ${anchor.slice(0, 4)}` };
}
if (period === 'year') return { from: `${anchor.slice(0, 4)}-01-01`, to: `${anchor.slice(0, 4)}-12-31`, label: anchor.slice(0, 4) };
return { ...custom, label: `${formatDate(custom.from)} – ${formatDate(custom.to)}` };
}
export const Reports: React.FC = () => {
const { route, navigate } = useRouter();
const [kind, id] = route.params;
const view = kind === 'kunde' ? 'customer' : kind === 'projekt' ? 'project' : 'time';
return (
<div className="mx-auto max-w-[1500px]">
<div className="no-scrollbar mb-4 flex items-center gap-1 overflow-x-auto overflow-y-hidden border-b border-ink-200">
{(
[
['time', 'Zeitreport', 'reports'],
['customer', 'Kundenreport', 'reports/kunde'],
['project', 'Projektreport', 'reports/projekt'],
] as const
).map(([key, label, path]) => (
<button
key={key}
onClick={() => navigate(path)}
className={`relative whitespace-nowrap px-4 py-2.5 text-sm font-bold transition ${view === key ? 'text-ink-900' : 'text-ink-400 hover:text-ink-700'}`}
>
{label}
{view === key && <span className="animate-pop absolute inset-x-2 bottom-0 h-0.5 rounded-full bg-brand-500" />}
</button>
))}
</div>
{view === 'time' && <TimeReport />}
{view === 'customer' && <CustomerReport id={id ? Number(id) : null} />}
{view === 'project' && <ProjectReport id={id ? Number(id) : null} />}
</div>
);
};
const TimeReport: React.FC = () => {
const { user } = useAuth();
const { navigate } = useRouter();
const [period, setPeriod] = usePersistentState<Period>('pulse_report_period', 'month');
const [anchor, setAnchor] = useState(todayIso());
const [custom, setCustom] = useState({ from: monthStart(todayIso()), to: todayIso() });
const [tab, setTab] = usePersistentState<Tab>('pulse_report_tab', 'users');
const [customerId, setCustomerId] = useState<number | ''>('');
const [team, setTeam] = useState<number | ''>('');
const [includeFixed, setIncludeFixed] = useState(false);
const [customers, setCustomers] = useState<Customer[]>([]);
const [leads, setLeads] = useState<DirectoryUser[]>([]);
// Klick auf einen Mitarbeiter oeffnet seine Buchungen des Zeitraums als Liste.
const [detail, setDetail] = useState<GroupRow | null>(null);
useEffect(() => {
customersApi.list().then(setCustomers).catch(() => undefined);
usersApi
.directory()
.then((list) => setLeads(list.filter((u) => (u.role === 'lead' || u.role === 'management') && u.is_active)))
.catch(() => undefined);
}, []);
const range = periodRange(period, anchor, custom);
const report = useLoader(
() => reportsApi.time({ from: range.from, to: range.to, customer_id: customerId || null, team: team || null, include_fixed: includeFixed }),
[range.from, range.to, customerId, team, includeFixed]
);
const shift = (step: number) => {
if (period === 'week') setAnchor(addDays(anchor, 7 * step));
if (period === 'month') setAnchor(addMonths(anchor, step));
if (period === 'quarter') setAnchor(addMonths(anchor, 3 * step));
if (period === 'year') setAnchor(`${Number(anchor.slice(0, 4)) + step}-01-01`);
};
const exportParams = { from: range.from, to: range.to, customer_id: customerId || undefined, team: team || undefined, include_fixed: includeFixed ? 1 : undefined };
const groupName = { clients: 'Kunden', projects: 'Projekte', tasks: 'Tasks', users: 'Mitarbeiter' }[tab];
const exportOptions = useMemo(
() => [
{ label: `PDF – ${groupName}`, hint: 'Mit Logo, Kopf und Fuß', path: `/exports/report${qs({ ...exportParams, group: tab, format: 'pdf' })}`, filename: 'Zeitreport.pdf' },
{ label: `Excel – ${groupName}`, hint: 'Mit Summenformeln', path: `/exports/report${qs({ ...exportParams, group: tab, format: 'xlsx' })}`, filename: 'Zeitreport.xlsx' },
{ label: `CSV – ${groupName}`, path: `/exports/report${qs({ ...exportParams, group: tab, format: 'csv' })}`, filename: 'Zeitreport.csv' },
{ label: 'Excel – alle Buchungen', hint: 'Detailliste für die Rechnung', path: `/exports/report${qs({ ...exportParams, group: 'detail', format: 'xlsx' })}`, filename: 'Zeitreport-Detail.xlsx' },
{ label: 'PDF – alle Buchungen', path: `/exports/report${qs({ ...exportParams, group: 'detail', format: 'pdf' })}`, filename: 'Zeitreport-Detail.pdf' },
{ label: 'CSV – alle Buchungen', path: `/exports/report${qs({ ...exportParams, group: 'detail', format: 'csv' })}`, filename: 'Zeitreport-Detail.csv' },
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[tab, range.from, range.to, customerId, team, includeFixed]
);
const data = report.data;
const s = data?.summary;
const share = s && s.minutes ? (s.billable_minutes / s.minutes) * 100 : 0;
const capacity = data?.users.reduce((sum, u) => sum + (u.capacity_minutes ?? 0), 0) ?? 0;
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
{atLeast(user, 'management') && leads.length > 0 && (
<select className={`${inputClass} !w-auto max-w-[220px]`} value={team} onChange={(e) => setTeam(e.target.value ? Number(e.target.value) : '')}>
<option value="">Alle Teams</option>
{leads.map((l) => (
<option key={l.id} value={l.id}>
Team {l.name}
</option>
))}
</select>
)}
<select className={`${inputClass} !w-auto max-w-[220px]`} value={customerId} onChange={(e) => setCustomerId(e.target.value ? Number(e.target.value) : '')}>
<option value="">Alle Kunden</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<select className={`${inputClass} !w-auto`} value={period} onChange={(e) => setPeriod(e.target.value as Period)}>
<option value="week">Woche</option>
<option value="month">Monat</option>
<option value="quarter">Quartal</option>
<option value="year">Jahr</option>
<option value="custom">Zeitraum</option>
</select>
{period === 'custom' ? (
<div className="flex items-center gap-2">
<input type="date" className={`${inputClass} !w-auto`} value={custom.from} onChange={(e) => setCustom({ ...custom, from: e.target.value })} />
<span className="text-ink-400">–</span>
<input type="date" className={`${inputClass} !w-auto`} value={custom.to} onChange={(e) => setCustom({ ...custom, to: e.target.value })} />
</div>
) : (
<div className="flex items-center rounded-xl border border-ink-200 bg-surface">
<button onClick={() => shift(-1)} className="rounded-l-xl p-2.5 text-ink-500 hover:bg-ink-50" aria-label="Zurück">
<Icon name="chevronLeft" className="h-4 w-4" strokeWidth={2.2} />
</button>
<span className="tabular flex items-center gap-2 border-x border-ink-200 px-3 py-2 text-xs font-bold text-ink-700">
<Icon name="calendar" className="h-4 w-4 text-ink-400" />
{range.label}
</span>
<button onClick={() => shift(1)} className="rounded-r-xl p-2.5 text-ink-500 hover:bg-ink-50" aria-label="Weiter">
<Icon name="chevronRight" className="h-4 w-4" strokeWidth={2.2} />
</button>
</div>
)}
</div>
{report.error && <ErrorBox message={report.error} onRetry={report.reload} />}
{!data ? (
<Spinner label="Lade Report…" />
) : (
<>
<Card className={`animate-rise grid gap-6 p-5 transition-opacity sm:p-6 lg:grid-cols-[1fr_1.2fr_1.3fr_1fr] ${report.loading ? 'opacity-60' : ''}`}>
<div>
<p className="text-sm font-semibold text-ink-500">Gesamtstunden</p>
<CountUp value={s!.minutes / 60} format={(v) => v.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} className="text-4xl font-extrabold text-ink-900" />
<p className="mt-1 text-xs text-ink-400">{s!.entries} Buchungen</p>
</div>
<div className="flex items-center gap-4">
<Donut percent={share} size={96} stroke={13} />
<div className="flex-1 space-y-1.5 text-sm">
<p className="flex items-center justify-between gap-3">
<span className="flex items-center gap-2 font-semibold text-ink-600">
<span className="h-3 w-3 rounded-sm" style={{ background: BILLABLE }} /> Billable
</span>
<b className="tabular text-ink-900">{formatHours(s!.billable_minutes)}</b>
</p>
<p className="flex items-center justify-between gap-3">
<span className="flex items-center gap-2 font-semibold text-ink-600">
<span className="h-3 w-3 rounded-sm" style={{ background: NON_BILLABLE }} /> Non-billable
</span>
<b className="tabular text-ink-900">{formatHours(s!.non_billable_minutes)}</b>
</p>
</div>
</div>
<div>
<p className="text-sm font-semibold text-ink-500">Billable Betrag</p>
<CountUp value={s!.amount} format={(v) => formatEuro(v, 2)} className="text-4xl font-extrabold text-ink-900" />
<label className="mt-2 flex cursor-pointer items-center gap-2 text-sm font-semibold text-ink-700">
<input type="checkbox" checked={includeFixed} onChange={(e) => setIncludeFixed(e.target.checked)} className="h-4 w-4 accent-[var(--color-brand-500)]" />
Festpreisprojekte einbeziehen
</label>
<p className="mt-1 text-[11px] leading-snug text-ink-400">
Festpreis-Beträge werden über den virtuellen Stundensatz (Budget ÷ gebuchte Stunden) verteilt – nur für den internen Gebrauch.
</p>
</div>
<div>
<p className="text-sm font-semibold text-ink-500">Auslastung</p>
<CountUp value={capacity ? (s!.minutes / capacity) * 100 : 0} format={(v) => `${Math.round(v)} %`} className="text-4xl font-extrabold text-ink-900" />
<p className="mt-1 text-xs text-ink-400">Kapazität {formatHours(capacity, 0)} h bis heute</p>
{!includeFixed && s!.fixed_amount > 0 && <p className="mt-2 text-[11px] text-ink-400">+ {formatEuro(s!.fixed_amount)} aus Festpreisprojekten</p>}
</div>
</Card>
<Card className="animate-rise overflow-hidden" style={{ '--i': 1 } as React.CSSProperties}>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-ink-100 px-4 pt-2">
<div className="no-scrollbar flex gap-1 overflow-x-auto overflow-y-hidden">
{(
[
['clients', 'Kunden'],
['projects', 'Projekte'],
['tasks', 'Tasks'],
['users', 'Mitarbeiter'],
] as const
).map(([key, label]) => (
<button key={key} onClick={() => setTab(key)} className={`relative whitespace-nowrap px-3 py-3 text-sm font-bold ${tab === key ? 'text-ink-900' : 'text-ink-400 hover:text-ink-700'}`}>
{label}
<span className="ml-1 text-[11px] text-ink-400">{data[key].length}</span>
{tab === key && <span className="absolute inset-x-2 bottom-0 h-0.5 rounded-full bg-[var(--color-brand-accent)]" />}
</button>
))}
</div>
<div className="flex items-center gap-2 pb-2">
<ExportMenu options={exportOptions} />
<IconButton icon="printer" label="Drucken" onClick={() => window.print()} className="hidden border border-ink-200 sm:block" />
</div>
</div>
<ReportTable
key={tab}
rows={data[tab]}
kind={tab}
label={{ clients: 'Kunde', projects: 'Projekt', tasks: 'Task', users: 'Name' }[tab]}
onRowClick={
tab === 'clients'
? (row) => navigate(`reports/kunde/${row.customer_id}`)
: tab === 'projects'
? (row) => navigate(`reports/projekt/${row.project_id}`)
: tab === 'users'
? (row) => setDetail(row)
: undefined
}
/>
</Card>
</>
)}
{detail && (
<UserEntriesDialog row={detail} range={range} customerId={customerId || null} onClose={() => setDetail(null)} />
)}
</div>
);
};
/** Die einzelnen Buchungen eines Mitarbeiters im gewaehlten Reportzeitraum. */
const UserEntriesDialog: React.FC<{
row: GroupRow;
range: { from: string; to: string; label: string };
customerId: number | null;
onClose: () => void;
}> = ({ row, range, customerId, onClose }) => {
const entries = useLoader(
() => entriesApi.list(range.from, range.to, row.user_id, customerId),
[range.from, range.to, row.user_id, customerId]
);
return (
<Modal
size="3xl"
onClose={onClose}
title={row.name}
subtitle={
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span>{range.label}</span>
<span className="text-ink-300">·</span>
<span>
{formatHours(row.minutes)} h gesamt, davon {formatHours(row.billable_minutes)} h billable
</span>
{row.utilization != null && (
<>
<span className="text-ink-300">·</span>
<span>{row.utilization} % Auslastung</span>
</>
)}
</span>
}
>
<div className="flex items-center gap-3 pb-4">
<Avatar id={row.user_id} name={row.name} color={row.color} avatar={row.avatar} size={40} external={row.is_external} />
<p className="text-sm font-semibold text-ink-500">
{entries.data ? `${entries.data.length} Buchungen` : 'Lade Buchungen…'}
</p>
</div>
{entries.error && <ErrorBox message={entries.error} onRetry={entries.reload} />}
{!entries.data ? (
<Spinner />
) : (
<div className="-mx-5 overflow-hidden border-t border-ink-100 sm:-mx-6">
<EntryList entries={entries.data} emptyText="Keine Buchungen in diesem Zeitraum." />
</div>
)}
</Modal>
);
};
export { periodRange };