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 (
{( [ ['time', 'Zeitreport', 'reports'], ['customer', 'Kundenreport', 'reports/kunde'], ['project', 'Projektreport', 'reports/projekt'], ] as const ).map(([key, label, path]) => ( ))}
{view === 'time' && } {view === 'customer' && } {view === 'project' && }
); }; const TimeReport: React.FC = () => { const { user } = useAuth(); const { navigate } = useRouter(); const [period, setPeriod] = usePersistentState('pulse_report_period', 'month'); const [anchor, setAnchor] = useState(todayIso()); const [custom, setCustom] = useState({ from: monthStart(todayIso()), to: todayIso() }); const [tab, setTab] = usePersistentState('pulse_report_tab', 'users'); const [customerId, setCustomerId] = useState(''); const [team, setTeam] = useState(''); const [includeFixed, setIncludeFixed] = useState(false); const [customers, setCustomers] = useState([]); const [leads, setLeads] = useState([]); // Klick auf einen Mitarbeiter oeffnet seine Buchungen des Zeitraums als Liste. const [detail, setDetail] = useState(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 (
{atLeast(user, 'management') && leads.length > 0 && ( )} {period === 'custom' ? (
setCustom({ ...custom, from: e.target.value })} /> – setCustom({ ...custom, to: e.target.value })} />
) : (
{range.label}
)}
{report.error && } {!data ? ( ) : ( <>

Gesamtstunden

v.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} className="text-4xl font-extrabold text-ink-900" />

{s!.entries} Buchungen

Billable {formatHours(s!.billable_minutes)}

Non-billable {formatHours(s!.non_billable_minutes)}

Billable Betrag

formatEuro(v, 2)} className="text-4xl font-extrabold text-ink-900" />

Festpreis-Beträge werden über den virtuellen Stundensatz (Budget ÷ gebuchte Stunden) verteilt – nur für den internen Gebrauch.

Auslastung

`${Math.round(v)} %`} className="text-4xl font-extrabold text-ink-900" />

Kapazität {formatHours(capacity, 0)} h bis heute

{!includeFixed && s!.fixed_amount > 0 &&

+ {formatEuro(s!.fixed_amount)} aus Festpreisprojekten

}
{( [ ['clients', 'Kunden'], ['projects', 'Projekte'], ['tasks', 'Tasks'], ['users', 'Mitarbeiter'], ] as const ).map(([key, label]) => ( ))}
window.print()} className="hidden border border-ink-200 sm:block" />
navigate(`reports/kunde/${row.customer_id}`) : tab === 'projects' ? (row) => navigate(`reports/projekt/${row.project_id}`) : tab === 'users' ? (row) => setDetail(row) : undefined } />
)} {detail && ( setDetail(null)} /> )}
); }; /** 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 ( {range.label} · {formatHours(row.minutes)} h gesamt, davon {formatHours(row.billable_minutes)} h billable {row.utilization != null && ( <> · {row.utilization} % Auslastung )} } >

{entries.data ? `${entries.data.length} Buchungen` : 'Lade Buchungen…'}

{entries.error && } {!entries.data ? ( ) : (
)}
); }; export { periodRange };