Compare commits

..
4 Commits
Author SHA1 Message Date
schakal00783andClaude Opus 5.5 876ef24c75 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>
2026-09-30 14:01:23 +02:00
schakal00783andClaude Opus 5.5 5275f7c03d fix(buchung): Plus-Button mobil unten mittig
Der schwebende Plus-Button sass rechts unten und verdeckte den
Kopier-Knopf der Buchungskarten. Jetzt mittig, dazu Abstand am
Seitenende, damit die letzte Karte ueber den Button scrollen kann.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:55:50 +02:00
schakal00783andClaude Opus 5.5 ef709d423c feat(buchung): Buchung per Monatskalender auf mehrere Tage kopieren
Das Kopier-Icon (Liste, Wochenansicht) und "Kopieren" im Bearbeiten-Dialog
oeffnen jetzt einen kleinen Monatskalender. Dort werden beliebige Tage
an- und abgewaehlt, auch ueber Monate hinweg, und in einem Schritt gebucht.

- Tage ausserhalb der Projektlaufzeit bzw. ohne Budgettopf sind gesperrt.
- Urlaub/Feiertage sind markiert, bereits gebuchte Stunden je Tag sichtbar.
- "Werktage" waehlt Mo-Fr des Monats ohne Urlaub und Feiertag.
- Buchungen laufen nacheinander; fehlgeschlagene Tage bleiben ausgewaehlt.
- Der Vorlage-Modus im Buchungsdialog entfaellt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:51:18 +02:00
schakal00783andClaude Opus 5.5 2b9b7a6ae3 feat(buchung): Buchungen kopieren
Duplizieren-Button im Bearbeiten-Dialog und Kopier-Icon je Buchung in
Liste und Wochenansicht. Die Kopie oeffnet den Dialog als neue Buchung
fuer heute mit Projekt, Task, Notiz und Zeit der Vorlage. Inaktive
Projekte oder Tasks fallen auf die Standardauswahl zurueck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:45:44 +02:00
11 changed files with 616 additions and 134 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 };
+29 -4
View File
@@ -3,6 +3,7 @@ import { Avatar, Button, Card, ErrorBox, Segmented, Spinner, inputClass } from '
import { Icon } from '../ui/Icon';
import { Donut } from '../ui/charts';
import { EntryModal } from './EntryModal';
import { CopyDialog } from './CopyDialog';
import { ExportDialog } from './ExportDialog';
import { WeekView } from './WeekView';
import { MonthView } from './MonthView';
@@ -54,6 +55,7 @@ export const Booking: React.FC = () => {
const [layout, setLayout] = usePersistentState<BookingLayout>('pulse_booking_layout', 'calendar');
const [anchor, setAnchor] = useState(todayIso());
const [modal, setModal] = useState<{ date: string; entry?: TimeEntry | null } | null>(null);
const [copyEntry, setCopyEntry] = useState<TimeEntry | null>(null);
const [exportOpen, setExportOpen] = useState(false);
const [viewUserId, setViewUserId] = useState<number>(user!.id);
const isLead = atLeast(user, 'lead');
@@ -100,6 +102,13 @@ export const Booking: React.FC = () => {
setModal({ date: date ?? (today >= range.from && today <= range.to ? today : range.from) });
};
/** Kopieren: Tage im Monatskalender waehlen, auf die die Buchung kopiert wird. */
const copy = (entry: TimeEntry) => {
if (readOnly) return;
setModal(null);
setCopyEntry(entry);
};
/*
* Im Export stehen nur die eigenen Projekte zur Wahl - auch fuer
* Vorgesetzte. Wer einen Nachweis fuer einen Kunden erstellt, filtert nach
@@ -131,7 +140,7 @@ export const Booking: React.FC = () => {
);
return (
<div className="mx-auto max-w-[1500px]">
<div className={`mx-auto max-w-[1500px] ${isMobile && !readOnly ? 'pb-16' : ''}`}>
{/* Werkzeugleiste */}
<div className="mb-4 flex flex-wrap items-center gap-2 sm:gap-3">
<div className="flex items-center rounded-xl border border-ink-200 bg-surface shadow-card">
@@ -228,12 +237,13 @@ export const Booking: React.FC = () => {
<EntryList
entries={list}
onEdit={readOnly ? undefined : (entry) => setModal({ date: entry.entry_date, entry })}
onCopy={readOnly ? undefined : copy}
emptyText={`Keine Buchungen ${mode === 'week' ? 'in dieser Woche' : mode === 'month' ? 'in diesem Monat' : 'in diesem Jahr'}.`}
/>
</Card>
)}
{layout === 'calendar' && mode === 'week' && (
<WeekView from={range.from} entries={list} readOnly={readOnly} dailyTarget={(weekly / 5) * 60} onAdd={openNew} onEdit={(entry) => !readOnly && setModal({ date: entry.entry_date, entry })} />
<WeekView from={range.from} entries={list} readOnly={readOnly} dailyTarget={(weekly / 5) * 60} onAdd={openNew} onEdit={(entry) => !readOnly && setModal({ date: entry.entry_date, entry })} onCopy={copy} />
)}
{layout === 'calendar' && mode === 'month' && (
<MonthView
@@ -266,14 +276,29 @@ export const Booking: React.FC = () => {
<button
onClick={() => openNew()}
aria-label="Zeit buchen"
className="animate-glow fixed bottom-24 right-5 z-40 flex h-16 w-16 items-center justify-center rounded-full bg-brand-500 text-white shadow-lift transition active:scale-95"
className="animate-glow fixed bottom-24 left-1/2 z-40 flex -translate-x-1/2 h-16 w-16 items-center justify-center rounded-full bg-brand-500 text-white shadow-lift transition active:scale-95"
>
<Icon name="plus" className="h-8 w-8" strokeWidth={2.6} />
</button>
)}
{modal && projects.data && (
<EntryModal projects={projects.data} date={modal.date} entry={modal.entry} onClose={() => setModal(null)} onSaved={() => { entries.reload(); projects.reload(); }} />
<EntryModal
projects={projects.data}
date={modal.date}
entry={modal.entry}
onClose={() => setModal(null)}
onSaved={() => { entries.reload(); projects.reload(); }}
onDuplicate={copy}
/>
)}
{copyEntry && projects.data && (
<CopyDialog
entry={copyEntry}
projects={projects.data}
onClose={() => setCopyEntry(null)}
onSaved={() => { entries.reload(); projects.reload(); }}
/>
)}
{exportOpen && (
<ExportDialog
+279
View File
@@ -0,0 +1,279 @@
import React, { useMemo, useState } from 'react';
import { Modal } from '../ui/Modal';
import { Button } from '../ui/controls';
import { Icon } from '../ui/Icon';
import { useAuth } from '../../lib/auth';
import { calendarApi, entriesApi } from '../../lib/api';
import { useLoader } from '../../lib/hooks';
import {
MONTHS,
WEEKDAYS_SHORT,
addDays,
addMonths,
colorFor,
formatDate,
formatDuration,
fromIso,
mondayOf,
monthEnd,
monthStart,
todayIso,
weekdayIndex,
} from '../../lib/format';
import { useToast } from '../Toast';
import { checkPot } from './budget';
import type { Project, TimeEntry } from '../../types';
interface Props {
entry: TimeEntry;
projects: Project[];
onClose: () => void;
onSaved: () => void;
}
/**
* Buchung auf mehrere Tage kopieren: kleiner Monatskalender, Tage per Klick
* an- und abwaehlen, dann alle auf einmal buchen. Projekt, Task, Notiz und
* Zeit kommen aus der Vorlage.
*/
export const CopyDialog: React.FC<Props> = ({ entry, projects, onClose, onSaved }) => {
const { user, settings } = useAuth();
const { addToast } = useToast();
const [month, setMonth] = useState(() => monthStart(todayIso()));
const [selected, setSelected] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const today = todayIso();
const start = mondayOf(month);
const end = monthEnd(month);
const weeks = Math.ceil(((fromIso(end).getTime() - fromIso(start).getTime()) / 86400000 + 1) / 7);
const cells = Array.from({ length: weeks * 7 }, (_, i) => addDays(start, i));
const gridTo = cells[cells.length - 1];
// Eigene Buchungen und Abwesenheiten im sichtbaren Ausschnitt - nur als Orientierung.
const booked = useLoader(() => entriesApi.list(start, gridTo, user!.id), [start, gridTo]);
const calendar = useLoader(() => calendarApi.get(start, gridTo), [start, gridTo]);
const minutesByDay = useMemo(() => {
const map = new Map<string, number>();
for (const e of booked.data ?? []) map.set(e.entry_date, (map.get(e.entry_date) ?? 0) + e.minutes);
return map;
}, [booked.data]);
const absenceByDay = useMemo(() => {
const map = new Map<string, string>();
for (const d of calendar.data?.days ?? []) if (d.user_id === user!.id) map.set(d.day, d.half ? `${d.label} (halber Tag)` : d.label);
return map;
}, [calendar.data, user]);
const project = projects.find((p) => p.id === entry.project_id && p.is_active) ?? null;
const task = project?.tasks.find((t) => t.id === entry.task_id) ?? null;
const taskGone = entry.task_id != null && (!task || !task.is_active);
/** Laufzeit und Topf pro Tag - das Budget selbst prueft das Backend beim Buchen. */
const blocked = (date: string) => {
if (!project) return 'Projekt ist nicht mehr aktiv.';
const check = checkPot(project, date, 0, settings.hours_per_day, settings.budget_warn_percent, settings.budget_warn_days);
return check.ok ? null : check.message;
};
const toggle = (date: string) =>
setSelected((prev) => {
const next = new Set(prev);
if (next.has(date)) next.delete(date);
else next.add(date);
return next;
});
/** Alle Werktage des Monats ohne Urlaub und Feiertag. */
const selectWorkdays = () =>
setSelected((prev) => {
const next = new Set(prev);
for (const date of cells) {
if (date.slice(0, 7) !== month.slice(0, 7) || weekdayIndex(date) >= 5) continue;
if (absenceByDay.has(date) || blocked(date)) continue;
next.add(date);
}
return next;
});
const clearMonth = () =>
setSelected((prev) => new Set([...prev].filter((d) => d.slice(0, 7) !== month.slice(0, 7))));
const dates = [...selected].sort();
const totalMinutes = dates.length * entry.minutes;
const save = async () => {
if (!project || dates.length === 0) return;
setBusy(true);
const done: string[] = [];
const failed: { date: string; message: string }[] = [];
const warnings = new Set<string>();
// Nacheinander, damit das Backend den Topf nach jeder Buchung neu rechnet.
for (const date of dates) {
try {
const result = await entriesApi.create({
project_id: project.id,
task_id: taskGone ? null : entry.task_id,
entry_date: date,
minutes: entry.minutes,
note: entry.note ?? '',
});
done.push(date);
if (result.warning) warnings.add(result.warning);
} catch (err) {
failed.push({ date, message: err instanceof Error ? err.message : 'Speichern fehlgeschlagen.' });
}
}
setBusy(false);
if (done.length) {
addToast(`${done.length} × ${formatDuration(entry.minutes)} h auf ${project.display_name} gebucht.`, 'success');
onSaved();
}
warnings.forEach((w) => addToast(w, 'info'));
if (failed.length === 0) return onClose();
// Fehlgeschlagene Tage bleiben ausgewaehlt, damit man sie gezielt nachbuchen oder abwaehlen kann.
setSelected(new Set(failed.map((f) => f.date)));
for (const f of failed) addToast(`${formatDate(f.date)}: ${f.message}`, 'error');
booked.reload();
};
const color = colorFor(entry.customer_id, entry.customer_color);
return (
<Modal
title="Buchung kopieren"
subtitle="Tage auswählen, auf die kopiert werden soll"
onClose={onClose}
size="md"
footer={
<div className="flex items-center justify-between gap-3">
<p className="tabular text-xs font-bold text-ink-500">
{dates.length ? `${dates.length} ${dates.length === 1 ? 'Tag' : 'Tage'} · ${formatDuration(totalMinutes)} h` : 'Keine Tage gewählt'}
</p>
<div className="flex gap-2">
<Button variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button icon="copy" busy={busy} onClick={save} disabled={!project || dates.length === 0}>
Buchen
</Button>
</div>
</div>
}
>
<div className="space-y-4">
{/* Vorlage */}
<div className="relative overflow-hidden rounded-xl border border-ink-200 bg-ink-50/60 p-3 pl-4">
<span aria-hidden className="absolute inset-y-0 left-0 w-1.5" style={{ background: color }} />
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="break-words text-sm font-extrabold text-ink-900">{entry.project_display}</p>
{entry.task_name && <p className="break-words text-xs font-semibold text-ink-500">{entry.task_name}</p>}
{entry.note && <p className="mt-0.5 break-words text-xs text-ink-400">{entry.note}</p>}
</div>
<span className="tabular shrink-0 text-lg font-extrabold text-ink-800">{formatDuration(entry.minutes)}</span>
</div>
</div>
{!project && (
<p className="rounded-xl border border-red-200 bg-red-50 p-3 text-sm font-semibold text-red-700">
Das Projekt ist nicht mehr aktiv oder dir nicht mehr zugewiesen – Kopieren ist nicht möglich.
</p>
)}
{project && taskGone && (
<p className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm font-semibold text-amber-700">
Der Task „{entry.task_name}“ ist nicht mehr aktiv – die Kopien werden ohne Task gebucht.
</p>
)}
{/* Monatskalender */}
<div>
<div className="mb-2 flex items-center justify-between">
<button
onClick={() => setMonth(addMonths(month, -1))}
className="rounded-lg p-2 text-ink-500 transition hover:bg-ink-100 hover:text-ink-800"
aria-label="Vorheriger Monat"
>
<Icon name="chevronLeft" className="h-4 w-4" />
</button>
<p className="text-sm font-extrabold text-ink-900">
{MONTHS[Number(month.slice(5, 7)) - 1]} {month.slice(0, 4)}
</p>
<button
onClick={() => setMonth(addMonths(month, 1))}
className="rounded-lg p-2 text-ink-500 transition hover:bg-ink-100 hover:text-ink-800"
aria-label="Nächster Monat"
>
<Icon name="chevronRight" className="h-4 w-4" />
</button>
</div>
<div className="mb-1 grid grid-cols-7 gap-1">
{WEEKDAYS_SHORT.map((d) => (
<p key={d} className="py-1 text-center text-[10px] font-extrabold uppercase tracking-wider text-ink-400">
{d}
</p>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{cells.map((date, i) => {
const inMonth = date.slice(0, 7) === month.slice(0, 7);
const reason = blocked(date);
const absence = absenceByDay.get(date);
const minutes = minutesByDay.get(date) ?? 0;
const active = selected.has(date);
const weekend = i % 7 >= 5;
const title = [formatDate(date, 'long'), reason, absence, minutes ? `${formatDuration(minutes)} h gebucht` : null].filter(Boolean).join('\n');
return (
<button
key={date}
onClick={() => toggle(date)}
disabled={!!reason || busy}
aria-pressed={active}
title={title}
className={`relative flex h-12 flex-col items-center justify-center rounded-xl border text-sm font-extrabold transition disabled:cursor-not-allowed ${
active
? 'border-brand-500 bg-brand-500 text-white shadow-card'
: reason
? 'border-transparent text-ink-300 line-through'
: absence
? 'border-amber-200 bg-amber-50 text-amber-700 hover:border-brand-300'
: date === today
? 'border-brand-300 text-brand-700 hover:bg-brand-50'
: weekend
? 'border-ink-100 bg-ink-50/60 text-ink-500 hover:border-brand-300'
: 'border-ink-200 text-ink-700 hover:border-brand-300 hover:bg-brand-50'
} ${inMonth ? '' : 'opacity-40'}`}
>
{fromIso(date).getDate()}
<span className={`tabular text-[9px] font-bold leading-none ${active ? 'text-white/80' : 'text-ink-400'}`}>
{minutes ? formatDuration(minutes) : date === entry.entry_date ? 'Vorlage' : ' '}
</span>
</button>
);
})}
</div>
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
<p className="flex items-center gap-1.5 text-[11px] text-ink-400">
<span className="h-2.5 w-2.5 rounded-sm border border-amber-200 bg-amber-50" /> Urlaub/Feiertag · Zahl = bereits gebucht
</p>
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={selectWorkdays} disabled={!project || busy}>
Werktage
</Button>
<Button variant="ghost" size="sm" onClick={clearMonth} disabled={busy}>
Leeren
</Button>
</div>
</div>
</div>
{dates.length > 0 && (
<p className="break-words text-xs text-ink-500">
<span className="font-bold text-ink-700">Gewählt: </span>
{dates.map((d) => formatDate(d)).join(', ')}
</p>
)}
</div>
</Modal>
);
};
+24 -5
View File
@@ -17,8 +17,10 @@ export const EntryList: React.FC<{
entries: TimeEntry[];
showUser?: boolean;
onEdit?: (entry: TimeEntry) => void;
/** Kopier-Knopf je Zeile: legt die Buchung als neue Buchung an. */
onCopy?: (entry: TimeEntry) => void;
emptyText?: string;
}> = ({ entries, showUser = false, onEdit, emptyText = 'Keine Buchungen im Zeitraum.' }) => {
}> = ({ entries, showUser = false, onEdit, onCopy, emptyText = 'Keine Buchungen im Zeitraum.' }) => {
const [sort, setSort] = useState<{ key: SortKey; dir: 1 | -1 }>({ key: 'entry_date', dir: 1 });
const sorted = useMemo(() => {
@@ -79,7 +81,8 @@ export const EntryList: React.FC<{
{head('project_display', 'Projekt', 'left', 'w-[24%]')}
{head('task_name', 'Task', 'left')}
{head('billable', 'Billable Zeit', 'right', 'w-[104px]')}
{head('non_billable', 'Non-billable Zeit', 'right', 'w-[124px] pr-4')}
{head('non_billable', 'Non-billable Zeit', 'right', `w-[124px] ${onCopy ? '' : 'pr-4'}`)}
{onCopy && <th className="w-[48px] pr-3" aria-label="Kopieren" />}
</tr>
</thead>
<tbody>
@@ -93,7 +96,7 @@ export const EntryList: React.FC<{
<tr
key={e.id}
onClick={() => onEdit?.(e)}
className={`animate-rise border-b border-ink-100 last:border-0 ${onEdit ? 'cursor-pointer hover:bg-brand-50/50' : 'hover:bg-ink-50'} ${
className={`animate-rise group border-b border-ink-100 last:border-0 ${onEdit ? 'cursor-pointer hover:bg-brand-50/50' : 'hover:bg-ink-50'} ${
first && i > 0 ? 'border-t border-t-ink-200' : ''
}`}
style={{ '--i': Math.min(i, 15) } as React.CSSProperties}
@@ -134,9 +137,24 @@ export const EntryList: React.FC<{
<td className={`tabular whitespace-nowrap px-3 py-2.5 text-right align-top font-extrabold ${bill ? 'text-ink-900' : 'text-ink-300'}`}>
{bill ? formatHours(bill) : '–'}
</td>
<td className={`tabular whitespace-nowrap py-2.5 pl-3 pr-4 text-right align-top font-extrabold ${non ? 'text-ink-700' : 'text-ink-300'}`}>
<td className={`tabular whitespace-nowrap py-2.5 pl-3 ${onCopy ? 'pr-3' : 'pr-4'} text-right align-top font-extrabold ${non ? 'text-ink-700' : 'text-ink-300'}`}>
{non ? formatHours(non) : '–'}
</td>
{onCopy && (
<td className="py-1.5 pr-3 text-right align-top">
<button
onClick={(event) => {
event.stopPropagation();
onCopy(e);
}}
title="Auf andere Tage kopieren"
aria-label="Buchung kopieren"
className="inline-flex h-7 w-7 items-center justify-center rounded-lg text-ink-400 transition hover:bg-brand-100 hover:text-brand-700 pointer-fine:opacity-0 pointer-fine:group-hover:opacity-100 pointer-fine:focus-visible:opacity-100"
>
<Icon name="copy" className="h-4 w-4" />
</button>
</td>
)}
</tr>
);
})}
@@ -148,7 +166,8 @@ export const EntryList: React.FC<{
</td>
<td className="tabular px-3 py-3 text-right">{formatHours(sum.billable + sum.non)} h gesamt</td>
<td className="tabular px-3 py-3 text-right">{formatHours(sum.billable)}</td>
<td className="tabular py-3 pl-3 pr-4 text-right">{formatHours(sum.non)}</td>
<td className={`tabular py-3 pl-3 ${onCopy ? 'pr-3' : 'pr-4'} text-right`}>{formatHours(sum.non)}</td>
{onCopy && <td />}
</tr>
</tfoot>
</table>
+12 -4
View File
@@ -15,6 +15,7 @@ interface Props {
entry?: TimeEntry | null;
onClose: () => void;
onSaved: () => void;
onDuplicate?: (entry: TimeEntry) => void;
}
/** Reihenfolge wie in der Auswahlliste: nach Kunde gruppiert, innerhalb in API-Reihenfolge. */
@@ -51,7 +52,7 @@ function firstTask(project: Project | undefined): number | '' {
* Buchungsdialog: oben Projekt, darunter Task, darunter Freitext mit der Zeit
* rechts daneben. Die Zeit versteht "1:23" und "1,5".
*/
export const EntryModal: React.FC<Props> = ({ projects, date: initialDate, entry, onClose, onSaved }) => {
export const EntryModal: React.FC<Props> = ({ projects, date: initialDate, entry, onClose, onSaved, onDuplicate }) => {
const { settings } = useAuth();
const { addToast } = useToast();
const bookable = useMemo(() => projects.filter((p) => p.is_active || p.id === entry?.project_id), [projects, entry]);
@@ -179,9 +180,16 @@ export const EntryModal: React.FC<Props> = ({ projects, date: initialDate, entry
footer={
<div className="flex items-center justify-between gap-3">
{entry ? (
<Button variant="ghost" icon="trash" className="text-red-600 hover:bg-red-50" onClick={() => setConfirmDelete(true)}>
Löschen
</Button>
<div className="flex gap-1">
<Button variant="ghost" icon="trash" className="text-red-600 hover:bg-red-50" onClick={() => setConfirmDelete(true)} aria-label="Löschen">
<span className="hidden sm:inline">Löschen</span>
</Button>
{onDuplicate && (
<Button variant="ghost" icon="copy" onClick={() => onDuplicate(entry)} title="Auf andere Tage kopieren" aria-label="Kopieren">
<span className="hidden sm:inline">Kopieren</span>
</Button>
)}
</div>
) : (
<span className="hidden text-[11px] text-ink-400 sm:block">Enter im Zeitfeld bucht · sonst Strg + Enter</span>
)}
+43 -22
View File
@@ -11,13 +11,14 @@ interface Props {
dailyTarget: number;
onAdd: (date: string) => void;
onEdit: (entry: TimeEntry) => void;
onCopy: (entry: TimeEntry) => void;
}
/**
* Wochenansicht wie ein moderner Stundenzettel: sieben Spalten, jede Buchung
* als Karte mit Kundenfarbe. Auf dem Handy ein Tagesstreifen mit Liste.
*/
export const WeekView: React.FC<Props> = ({ from, entries, readOnly, dailyTarget, onAdd, onEdit }) => {
export const WeekView: React.FC<Props> = ({ from, entries, readOnly, dailyTarget, onAdd, onEdit, onCopy }) => {
const isMobile = useIsMobile();
const today = todayIso();
const days = Array.from({ length: 7 }, (_, i) => addDays(from, i));
@@ -58,7 +59,7 @@ export const WeekView: React.FC<Props> = ({ from, entries, readOnly, dailyTarget
</div>
<div key={selected} className="space-y-2">
{list.map((entry, i) => (
<EntryCard key={entry.id} entry={entry} index={i} onClick={() => onEdit(entry)} readOnly={readOnly} large />
<EntryCard key={entry.id} entry={entry} index={i} onClick={() => onEdit(entry)} onCopy={() => onCopy(entry)} readOnly={readOnly} large />
))}
{list.length === 0 && (
<button
@@ -102,7 +103,7 @@ export const WeekView: React.FC<Props> = ({ from, entries, readOnly, dailyTarget
</div>
<div className="flex flex-1 flex-col gap-2 p-2">
{list.map((entry, j) => (
<EntryCard key={entry.id} entry={entry} index={j} onClick={() => onEdit(entry)} readOnly={readOnly} />
<EntryCard key={entry.id} entry={entry} index={j} onClick={() => onEdit(entry)} onCopy={() => onCopy(entry)} readOnly={readOnly} />
))}
{!readOnly && (
<button
@@ -131,32 +132,52 @@ const DayMeter: React.FC<{ minutes: number; target: number }> = ({ minutes, targ
);
};
export const EntryCard: React.FC<{ entry: TimeEntry; index: number; onClick: () => void; readOnly: boolean; large?: boolean }> = ({
export const EntryCard: React.FC<{ entry: TimeEntry; index: number; onClick: () => void; onCopy?: () => void; readOnly: boolean; large?: boolean }> = ({
entry,
index,
onClick,
onCopy,
readOnly,
large,
}) => {
const color = colorFor(entry.customer_id, entry.customer_color);
const canCopy = !readOnly && !!onCopy;
return (
<button
onClick={onClick}
disabled={readOnly && !large}
className={`animate-rise relative w-full overflow-hidden rounded-xl border border-ink-200 bg-surface text-left transition ${
readOnly ? 'cursor-default' : 'hover:-translate-y-0.5 hover:border-brand-300 hover:shadow-lift'
} ${large ? 'p-4 pl-5' : 'p-2.5 pl-3.5'}`}
style={{ '--i': index } as React.CSSProperties}
title={`${entry.project_display}${entry.task_name ? ` · ${entry.task_name}` : ''}${entry.note ? `\n${entry.note}` : ''}`}
>
<span aria-hidden className="absolute inset-y-0 left-0 w-1.5" style={{ background: color }} />
<div className="flex items-start justify-between gap-2">
<p className={`min-w-0 font-extrabold text-ink-900 ${large ? 'text-sm' : 'line-clamp-2 text-xs leading-snug'}`}>{entry.project_display}</p>
<span className={`tabular shrink-0 font-extrabold text-ink-800 ${large ? 'text-lg' : 'text-xs'}`}>{formatDuration(entry.minutes)}</span>
</div>
{entry.task_name && <p className={`truncate font-semibold text-ink-500 ${large ? 'text-xs' : 'text-[11px]'}`}>{entry.task_name}</p>}
{entry.note && <p className={`mt-0.5 text-ink-400 ${large ? 'text-xs' : 'line-clamp-2 text-[11px] leading-snug'}`}>{entry.note}</p>}
{!entry.billable && <span className="mt-1 inline-block rounded-full bg-ink-100 px-1.5 text-[9px] font-bold uppercase tracking-wide text-ink-500">non-billable</span>}
</button>
// Kopier-Knopf als Geschwister der Karte - ein Button im Button ist kein gueltiges HTML.
<div className="group/card relative">
<button
onClick={onClick}
disabled={readOnly && !large}
className={`animate-rise relative w-full overflow-hidden rounded-xl border border-ink-200 bg-surface text-left transition ${
readOnly ? 'cursor-default' : 'hover:-translate-y-0.5 hover:border-brand-300 hover:shadow-lift'
} ${large ? 'p-4 pl-5' : 'p-2.5 pl-3.5'}`}
style={{ '--i': index } as React.CSSProperties}
title={`${entry.project_display}${entry.task_name ? ` · ${entry.task_name}` : ''}${entry.note ? `
${entry.note}` : ''}`}
>
<span aria-hidden className="absolute inset-y-0 left-0 w-1.5" style={{ background: color }} />
<div className="flex items-start justify-between gap-2">
<p className={`min-w-0 font-extrabold text-ink-900 ${large ? 'text-sm' : 'line-clamp-2 text-xs leading-snug'}`}>{entry.project_display}</p>
<span className={`tabular shrink-0 font-extrabold text-ink-800 ${large ? 'text-lg' : 'text-xs'}`}>{formatDuration(entry.minutes)}</span>
</div>
<div className={canCopy && large ? 'pr-10' : ''}>
{entry.task_name && <p className={`truncate font-semibold text-ink-500 ${large ? 'text-xs' : 'text-[11px]'}`}>{entry.task_name}</p>}
{entry.note && <p className={`mt-0.5 text-ink-400 ${large ? 'text-xs' : 'line-clamp-2 text-[11px] leading-snug'}`}>{entry.note}</p>}
{!entry.billable && <span className="mt-1 inline-block rounded-full bg-ink-100 px-1.5 text-[9px] font-bold uppercase tracking-wide text-ink-500">non-billable</span>}
</div>
</button>
{canCopy && (
<button
onClick={onCopy}
title="Auf andere Tage kopieren"
aria-label="Buchung kopieren"
className={`absolute flex items-center justify-center rounded-lg border border-ink-200 bg-surface text-ink-500 shadow-card transition hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700 pointer-fine:opacity-0 pointer-fine:group-hover/card:opacity-100 pointer-fine:focus-visible:opacity-100 ${
large ? 'bottom-3 right-3 h-9 w-9' : 'bottom-1.5 right-1.5 h-6 w-6'
}`}
>
<Icon name="copy" className={large ? 'h-4 w-4' : 'h-3.5 w-3.5'} />
</button>
)}
</div>
);
};
+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 })}`),
};