feat(reports): Kundenreport nach Woche, Monat, Quartal, Jahr oder Zeitraum

Zeitraumwahl im Kundenreport (Standard Gesamt). Stunden, Team, Tasks,
Projektstunden und Exporte beziehen sich auf den Zeitraum; Diagramm je
Tag/Woche/Monat nach Laenge. Zeitraumwahl als PeriodPicker ausgelagert,
auch im Zeitreport genutzt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-30 14:01:23 +02:00
co-authored by Claude Opus 5.5
parent 5275f7c03d
commit 876ef24c75
6 changed files with 229 additions and 99 deletions
+20
View File
@@ -88,6 +88,25 @@ Ein Klick auf eine Zeile öffnet dieselbe Detailansicht wie eine Kachel, hier un
mindestens Vorgesetzter ist.
## Kundenreport: Zeitraum
*Reports → Kundenreport* hat neben der Kundenauswahl eine Zeitraumwahl: **Gesamt** (Standard, die ganze
Kundenbeziehung wie bisher), **Woche**, **Monat**, **Quartal**, **Jahr** mit Vor/Zurück-Pfeilen oder ein freier
**Zeitraum** (von–bis). Die Wahl merkt sich der Browser (`pulse_customer_report_period`) und bleibt beim
Kundenwechsel stehen, damit sich Kunden im selben Zeitraum vergleichen lassen.
- Auf den Zeitraum beziehen sich: Gesamtstunden (billable/non-billable), die Stunden je Projekt, die Tabs *Team*
und *Tasks* sowie die Exporte *PDF – Projekte* und *CSV – alle Buchungen*. Der Stundennachweis nimmt den Monat,
in dem der Zeitraum endet.
- Die zweite Kachel zeigt mit Zeitraum den **Umsatz im Zeitraum** (Buchungen inkl. Festpreis-Anteil), darunter
*Erbracht gesamt*. **Umsatz offen**, **Budget gesamt** und die Budgetbalken bleiben Stand heute – sie hängen am
Projekt, nicht am Zeitraum.
- Das Diagramm wählt die Einheit nach der Länge: bis 31 Tage je Tag, bis 184 Tage je Woche (KW), darüber je Monat.
Ohne Zeitraum wie bisher Monate mit Auswahl 6/12/24.
- API: `GET /api/reports/customer/:id?from=YYYY-MM-DD&to=YYYY-MM-DD` – nur beide Werte zusammen schränken ein,
`from > to` gibt 400. Die Antwort enthält `range`, `series_unit` (`day`/`week`/`month`) und `series` mit `key`/`start`.
- Die Zeitraumwahl ist `src/components/reports/PeriodPicker.tsx` und wird auch vom Zeitreport benutzt.
## Auslastung
Die Auslastung vergleicht **gebuchte Stunden bis heute** mit der **Kapazität bis heute**
@@ -335,6 +354,7 @@ Aus Auftrag und Call-Mitschrift.
| Kunden anlegen/bearbeiten, wer hat welche Projekte mit welchen Mitarbeitern, Umsatz erbracht/offen, animiert | ✅ | Kunden |
| Reports wie Harvest (Report.png), interaktiv, Export Excel/PDF/CSV | ✅ | Reports → Zeitreport |
| Report für einzelne Kunden / Projekt wie Report-Kunde.png | ✅ | Reports → Kunden-/Projektreport |
| Kundenreport nach Woche, Monat, Jahr oder freiem Zeitraum | ✅ zusätzlich Quartal und Gesamt | Reports → Kundenreport |
| Einstellungen (nur Admin): SSO-Credentials, Userverwaltung, Kopf/Fuß der Exporte | ✅ inkl. PDF-/Excel-Vorschau | Einstellungen |
| Arbeitsstunden pro Woche (40) | ✅ | Einstellungen → Allgemein |
| Projektbezeichnungen nach Muster | ✅ | Einstellungen → Allgemein |
+59 -23
View File
@@ -4,7 +4,7 @@ import { teamOf, userFilter, visibleUserIds } from '../lib/access.js';
import { getSettings } from '../lib/settings.js';
import { loadEntries } from '../lib/entries.js';
import { loadProjects } from '../lib/projects.js';
import { addDays, mondayOf, parseDate, today, isoWeek } from '../lib/dates.js';
import { addDays, daysBetween, mondayOf, parseDate, today, isoWeek } from '../lib/dates.js';
import { capacityMinutes } from './dashboard.js';
const router = express.Router();
@@ -196,6 +196,53 @@ function buildProjectDetail(project, entries) {
};
}
/**
* Zeitreihe fuer den Kundenreport. Ohne Zeitraum: Monate ueber die ganze
* Kundenbeziehung (hoechstens 24). Mit Zeitraum: die Einheit richtet sich nach
* der Laenge - bis 31 Tage je Tag, bis ein halbes Jahr je Woche, sonst je Monat.
*/
function customerSeries(entries, from, to) {
const sum = (key, start, own) => ({
key,
start,
minutes: own.reduce((s, e) => s + e.minutes, 0),
billable_minutes: own.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0),
amount: r2(own.reduce((s, e) => s + e.amount + e.fixed_amount, 0)),
});
const monthly = (first, last, limit) => {
const points = [];
const cursor = new Date(`${first}-01T00:00:00Z`);
while (cursor.toISOString().slice(0, 7) <= last && points.length < limit) {
const month = cursor.toISOString().slice(0, 7);
points.push(sum(month, `${month}-01`, entries.filter((e) => e.entry_date.startsWith(month))));
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
}
return points;
};
if (!from) {
const months = [...new Set(entries.map((e) => e.entry_date.slice(0, 7)))].sort();
const last = today().slice(0, 7);
return { unit: 'month', points: monthly(months[0] || last, last, 36).slice(-24) };
}
const days = daysBetween(from, to) + 1;
if (days <= 31) {
const points = [];
for (let d = from; d <= to; d = addDays(d, 1)) points.push(sum(d, d, entries.filter((e) => e.entry_date === d)));
return { unit: 'day', points };
}
if (days <= 184) {
const points = [];
for (let monday = mondayOf(from); monday <= to; monday = addDays(monday, 7)) {
const sunday = addDays(monday, 6);
points.push(sum(monday, monday, entries.filter((e) => e.entry_date >= monday && e.entry_date <= sunday)));
}
return { unit: 'week', points };
}
return { unit: 'month', points: monthly(from.slice(0, 7), to.slice(0, 7), 120) };
}
/** Kundenreport: alle Projekte eines Kunden mit Budget, Verlauf und Team. */
router.get('/customer/:id', async (req, res, next) => {
try {
@@ -204,31 +251,20 @@ router.get('/customer/:id', async (req, res, next) => {
if (!customer) return res.status(404).json({ error: 'Kunde nicht gefunden.' });
const projects = await loadProjects(settings, { where: 'p.customer_id = ?', params: [customer.id] });
const userIds = await visibleUserIds(req.user);
const entries = await loadEntries(settings, { customerId: customer.id, userIds, from: req.query.from, to: req.query.to });
// Zeitraum nur vollstaendig: von und bis, sonst die ganze Kundenbeziehung.
const ranged = parseDate(req.query.from) && parseDate(req.query.to);
const from = ranged ? req.query.from : null;
const to = ranged ? req.query.to : null;
if (ranged && from > to) return res.status(400).json({ error: 'Zeitraum ungültig.' });
const entries = await loadEntries(settings, { customerId: customer.id, userIds, from, to });
const series = customerSeries(entries, from, to);
// Monatsreihe ueber die ganze Kundenbeziehung, hoechstens 24 Monate.
const months = [...new Set(entries.map((e) => e.entry_date.slice(0, 7)))].sort();
const first = months[0] || today().slice(0, 7);
const series = [];
const cursor = new Date(`${first}-01T00:00:00Z`);
const last = today().slice(0, 7);
while (cursor.toISOString().slice(0, 7) <= last && series.length < 36) {
const month = cursor.toISOString().slice(0, 7);
const own = entries.filter((e) => e.entry_date.startsWith(month));
series.push({
month,
minutes: own.reduce((s, e) => s + e.minutes, 0),
billable_minutes: own.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0),
amount: r2(own.reduce((s, e) => s + e.amount + e.fixed_amount, 0)),
});
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
}
const minutes = entries.reduce((s, e) => s + e.minutes, 0);
const minutes = entries.reduce((s, e) => s + e.minutes, 0);
const billable = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0);
res.json({
customer,
range: { from, to },
series_unit: series.unit,
totals: {
minutes,
billable_minutes: billable,
@@ -238,7 +274,7 @@ router.get('/customer/:id', async (req, res, next) => {
open: r2(projects.reduce((s, p) => s + p.open, 0)),
budget_value: r2(projects.reduce((s, p) => s + p.budget_value, 0)),
},
series: series.slice(-24),
series: series.points,
projects: projects.map((p) => {
const own = entries.filter((e) => e.project_id === p.id);
return { ...buildProjectDetail(p, own), weekly: undefined };
+57 -22
View File
@@ -7,8 +7,9 @@ import { ExportMenu } from './ExportMenu';
import { ReportKpi, ReportRow } from './ProjectReport';
import { useAuth } from '../../lib/auth';
import { customersApi, qs, reportsApi, type CustomerReport as CustomerReportData } from '../../lib/api';
import { useLoader } from '../../lib/hooks';
import { MONTHS, colorFor, formatEuro, formatHours, formatNumber, todayIso } from '../../lib/format';
import { useLoader, usePersistentState } from '../../lib/hooks';
import { PeriodPicker, periodRange, type Period } from './PeriodPicker';
import { MONTHS, colorFor, formatDate, formatEuro, formatHours, formatNumber, isoWeek, monthStart, todayIso } from '../../lib/format';
import { useRouter } from '../../lib/router';
import { TYPE_TONE } from '../projects/Projects';
import { PROJECT_TYPE_LABELS } from '../../types';
@@ -16,7 +17,16 @@ import { PROJECT_TYPE_LABELS } from '../../types';
export const CustomerReport: React.FC<{ id: number | null }> = ({ id }) => {
const { navigate } = useRouter();
const customers = useLoader(() => customersApi.list(), []);
const report = useLoader(() => (id ? reportsApi.customer(id) : Promise.resolve(null)), [id]);
// Zeitraum bleibt beim Kundenwechsel erhalten - so lassen sich Kunden im
// selben Monat vergleichen. 'all' zeigt die ganze Kundenbeziehung.
const [period, setPeriod] = usePersistentState<Period | 'all'>('pulse_customer_report_period', 'all');
const [anchor, setAnchor] = useState(todayIso());
const [custom, setCustom] = useState({ from: monthStart(todayIso()), to: todayIso() });
const range = period === 'all' ? null : periodRange(period, anchor, custom);
const report = useLoader(
() => (id ? reportsApi.customer(id, range) : Promise.resolve(null)),
[id, range?.from, range?.to]
);
const [status, setStatus] = useState<'active' | 'inactive' | 'all'>('active');
// Der gewaehlte Kunde bleibt in der Liste, auch wenn der Filter ihn sonst
@@ -47,6 +57,7 @@ export const CustomerReport: React.FC<{ id: number | null }> = ({ id }) => {
{ value: 'all', label: 'Alle' },
]}
/>
<PeriodPicker period={period} onPeriod={setPeriod} anchor={anchor} onAnchor={setAnchor} custom={custom} onCustom={setCustom} allLabel="Gesamt" />
</div>
{!id ? (
<EmptyState icon="building" title="Kunde auswählen" text="Alle Projekte eines Kunden mit Budget, Umsatz, Verlauf und Team." />
@@ -55,13 +66,15 @@ export const CustomerReport: React.FC<{ id: number | null }> = ({ id }) => {
) : !report.data ? (
<Spinner label="Lade Kundenreport…" />
) : (
<Body key={id} data={report.data} />
<div className={`transition-opacity ${report.loading ? 'opacity-60' : ''}`}>
<Body key={id} data={report.data} rangeLabel={range?.label ?? null} />
</div>
)}
</div>
);
};
const Body: React.FC<{ data: CustomerReportData }> = ({ data }) => {
const Body: React.FC<{ data: CustomerReportData; rangeLabel: string | null }> = ({ data, rangeLabel }) => {
const { settings } = useAuth();
const { navigate } = useRouter();
const [tab, setTab] = useState<'projects' | 'team' | 'tasks'>('projects');
@@ -69,14 +82,26 @@ const Body: React.FC<{ data: CustomerReportData }> = ({ data }) => {
const c = data.customer;
const t = data.totals;
const color = colorFor(c.id, c.color);
const month = todayIso().slice(0, 7);
const series = useMemo(() => data.series.slice(-months), [data.series, months]);
const ranged = Boolean(data.range.from && data.range.to);
// Stundennachweis bezieht sich auf einen Monat: Monat des Zeitraumendes,
// sonst der laufende.
const month = (data.range.to ?? todayIso()).slice(0, 7);
const from = data.range.from ?? `${month.slice(0, 4)}-01-01`;
const to = data.range.to ?? todayIso();
const series = useMemo(() => (ranged ? data.series : data.series.slice(-months)), [data.series, months, ranged]);
const pointLabel = (start: string) => {
if (data.series_unit === 'day') return formatDate(start, 'day');
if (data.series_unit === 'week') return `KW ${isoWeek(start)}`;
return `${MONTHS[Number(start.slice(5, 7)) - 1].slice(0, 3)} ${start.slice(2, 4)}`;
};
const chartTitle = { day: 'Stunden je Tag', week: 'Stunden je Woche', month: 'Stunden je Monat' }[data.series_unit];
const rangeName = ranged ? 'Zeitraum' : 'Jahr';
const exportOptions = [
{ label: 'PDF – Stundennachweis (Monat)', hint: 'Vorlage für den Kunden, alle Berater', path: `/exports/timesheet${qs({ month, format: 'pdf', user_id: 'all', customer_id: c.id })}`, filename: `${c.short_name}-${month}.pdf` },
{ label: 'Excel – Stundennachweis mit Beträgen', hint: 'Für die Rechnungsstellung', path: `/exports/timesheet${qs({ month, format: 'xlsx', user_id: 'all', customer_id: c.id, amounts: 1 })}`, filename: `${c.short_name}-${month}.xlsx` },
{ label: 'PDF – Projekte (Jahr)', path: `/exports/report${qs({ from: `${month.slice(0, 4)}-01-01`, to: todayIso(), customer_id: c.id, group: 'projects', format: 'pdf', include_fixed: 1 })}`, filename: `${c.short_name}-projekte.pdf` },
{ label: 'CSV – alle Buchungen (Jahr)', path: `/exports/report${qs({ from: `${month.slice(0, 4)}-01-01`, to: todayIso(), customer_id: c.id, group: 'detail', format: 'csv', include_fixed: 1 })}`, filename: `${c.short_name}.csv` },
{ label: `PDF – Projekte (${rangeName})`, path: `/exports/report${qs({ from, to, customer_id: c.id, group: 'projects', format: 'pdf', include_fixed: 1 })}`, filename: `${c.short_name}-projekte.pdf` },
{ label: `CSV – alle Buchungen (${rangeName})`, path: `/exports/report${qs({ from, to, customer_id: c.id, group: 'detail', format: 'csv', include_fixed: 1 })}`, filename: `${c.short_name}.csv` },
];
return (
@@ -88,6 +113,7 @@ const Body: React.FC<{ data: CustomerReportData }> = ({ data }) => {
<div className="min-w-0 flex-1">
<h2 className="truncate text-2xl text-ink-900 sm:text-3xl">{c.name}</h2>
<p className="text-sm text-ink-500">
{rangeLabel && <b className="font-bold text-ink-700">{rangeLabel} · </b>}
{data.projects.length} Projekte · {data.team.length} Mitarbeiter
{c.contact_person && ` · Ansprechpartner: ${c.contact_person}`}
</p>
@@ -101,10 +127,17 @@ const Body: React.FC<{ data: CustomerReportData }> = ({ data }) => {
<ReportRow label="Billable" value={formatHours(t.billable_minutes)} />
<ReportRow label="Non-billable" value={formatHours(t.non_billable_minutes)} />
</ReportKpi>
<ReportKpi index={2} title="Umsatz erbracht">
<CountUp value={t.earned} format={(v) => formatEuro(v, 2)} className="text-3xl font-extrabold text-ink-900" />
<ReportRow label="davon aus Buchungen" value={formatEuro(t.amount, 2)} />
</ReportKpi>
{ranged ? (
<ReportKpi index={2} title="Umsatz im Zeitraum">
<CountUp value={t.amount} format={(v) => formatEuro(v, 2)} className="text-3xl font-extrabold text-ink-900" />
<ReportRow label="Erbracht gesamt" value={formatEuro(t.earned, 2)} />
</ReportKpi>
) : (
<ReportKpi index={2} title="Umsatz erbracht">
<CountUp value={t.earned} format={(v) => formatEuro(v, 2)} className="text-3xl font-extrabold text-ink-900" />
<ReportRow label="davon aus Buchungen" value={formatEuro(t.amount, 2)} />
</ReportKpi>
)}
<ReportKpi index={3} title="Umsatz offen">
<CountUp value={t.open} format={(v) => formatEuro(v, 2)} className="text-3xl font-extrabold text-ink-900" />
<ReportRow label="in laufenden Töpfen" value={`${data.projects.reduce((s, p) => s + p.project.pots.filter((x) => x.status === 'active').length, 0)}`} />
@@ -118,25 +151,27 @@ const Body: React.FC<{ data: CustomerReportData }> = ({ data }) => {
<Card className="animate-rise p-4 sm:p-5" style={{ '--i': 5 } as React.CSSProperties}>
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-base text-ink-900">Stunden je Monat</h3>
<h3 className="text-base text-ink-900">{chartTitle}</h3>
<Legend className="mt-1" items={[{ color: BILLABLE, label: 'Billable' }, { color: NON_BILLABLE, label: 'Non-billable' }]} />
</div>
<select className="rounded-xl border border-ink-200 bg-surface px-3 py-1.5 text-xs font-bold text-ink-600" value={months} onChange={(e) => setMonths(Number(e.target.value))}>
<option value={6}>6 Monate</option>
<option value={12}>12 Monate</option>
<option value={24}>24 Monate</option>
</select>
{!ranged && (
<select className="rounded-xl border border-ink-200 bg-surface px-3 py-1.5 text-xs font-bold text-ink-600" value={months} onChange={(e) => setMonths(Number(e.target.value))}>
<option value={6}>6 Monate</option>
<option value={12}>12 Monate</option>
<option value={24}>24 Monate</option>
</select>
)}
</div>
{series.length ? (
{series.some((m) => m.minutes > 0) ? (
<StackedBars
scale={60}
points={series.map((m) => ({ key: m.month, label: `${MONTHS[Number(m.month.slice(5)) - 1].slice(0, 3)} ${m.month.slice(2, 4)}`, a: m.billable_minutes, b: m.minutes - m.billable_minutes }))}
points={series.map((m) => ({ key: m.key, label: pointLabel(m.start), a: m.billable_minutes, b: m.minutes - m.billable_minutes }))}
format={(v) => `${formatHours(v, 1)} h`}
axisFormat={(v) => formatNumber(v / 60, 0)}
height={220}
/>
) : (
<p className="py-10 text-center text-xs text-ink-400">Noch keine Buchungen.</p>
<p className="py-10 text-center text-xs text-ink-400">{ranged ? 'Keine Buchungen im Zeitraum.' : 'Noch keine Buchungen.'}</p>
)}
</Card>
+86
View File
@@ -0,0 +1,86 @@
import React from 'react';
import { inputClass } from '../ui/controls';
import { Icon } from '../ui/Icon';
import { MONTHS, addDays, addMonths, formatDate, isoWeek, mondayOf, monthEnd, monthStart } from '../../lib/format';
export type Period = 'week' | 'month' | 'quarter' | 'year' | 'custom';
export 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)}` };
}
/** Verschiebt den Bezugstag um eine Periode vor oder zurueck. */
export function shiftAnchor(period: Period, anchor: string, step: number) {
if (period === 'week') return addDays(anchor, 7 * step);
if (period === 'month') return addMonths(anchor, step);
if (period === 'quarter') return addMonths(anchor, 3 * step);
if (period === 'year') return `${Number(anchor.slice(0, 4)) + step}-01-01`;
return anchor;
}
/**
* Auswahl Woche/Monat/Quartal/Jahr/Zeitraum mit Blaettern. `allLabel` blendet
* zusaetzlich eine Option ohne Einschraenkung ein (Wert 'all').
*/
export function PeriodPicker<P extends Period | 'all'>({
period,
onPeriod,
anchor,
onAnchor,
custom,
onCustom,
allLabel,
}: {
period: P;
onPeriod: (p: P) => void;
anchor: string;
onAnchor: (a: string) => void;
custom: { from: string; to: string };
onCustom: (c: { from: string; to: string }) => void;
allLabel?: string;
}) {
const label = period === 'all' ? null : periodRange(period as Period, anchor, custom).label;
return (
<>
<select className={`${inputClass} !w-auto`} value={period} onChange={(e) => onPeriod(e.target.value as P)}>
{allLabel && <option value="all">{allLabel}</option>}
<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 === 'all' ? null : period === 'custom' ? (
<div className="flex items-center gap-2">
<input type="date" className={`${inputClass} !w-auto`} value={custom.from} max={custom.to} onChange={(e) => e.target.value && onCustom({ ...custom, from: e.target.value })} />
<span className="text-ink-400">–</span>
<input type="date" className={`${inputClass} !w-auto`} value={custom.to} min={custom.from} onChange={(e) => e.target.value && onCustom({ ...custom, to: e.target.value })} />
</div>
) : (
<div className="flex items-center rounded-xl border border-ink-200 bg-surface">
<button onClick={() => onAnchor(shiftAnchor(period as Period, anchor, -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" />
{label}
</span>
<button onClick={() => onAnchor(shiftAnchor(period as Period, anchor, 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>
)}
</>
);
}
+3 -52
View File
@@ -1,38 +1,22 @@
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 { PeriodPicker, periodRange, type Period } from './PeriodPicker';
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 { formatEuro, formatHours, 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;
@@ -105,13 +89,6 @@ const TimeReport: React.FC = () => {
[range.from, range.to, customerId, team, includeFixed, hideNonBillable]
);
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, hide_non_billable: hideNonBillable ? 1 : undefined };
const groupName = { clients: 'Kunden', projects: 'Projekte', tasks: 'Tasks', users: 'Mitarbeiter' }[tab];
const exportOptions = useMemo(
@@ -165,33 +142,7 @@ const TimeReport: React.FC = () => {
</optgroup>
)}
</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>
)}
<PeriodPicker period={period} onPeriod={setPeriod} anchor={anchor} onAnchor={setAnchor} custom={custom} onCustom={setCustom} />
</div>
{report.error && <ErrorBox message={report.error} onRetry={report.reload} />}
+4 -2
View File
@@ -489,6 +489,8 @@ export interface ProjectDetail {
export interface CustomerReport {
customer: Customer;
range: { from: string | null; to: string | null };
series_unit: 'day' | 'week' | 'month';
totals: {
minutes: number;
billable_minutes: number;
@@ -498,7 +500,7 @@ export interface CustomerReport {
open: number;
budget_value: number;
};
series: Array<{ month: string; minutes: number; billable_minutes: number; amount: number }>;
series: Array<{ key: string; start: string; minutes: number; billable_minutes: number; amount: number }>;
projects: Array<Omit<ProjectDetail, 'weekly'>>;
team: GroupRow[];
tasks: GroupRow[];
@@ -514,5 +516,5 @@ export const reportsApi = {
})}`
),
project: (id: number) => get<ProjectDetail>(`/reports/project/${id}`),
customer: (id: number) => get<CustomerReport>(`/reports/customer/${id}`),
customer: (id: number, range?: { from: string; to: string } | null) => get<CustomerReport>(`/reports/customer/${id}${qs({ from: range?.from, to: range?.to })}`),
};