Compare commits
4
Commits
b6ba7860bf
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
876ef24c75 | ||
|
|
5275f7c03d | ||
|
|
ef709d423c | ||
|
|
2b9b7a6ae3 |
@@ -88,6 +88,25 @@ Ein Klick auf eine Zeile öffnet dieselbe Detailansicht wie eine Kachel, hier un
|
|||||||
mindestens Vorgesetzter ist.
|
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
|
## Auslastung
|
||||||
|
|
||||||
Die Auslastung vergleicht **gebuchte Stunden bis heute** mit der **Kapazität bis heute**
|
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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Einstellungen (nur Admin): SSO-Credentials, Userverwaltung, Kopf/Fuß der Exporte | ✅ inkl. PDF-/Excel-Vorschau | Einstellungen |
|
||||||
| Arbeitsstunden pro Woche (40) | ✅ | Einstellungen → Allgemein |
|
| Arbeitsstunden pro Woche (40) | ✅ | Einstellungen → Allgemein |
|
||||||
| Projektbezeichnungen nach Muster | ✅ | 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 { getSettings } from '../lib/settings.js';
|
||||||
import { loadEntries } from '../lib/entries.js';
|
import { loadEntries } from '../lib/entries.js';
|
||||||
import { loadProjects } from '../lib/projects.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';
|
import { capacityMinutes } from './dashboard.js';
|
||||||
|
|
||||||
const router = express.Router();
|
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. */
|
/** Kundenreport: alle Projekte eines Kunden mit Budget, Verlauf und Team. */
|
||||||
router.get('/customer/:id', async (req, res, next) => {
|
router.get('/customer/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
@@ -204,31 +251,20 @@ router.get('/customer/:id', async (req, res, next) => {
|
|||||||
if (!customer) return res.status(404).json({ error: 'Kunde nicht gefunden.' });
|
if (!customer) return res.status(404).json({ error: 'Kunde nicht gefunden.' });
|
||||||
const projects = await loadProjects(settings, { where: 'p.customer_id = ?', params: [customer.id] });
|
const projects = await loadProjects(settings, { where: 'p.customer_id = ?', params: [customer.id] });
|
||||||
const userIds = await visibleUserIds(req.user);
|
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 minutes = entries.reduce((s, e) => s + e.minutes, 0);
|
||||||
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 billable = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0);
|
const billable = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0);
|
||||||
res.json({
|
res.json({
|
||||||
customer,
|
customer,
|
||||||
|
range: { from, to },
|
||||||
|
series_unit: series.unit,
|
||||||
totals: {
|
totals: {
|
||||||
minutes,
|
minutes,
|
||||||
billable_minutes: billable,
|
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)),
|
open: r2(projects.reduce((s, p) => s + p.open, 0)),
|
||||||
budget_value: r2(projects.reduce((s, p) => s + p.budget_value, 0)),
|
budget_value: r2(projects.reduce((s, p) => s + p.budget_value, 0)),
|
||||||
},
|
},
|
||||||
series: series.slice(-24),
|
series: series.points,
|
||||||
projects: projects.map((p) => {
|
projects: projects.map((p) => {
|
||||||
const own = entries.filter((e) => e.project_id === p.id);
|
const own = entries.filter((e) => e.project_id === p.id);
|
||||||
return { ...buildProjectDetail(p, own), weekly: undefined };
|
return { ...buildProjectDetail(p, own), weekly: undefined };
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Avatar, Button, Card, ErrorBox, Segmented, Spinner, inputClass } from '
|
|||||||
import { Icon } from '../ui/Icon';
|
import { Icon } from '../ui/Icon';
|
||||||
import { Donut } from '../ui/charts';
|
import { Donut } from '../ui/charts';
|
||||||
import { EntryModal } from './EntryModal';
|
import { EntryModal } from './EntryModal';
|
||||||
|
import { CopyDialog } from './CopyDialog';
|
||||||
import { ExportDialog } from './ExportDialog';
|
import { ExportDialog } from './ExportDialog';
|
||||||
import { WeekView } from './WeekView';
|
import { WeekView } from './WeekView';
|
||||||
import { MonthView } from './MonthView';
|
import { MonthView } from './MonthView';
|
||||||
@@ -54,6 +55,7 @@ export const Booking: React.FC = () => {
|
|||||||
const [layout, setLayout] = usePersistentState<BookingLayout>('pulse_booking_layout', 'calendar');
|
const [layout, setLayout] = usePersistentState<BookingLayout>('pulse_booking_layout', 'calendar');
|
||||||
const [anchor, setAnchor] = useState(todayIso());
|
const [anchor, setAnchor] = useState(todayIso());
|
||||||
const [modal, setModal] = useState<{ date: string; entry?: TimeEntry | null } | null>(null);
|
const [modal, setModal] = useState<{ date: string; entry?: TimeEntry | null } | null>(null);
|
||||||
|
const [copyEntry, setCopyEntry] = useState<TimeEntry | null>(null);
|
||||||
const [exportOpen, setExportOpen] = useState(false);
|
const [exportOpen, setExportOpen] = useState(false);
|
||||||
const [viewUserId, setViewUserId] = useState<number>(user!.id);
|
const [viewUserId, setViewUserId] = useState<number>(user!.id);
|
||||||
const isLead = atLeast(user, 'lead');
|
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) });
|
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
|
* Im Export stehen nur die eigenen Projekte zur Wahl - auch fuer
|
||||||
* Vorgesetzte. Wer einen Nachweis fuer einen Kunden erstellt, filtert nach
|
* Vorgesetzte. Wer einen Nachweis fuer einen Kunden erstellt, filtert nach
|
||||||
@@ -131,7 +140,7 @@ export const Booking: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[1500px]">
|
<div className={`mx-auto max-w-[1500px] ${isMobile && !readOnly ? 'pb-16' : ''}`}>
|
||||||
{/* Werkzeugleiste */}
|
{/* Werkzeugleiste */}
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-2 sm:gap-3">
|
<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">
|
<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
|
<EntryList
|
||||||
entries={list}
|
entries={list}
|
||||||
onEdit={readOnly ? undefined : (entry) => setModal({ date: entry.entry_date, entry })}
|
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'}.`}
|
emptyText={`Keine Buchungen ${mode === 'week' ? 'in dieser Woche' : mode === 'month' ? 'in diesem Monat' : 'in diesem Jahr'}.`}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
{layout === 'calendar' && mode === 'week' && (
|
{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' && (
|
{layout === 'calendar' && mode === 'month' && (
|
||||||
<MonthView
|
<MonthView
|
||||||
@@ -266,14 +276,29 @@ export const Booking: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => openNew()}
|
onClick={() => openNew()}
|
||||||
aria-label="Zeit buchen"
|
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} />
|
<Icon name="plus" className="h-8 w-8" strokeWidth={2.6} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{modal && projects.data && (
|
{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 && (
|
{exportOpen && (
|
||||||
<ExportDialog
|
<ExportDialog
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -17,8 +17,10 @@ export const EntryList: React.FC<{
|
|||||||
entries: TimeEntry[];
|
entries: TimeEntry[];
|
||||||
showUser?: boolean;
|
showUser?: boolean;
|
||||||
onEdit?: (entry: TimeEntry) => void;
|
onEdit?: (entry: TimeEntry) => void;
|
||||||
|
/** Kopier-Knopf je Zeile: legt die Buchung als neue Buchung an. */
|
||||||
|
onCopy?: (entry: TimeEntry) => void;
|
||||||
emptyText?: string;
|
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 [sort, setSort] = useState<{ key: SortKey; dir: 1 | -1 }>({ key: 'entry_date', dir: 1 });
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
@@ -79,7 +81,8 @@ export const EntryList: React.FC<{
|
|||||||
{head('project_display', 'Projekt', 'left', 'w-[24%]')}
|
{head('project_display', 'Projekt', 'left', 'w-[24%]')}
|
||||||
{head('task_name', 'Task', 'left')}
|
{head('task_name', 'Task', 'left')}
|
||||||
{head('billable', 'Billable Zeit', 'right', 'w-[104px]')}
|
{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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -93,7 +96,7 @@ export const EntryList: React.FC<{
|
|||||||
<tr
|
<tr
|
||||||
key={e.id}
|
key={e.id}
|
||||||
onClick={() => onEdit?.(e)}
|
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' : ''
|
first && i > 0 ? 'border-t border-t-ink-200' : ''
|
||||||
}`}
|
}`}
|
||||||
style={{ '--i': Math.min(i, 15) } as React.CSSProperties}
|
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'}`}>
|
<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) : '–'}
|
{bill ? formatHours(bill) : '–'}
|
||||||
</td>
|
</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) : '–'}
|
{non ? formatHours(non) : '–'}
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -148,7 +166,8 @@ export const EntryList: React.FC<{
|
|||||||
</td>
|
</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 + sum.non)} h gesamt</td>
|
||||||
<td className="tabular px-3 py-3 text-right">{formatHours(sum.billable)}</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>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface Props {
|
|||||||
entry?: TimeEntry | null;
|
entry?: TimeEntry | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
|
onDuplicate?: (entry: TimeEntry) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reihenfolge wie in der Auswahlliste: nach Kunde gruppiert, innerhalb in API-Reihenfolge. */
|
/** 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
|
* Buchungsdialog: oben Projekt, darunter Task, darunter Freitext mit der Zeit
|
||||||
* rechts daneben. Die Zeit versteht "1:23" und "1,5".
|
* 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 { settings } = useAuth();
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
const bookable = useMemo(() => projects.filter((p) => p.is_active || p.id === entry?.project_id), [projects, entry]);
|
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={
|
footer={
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
{entry ? (
|
{entry ? (
|
||||||
<Button variant="ghost" icon="trash" className="text-red-600 hover:bg-red-50" onClick={() => setConfirmDelete(true)}>
|
<div className="flex gap-1">
|
||||||
Löschen
|
<Button variant="ghost" icon="trash" className="text-red-600 hover:bg-red-50" onClick={() => setConfirmDelete(true)} aria-label="Löschen">
|
||||||
</Button>
|
<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>
|
<span className="hidden text-[11px] text-ink-400 sm:block">Enter im Zeitfeld bucht · sonst Strg + Enter</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,13 +11,14 @@ interface Props {
|
|||||||
dailyTarget: number;
|
dailyTarget: number;
|
||||||
onAdd: (date: string) => void;
|
onAdd: (date: string) => void;
|
||||||
onEdit: (entry: TimeEntry) => void;
|
onEdit: (entry: TimeEntry) => void;
|
||||||
|
onCopy: (entry: TimeEntry) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wochenansicht wie ein moderner Stundenzettel: sieben Spalten, jede Buchung
|
* Wochenansicht wie ein moderner Stundenzettel: sieben Spalten, jede Buchung
|
||||||
* als Karte mit Kundenfarbe. Auf dem Handy ein Tagesstreifen mit Liste.
|
* 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 isMobile = useIsMobile();
|
||||||
const today = todayIso();
|
const today = todayIso();
|
||||||
const days = Array.from({ length: 7 }, (_, i) => addDays(from, i));
|
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>
|
||||||
<div key={selected} className="space-y-2">
|
<div key={selected} className="space-y-2">
|
||||||
{list.map((entry, i) => (
|
{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 && (
|
{list.length === 0 && (
|
||||||
<button
|
<button
|
||||||
@@ -102,7 +103,7 @@ export const WeekView: React.FC<Props> = ({ from, entries, readOnly, dailyTarget
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 flex-col gap-2 p-2">
|
<div className="flex flex-1 flex-col gap-2 p-2">
|
||||||
{list.map((entry, j) => (
|
{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 && (
|
{!readOnly && (
|
||||||
<button
|
<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,
|
entry,
|
||||||
index,
|
index,
|
||||||
onClick,
|
onClick,
|
||||||
|
onCopy,
|
||||||
readOnly,
|
readOnly,
|
||||||
large,
|
large,
|
||||||
}) => {
|
}) => {
|
||||||
const color = colorFor(entry.customer_id, entry.customer_color);
|
const color = colorFor(entry.customer_id, entry.customer_color);
|
||||||
|
const canCopy = !readOnly && !!onCopy;
|
||||||
return (
|
return (
|
||||||
<button
|
// Kopier-Knopf als Geschwister der Karte - ein Button im Button ist kein gueltiges HTML.
|
||||||
onClick={onClick}
|
<div className="group/card relative">
|
||||||
disabled={readOnly && !large}
|
<button
|
||||||
className={`animate-rise relative w-full overflow-hidden rounded-xl border border-ink-200 bg-surface text-left transition ${
|
onClick={onClick}
|
||||||
readOnly ? 'cursor-default' : 'hover:-translate-y-0.5 hover:border-brand-300 hover:shadow-lift'
|
disabled={readOnly && !large}
|
||||||
} ${large ? 'p-4 pl-5' : 'p-2.5 pl-3.5'}`}
|
className={`animate-rise relative w-full overflow-hidden rounded-xl border border-ink-200 bg-surface text-left transition ${
|
||||||
style={{ '--i': index } as React.CSSProperties}
|
readOnly ? 'cursor-default' : 'hover:-translate-y-0.5 hover:border-brand-300 hover:shadow-lift'
|
||||||
title={`${entry.project_display}${entry.task_name ? ` · ${entry.task_name}` : ''}${entry.note ? `\n${entry.note}` : ''}`}
|
} ${large ? 'p-4 pl-5' : 'p-2.5 pl-3.5'}`}
|
||||||
>
|
style={{ '--i': index } as React.CSSProperties}
|
||||||
<span aria-hidden className="absolute inset-y-0 left-0 w-1.5" style={{ background: color }} />
|
title={`${entry.project_display}${entry.task_name ? ` · ${entry.task_name}` : ''}${entry.note ? `
|
||||||
<div className="flex items-start justify-between gap-2">
|
${entry.note}` : ''}`}
|
||||||
<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>
|
<span aria-hidden className="absolute inset-y-0 left-0 w-1.5" style={{ background: color }} />
|
||||||
</div>
|
<div className="flex items-start justify-between gap-2">
|
||||||
{entry.task_name && <p className={`truncate font-semibold text-ink-500 ${large ? 'text-xs' : 'text-[11px]'}`}>{entry.task_name}</p>}
|
<p className={`min-w-0 font-extrabold text-ink-900 ${large ? 'text-sm' : 'line-clamp-2 text-xs leading-snug'}`}>{entry.project_display}</p>
|
||||||
{entry.note && <p className={`mt-0.5 text-ink-400 ${large ? 'text-xs' : 'line-clamp-2 text-[11px] leading-snug'}`}>{entry.note}</p>}
|
<span className={`tabular shrink-0 font-extrabold text-ink-800 ${large ? 'text-lg' : 'text-xs'}`}>{formatDuration(entry.minutes)}</span>
|
||||||
{!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>
|
<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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import { ExportMenu } from './ExportMenu';
|
|||||||
import { ReportKpi, ReportRow } from './ProjectReport';
|
import { ReportKpi, ReportRow } from './ProjectReport';
|
||||||
import { useAuth } from '../../lib/auth';
|
import { useAuth } from '../../lib/auth';
|
||||||
import { customersApi, qs, reportsApi, type CustomerReport as CustomerReportData } from '../../lib/api';
|
import { customersApi, qs, reportsApi, type CustomerReport as CustomerReportData } from '../../lib/api';
|
||||||
import { useLoader } from '../../lib/hooks';
|
import { useLoader, usePersistentState } from '../../lib/hooks';
|
||||||
import { MONTHS, colorFor, formatEuro, formatHours, formatNumber, todayIso } from '../../lib/format';
|
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 { useRouter } from '../../lib/router';
|
||||||
import { TYPE_TONE } from '../projects/Projects';
|
import { TYPE_TONE } from '../projects/Projects';
|
||||||
import { PROJECT_TYPE_LABELS } from '../../types';
|
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 }) => {
|
export const CustomerReport: React.FC<{ id: number | null }> = ({ id }) => {
|
||||||
const { navigate } = useRouter();
|
const { navigate } = useRouter();
|
||||||
const customers = useLoader(() => customersApi.list(), []);
|
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');
|
const [status, setStatus] = useState<'active' | 'inactive' | 'all'>('active');
|
||||||
|
|
||||||
// Der gewaehlte Kunde bleibt in der Liste, auch wenn der Filter ihn sonst
|
// 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' },
|
{ value: 'all', label: 'Alle' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
<PeriodPicker period={period} onPeriod={setPeriod} anchor={anchor} onAnchor={setAnchor} custom={custom} onCustom={setCustom} allLabel="Gesamt" />
|
||||||
</div>
|
</div>
|
||||||
{!id ? (
|
{!id ? (
|
||||||
<EmptyState icon="building" title="Kunde auswählen" text="Alle Projekte eines Kunden mit Budget, Umsatz, Verlauf und Team." />
|
<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 ? (
|
) : !report.data ? (
|
||||||
<Spinner label="Lade Kundenreport…" />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Body: React.FC<{ data: CustomerReportData }> = ({ data }) => {
|
const Body: React.FC<{ data: CustomerReportData; rangeLabel: string | null }> = ({ data, rangeLabel }) => {
|
||||||
const { settings } = useAuth();
|
const { settings } = useAuth();
|
||||||
const { navigate } = useRouter();
|
const { navigate } = useRouter();
|
||||||
const [tab, setTab] = useState<'projects' | 'team' | 'tasks'>('projects');
|
const [tab, setTab] = useState<'projects' | 'team' | 'tasks'>('projects');
|
||||||
@@ -69,14 +82,26 @@ const Body: React.FC<{ data: CustomerReportData }> = ({ data }) => {
|
|||||||
const c = data.customer;
|
const c = data.customer;
|
||||||
const t = data.totals;
|
const t = data.totals;
|
||||||
const color = colorFor(c.id, c.color);
|
const color = colorFor(c.id, c.color);
|
||||||
const month = todayIso().slice(0, 7);
|
const ranged = Boolean(data.range.from && data.range.to);
|
||||||
const series = useMemo(() => data.series.slice(-months), [data.series, months]);
|
// 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 = [
|
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: '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: '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: `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 (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: `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 (
|
return (
|
||||||
@@ -88,6 +113,7 @@ const Body: React.FC<{ data: CustomerReportData }> = ({ data }) => {
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h2 className="truncate text-2xl text-ink-900 sm:text-3xl">{c.name}</h2>
|
<h2 className="truncate text-2xl text-ink-900 sm:text-3xl">{c.name}</h2>
|
||||||
<p className="text-sm text-ink-500">
|
<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
|
{data.projects.length} Projekte · {data.team.length} Mitarbeiter
|
||||||
{c.contact_person && ` · Ansprechpartner: ${c.contact_person}`}
|
{c.contact_person && ` · Ansprechpartner: ${c.contact_person}`}
|
||||||
</p>
|
</p>
|
||||||
@@ -101,10 +127,17 @@ const Body: React.FC<{ data: CustomerReportData }> = ({ data }) => {
|
|||||||
<ReportRow label="Billable" value={formatHours(t.billable_minutes)} />
|
<ReportRow label="Billable" value={formatHours(t.billable_minutes)} />
|
||||||
<ReportRow label="Non-billable" value={formatHours(t.non_billable_minutes)} />
|
<ReportRow label="Non-billable" value={formatHours(t.non_billable_minutes)} />
|
||||||
</ReportKpi>
|
</ReportKpi>
|
||||||
<ReportKpi index={2} title="Umsatz erbracht">
|
{ranged ? (
|
||||||
<CountUp value={t.earned} format={(v) => formatEuro(v, 2)} className="text-3xl font-extrabold text-ink-900" />
|
<ReportKpi index={2} title="Umsatz im Zeitraum">
|
||||||
<ReportRow label="davon aus Buchungen" value={formatEuro(t.amount, 2)} />
|
<CountUp value={t.amount} format={(v) => formatEuro(v, 2)} className="text-3xl font-extrabold text-ink-900" />
|
||||||
</ReportKpi>
|
<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">
|
<ReportKpi index={3} title="Umsatz offen">
|
||||||
<CountUp value={t.open} format={(v) => formatEuro(v, 2)} className="text-3xl font-extrabold text-ink-900" />
|
<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)}`} />
|
<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}>
|
<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 className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||||
<div>
|
<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' }]} />
|
<Legend className="mt-1" items={[{ color: BILLABLE, label: 'Billable' }, { color: NON_BILLABLE, label: 'Non-billable' }]} />
|
||||||
</div>
|
</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))}>
|
{!ranged && (
|
||||||
<option value={6}>6 Monate</option>
|
<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={12}>12 Monate</option>
|
<option value={6}>6 Monate</option>
|
||||||
<option value={24}>24 Monate</option>
|
<option value={12}>12 Monate</option>
|
||||||
</select>
|
<option value={24}>24 Monate</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{series.length ? (
|
{series.some((m) => m.minutes > 0) ? (
|
||||||
<StackedBars
|
<StackedBars
|
||||||
scale={60}
|
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`}
|
format={(v) => `${formatHours(v, 1)} h`}
|
||||||
axisFormat={(v) => formatNumber(v / 60, 0)}
|
axisFormat={(v) => formatNumber(v / 60, 0)}
|
||||||
height={220}
|
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>
|
</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 React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { Avatar, Card, ErrorBox, IconButton, Spinner, inputClass } from '../ui/controls';
|
import { Avatar, Card, ErrorBox, IconButton, Spinner, inputClass } from '../ui/controls';
|
||||||
import { Modal } from '../ui/Modal';
|
import { Modal } from '../ui/Modal';
|
||||||
import { Icon } from '../ui/Icon';
|
|
||||||
import { CountUp, Donut, BILLABLE, NON_BILLABLE } from '../ui/charts';
|
import { CountUp, Donut, BILLABLE, NON_BILLABLE } from '../ui/charts';
|
||||||
import { ReportTable } from './ReportTable';
|
import { ReportTable } from './ReportTable';
|
||||||
import { EntryList } from '../booking/EntryList';
|
import { EntryList } from '../booking/EntryList';
|
||||||
import { ExportMenu } from './ExportMenu';
|
import { ExportMenu } from './ExportMenu';
|
||||||
import { CustomerReport } from './CustomerReport';
|
import { CustomerReport } from './CustomerReport';
|
||||||
import { ProjectReport } from './ProjectReport';
|
import { ProjectReport } from './ProjectReport';
|
||||||
|
import { PeriodPicker, periodRange, type Period } from './PeriodPicker';
|
||||||
import { useAuth } from '../../lib/auth';
|
import { useAuth } from '../../lib/auth';
|
||||||
import { customersApi, entriesApi, qs, reportsApi, usersApi, type GroupRow } from '../../lib/api';
|
import { customersApi, entriesApi, qs, reportsApi, usersApi, type GroupRow } from '../../lib/api';
|
||||||
import { useLoader, usePersistentState } from '../../lib/hooks';
|
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 { useRouter } from '../../lib/router';
|
||||||
import { atLeast, type Customer, type DirectoryUser } from '../../types';
|
import { atLeast, type Customer, type DirectoryUser } from '../../types';
|
||||||
|
|
||||||
type Period = 'week' | 'month' | 'quarter' | 'year' | 'custom';
|
|
||||||
type Tab = 'clients' | 'projects' | 'tasks' | 'users';
|
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 = () => {
|
export const Reports: React.FC = () => {
|
||||||
const { route, navigate } = useRouter();
|
const { route, navigate } = useRouter();
|
||||||
const [kind, id] = route.params;
|
const [kind, id] = route.params;
|
||||||
@@ -105,13 +89,6 @@ const TimeReport: React.FC = () => {
|
|||||||
[range.from, range.to, customerId, team, includeFixed, hideNonBillable]
|
[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 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 groupName = { clients: 'Kunden', projects: 'Projekte', tasks: 'Tasks', users: 'Mitarbeiter' }[tab];
|
||||||
const exportOptions = useMemo(
|
const exportOptions = useMemo(
|
||||||
@@ -165,33 +142,7 @@ const TimeReport: React.FC = () => {
|
|||||||
</optgroup>
|
</optgroup>
|
||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
<select className={`${inputClass} !w-auto`} value={period} onChange={(e) => setPeriod(e.target.value as Period)}>
|
<PeriodPicker period={period} onPeriod={setPeriod} anchor={anchor} onAnchor={setAnchor} custom={custom} onCustom={setCustom} />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{report.error && <ErrorBox message={report.error} onRetry={report.reload} />}
|
{report.error && <ErrorBox message={report.error} onRetry={report.reload} />}
|
||||||
|
|||||||
+4
-2
@@ -489,6 +489,8 @@ export interface ProjectDetail {
|
|||||||
|
|
||||||
export interface CustomerReport {
|
export interface CustomerReport {
|
||||||
customer: Customer;
|
customer: Customer;
|
||||||
|
range: { from: string | null; to: string | null };
|
||||||
|
series_unit: 'day' | 'week' | 'month';
|
||||||
totals: {
|
totals: {
|
||||||
minutes: number;
|
minutes: number;
|
||||||
billable_minutes: number;
|
billable_minutes: number;
|
||||||
@@ -498,7 +500,7 @@ export interface CustomerReport {
|
|||||||
open: number;
|
open: number;
|
||||||
budget_value: 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'>>;
|
projects: Array<Omit<ProjectDetail, 'weekly'>>;
|
||||||
team: GroupRow[];
|
team: GroupRow[];
|
||||||
tasks: GroupRow[];
|
tasks: GroupRow[];
|
||||||
@@ -514,5 +516,5 @@ export const reportsApi = {
|
|||||||
})}`
|
})}`
|
||||||
),
|
),
|
||||||
project: (id: number) => get<ProjectDetail>(`/reports/project/${id}`),
|
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