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)}
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 };