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>
321 lines
16 KiB
TypeScript
321 lines
16 KiB
TypeScript
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 };
|