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>
This commit is contained in:
schakal00783
2026-09-25 13:51:18 +02:00
co-authored by Claude Opus 5.5
parent 2b9b7a6ae3
commit ef709d423c
5 changed files with 304 additions and 25 deletions
+14 -5
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';
@@ -53,7 +54,8 @@ export const Booking: React.FC = () => {
const [mode, setMode] = usePersistentState<BookingMode>('pulse_booking_mode', 'week');
const [layout, setLayout] = usePersistentState<BookingLayout>('pulse_booking_layout', 'calendar');
const [anchor, setAnchor] = useState(todayIso());
const [modal, setModal] = useState<{ date: string; entry?: TimeEntry | null; template?: 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 [viewUserId, setViewUserId] = useState<number>(user!.id);
const isLead = atLeast(user, 'lead');
@@ -100,10 +102,11 @@ export const Booking: React.FC = () => {
setModal({ date: date ?? (today >= range.from && today <= range.to ? today : range.from) });
};
/** Kopie einer Buchung als neue Buchung fuer heute; Datum ist im Dialog aenderbar. */
/** Kopieren: Tage im Monatskalender waehlen, auf die die Buchung kopiert wird. */
const copy = (entry: TimeEntry) => {
if (readOnly) return;
setModal({ date: today, template: entry });
setModal(null);
setCopyEntry(entry);
};
/*
@@ -281,16 +284,22 @@ export const Booking: React.FC = () => {
{modal && projects.data && (
<EntryModal
key={modal.entry?.id ?? `new-${modal.template?.id ?? ''}`}
projects={projects.data}
date={modal.date}
entry={modal.entry}
template={modal.template}
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
month={anchor.slice(0, 7)}
+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>
);
};
+1 -1
View File
@@ -147,7 +147,7 @@ export const EntryList: React.FC<{
event.stopPropagation();
onCopy(e);
}}
title="Als neue Buchung kopieren"
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"
>
+9 -18
View File
@@ -13,8 +13,6 @@ interface Props {
projects: Project[];
date: string;
entry?: TimeEntry | null;
/** Vorlage fuer eine neue Buchung (Kopie): Projekt, Task, Notiz und Zeit werden uebernommen. */
template?: TimeEntry | null;
onClose: () => void;
onSaved: () => void;
onDuplicate?: (entry: TimeEntry) => void;
@@ -54,26 +52,19 @@ 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, template, onClose, onSaved, onDuplicate }) => {
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]);
/*
* Kopie: nur uebernehmen, solange das Projekt noch buchbar ist. Ein
* inaktiver Task faellt ueber den Projekt-Effekt unten auf den ersten
* aktiven Task zurueck.
*/
const copyFrom = !entry && template && bookable.some((p) => p.id === template.project_id) ? template : null;
const source = entry ?? copyFrom;
const [projectId, setProjectId] = useState<number | ''>(() => source?.project_id ?? defaultProject(bookable, initialDate, settings));
const [projectId, setProjectId] = useState<number | ''>(() => entry?.project_id ?? defaultProject(bookable, initialDate, settings));
// Neue Buchung: erster aktiver Task des vorausgewaehlten Projekts.
const [taskId, setTaskId] = useState<number | ''>(() =>
source ? source.task_id ?? '' : firstTask(bookable.find((p) => p.id === projectId))
entry ? entry.task_id ?? '' : firstTask(bookable.find((p) => p.id === projectId))
);
const [note, setNote] = useState(source?.note ?? '');
const [note, setNote] = useState(entry?.note ?? '');
const [date, setDate] = useState(entry?.entry_date ?? initialDate);
const [timeText, setTimeText] = useState(source ? formatDuration(source.minutes) : '0:00');
const [timeText, setTimeText] = useState(entry ? formatDuration(entry.minutes) : '0:00');
const [busy, setBusy] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const timeRef = useRef<HTMLInputElement>(null);
@@ -105,7 +96,7 @@ export const EntryModal: React.FC<Props> = ({ projects, date: initialDate, entry
// Projekt gewechselt: passt der Task nicht mehr, den ersten Task des neuen Projekts nehmen.
useEffect(() => {
if (taskId && tasks.some((t) => t.id === taskId)) return;
if (!taskId && source && source.project_id === projectId) return; // bewusst ohne Task gebucht
if (!taskId && entry && entry.project_id === projectId) return; // bewusst ohne Task gebucht
setTaskId(firstTask(project ?? undefined));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId]);
@@ -182,7 +173,7 @@ export const EntryModal: React.FC<Props> = ({ projects, date: initialDate, entry
return (
<>
<Modal
title={entry ? 'Buchung bearbeiten' : copyFrom ? 'Buchung kopieren' : 'Zeit buchen'}
title={entry ? 'Buchung bearbeiten' : 'Zeit buchen'}
subtitle={formatDate(date, 'long')}
onClose={onClose}
size="md"
@@ -194,8 +185,8 @@ export const EntryModal: React.FC<Props> = ({ projects, date: initialDate, entry
<span className="hidden sm:inline">Löschen</span>
</Button>
{onDuplicate && (
<Button variant="ghost" icon="copy" onClick={() => onDuplicate(entry)} title="Als neue Buchung kopieren" aria-label="Duplizieren">
<span className="hidden sm:inline">Duplizieren</span>
<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>
+1 -1
View File
@@ -169,7 +169,7 @@ ${entry.note}` : ''}`}
{canCopy && (
<button
onClick={onCopy}
title="Als neue Buchung kopieren"
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'