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:
co-authored by
Claude Opus 5.5
parent
5275f7c03d
commit
876ef24c75
@@ -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 |
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 })}`),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user