feat(ansichten): Listenansicht fuer Buchungen, Report und Kunden

Neue Komponente EntryList zeigt einzelne Buchungen als sortierbare Tabelle
mit Datum, Kunde, Projekt, Task und der Zeit getrennt nach billable und
non-billable, inklusive Summen.

- Buchung: Umschalter Kalender/Liste neben Woche/Monat/Jahr, Auswahl wird
  im Browser gemerkt. Klick auf eine Zeile oeffnet die Buchung.
- Reports: Klick auf einen Mitarbeiter oeffnet dessen Buchungen des
  Reportzeitraums als Liste; ein gesetzter Kundenfilter wird uebernommen.
- Kunden: Umschalter Karten/Liste mit Ort, Projekten, Stunden und Umsatz.
- GET /api/entries akzeptiert dafuer customer_id, Segmented nimmt Icons,
  Modal kennt die Groesse 2xl.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-18 10:14:48 +02:00
co-authored by Claude Opus 5
parent 8c93df11d9
commit ef7171496f
9 changed files with 424 additions and 17 deletions
+63 -3
View File
@@ -1,13 +1,15 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Card, ErrorBox, IconButton, Spinner, inputClass } from '../ui/controls';
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, qs, reportsApi, usersApi } from '../../lib/api';
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';
@@ -75,6 +77,8 @@ const TimeReport: React.FC = () => {
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);
@@ -247,14 +251,70 @@ const TimeReport: React.FC = () => {
? (row) => navigate(`reports/kunde/${row.customer_id}`)
: tab === 'projects'
? (row) => navigate(`reports/projekt/${row.project_id}`)
: undefined
: 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="2xl"
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} 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 };