From 876ef24c755d6c576d43275932e2367bdd9a5577 Mon Sep 17 00:00:00 2001 From: schakal00783 Date: Wed, 30 Sep 2026 14:01:23 +0200 Subject: [PATCH] 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 --- README.md | 20 ++++++ server/src/routes/reports.js | 82 +++++++++++++++------ src/components/reports/CustomerReport.tsx | 79 +++++++++++++++------ src/components/reports/PeriodPicker.tsx | 86 +++++++++++++++++++++++ src/components/reports/Reports.tsx | 55 +-------------- src/lib/api.ts | 6 +- 6 files changed, 229 insertions(+), 99 deletions(-) create mode 100644 src/components/reports/PeriodPicker.tsx diff --git a/README.md b/README.md index 7a9edc7..197f644 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/server/src/routes/reports.js b/server/src/routes/reports.js index 578da54..8e579b9 100644 --- a/server/src/routes/reports.js +++ b/server/src/routes/reports.js @@ -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 }; diff --git a/src/components/reports/CustomerReport.tsx b/src/components/reports/CustomerReport.tsx index 354afc1..1ca8cb8 100644 --- a/src/components/reports/CustomerReport.tsx +++ b/src/components/reports/CustomerReport.tsx @@ -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('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' }, ]} /> + {!id ? ( @@ -55,13 +66,15 @@ export const CustomerReport: React.FC<{ id: number | null }> = ({ id }) => { ) : !report.data ? ( ) : ( - +
+ +
)} ); }; -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 }) => {

{c.name}

+ {rangeLabel && {rangeLabel} · } {data.projects.length} Projekte · {data.team.length} Mitarbeiter {c.contact_person && ` · Ansprechpartner: ${c.contact_person}`}

@@ -101,10 +127,17 @@ const Body: React.FC<{ data: CustomerReportData }> = ({ data }) => { - - formatEuro(v, 2)} className="text-3xl font-extrabold text-ink-900" /> - - + {ranged ? ( + + formatEuro(v, 2)} className="text-3xl font-extrabold text-ink-900" /> + + + ) : ( + + formatEuro(v, 2)} className="text-3xl font-extrabold text-ink-900" /> + + + )} formatEuro(v, 2)} className="text-3xl font-extrabold text-ink-900" /> s + p.project.pots.filter((x) => x.status === 'active').length, 0)}`} /> @@ -118,25 +151,27 @@ const Body: React.FC<{ data: CustomerReportData }> = ({ data }) => {
-

Stunden je Monat

+

{chartTitle}

- + {!ranged && ( + + )}
- {series.length ? ( + {series.some((m) => m.minutes > 0) ? ( ({ 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} /> ) : ( -

Noch keine Buchungen.

+

{ranged ? 'Keine Buchungen im Zeitraum.' : 'Noch keine Buchungen.'}

)}
diff --git a/src/components/reports/PeriodPicker.tsx b/src/components/reports/PeriodPicker.tsx new file mode 100644 index 0000000..e4e31d4 --- /dev/null +++ b/src/components/reports/PeriodPicker.tsx @@ -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

({ + 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 ( + <> + + {period === 'all' ? null : period === 'custom' ? ( +

+ e.target.value && onCustom({ ...custom, from: e.target.value })} /> + – + e.target.value && onCustom({ ...custom, to: e.target.value })} /> +
+ ) : ( +
+ + + + {label} + + +
+ )} + + ); +} diff --git a/src/components/reports/Reports.tsx b/src/components/reports/Reports.tsx index d8733f9..25240bf 100644 --- a/src/components/reports/Reports.tsx +++ b/src/components/reports/Reports.tsx @@ -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 = () => { )} - - {period === 'custom' ? ( -
- setCustom({ ...custom, from: e.target.value })} /> - – - setCustom({ ...custom, to: e.target.value })} /> -
- ) : ( -
- - - - {range.label} - - -
- )} +
{report.error && } diff --git a/src/lib/api.ts b/src/lib/api.ts index c3b7d41..fa0708e 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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>; team: GroupRow[]; tasks: GroupRow[]; @@ -514,5 +516,5 @@ export const reportsApi = { })}` ), project: (id: number) => get(`/reports/project/${id}`), - customer: (id: number) => get(`/reports/customer/${id}`), + customer: (id: number, range?: { from: string; to: string } | null) => get(`/reports/customer/${id}${qs({ from: range?.from, to: range?.to })}`), };