From ef709d423cb053235671793768b5762477289d8e Mon Sep 17 00:00:00 2001 From: schakal00783 Date: Fri, 25 Sep 2026 13:51:18 +0200 Subject: [PATCH] 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 --- src/components/booking/Booking.tsx | 19 +- src/components/booking/CopyDialog.tsx | 279 ++++++++++++++++++++++++++ src/components/booking/EntryList.tsx | 2 +- src/components/booking/EntryModal.tsx | 27 +-- src/components/booking/WeekView.tsx | 2 +- 5 files changed, 304 insertions(+), 25 deletions(-) create mode 100644 src/components/booking/CopyDialog.tsx diff --git a/src/components/booking/Booking.tsx b/src/components/booking/Booking.tsx index f06c985..6c2604a 100644 --- a/src/components/booking/Booking.tsx +++ b/src/components/booking/Booking.tsx @@ -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('pulse_booking_mode', 'week'); const [layout, setLayout] = usePersistentState('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(null); const [exportOpen, setExportOpen] = useState(false); const [viewUserId, setViewUserId] = useState(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 && ( setModal(null)} onSaved={() => { entries.reload(); projects.reload(); }} onDuplicate={copy} /> )} + {copyEntry && projects.data && ( + setCopyEntry(null)} + onSaved={() => { entries.reload(); projects.reload(); }} + /> + )} {exportOpen && ( 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 = ({ entry, projects, onClose, onSaved }) => { + const { user, settings } = useAuth(); + const { addToast } = useToast(); + const [month, setMonth] = useState(() => monthStart(todayIso())); + const [selected, setSelected] = useState>(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(); + 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(); + 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(); + // 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 ( + +

+ {dates.length ? `${dates.length} ${dates.length === 1 ? 'Tag' : 'Tage'} · ${formatDuration(totalMinutes)} h` : 'Keine Tage gewählt'} +

+
+ + +
+ + } + > +
+ {/* Vorlage */} +
+ +
+
+

{entry.project_display}

+ {entry.task_name &&

{entry.task_name}

} + {entry.note &&

{entry.note}

} +
+ {formatDuration(entry.minutes)} +
+
+ + {!project && ( +

+ Das Projekt ist nicht mehr aktiv oder dir nicht mehr zugewiesen – Kopieren ist nicht möglich. +

+ )} + {project && taskGone && ( +

+ Der Task „{entry.task_name}“ ist nicht mehr aktiv – die Kopien werden ohne Task gebucht. +

+ )} + + {/* Monatskalender */} +
+
+ +

+ {MONTHS[Number(month.slice(5, 7)) - 1]} {month.slice(0, 4)} +

+ +
+
+ {WEEKDAYS_SHORT.map((d) => ( +

+ {d} +

+ ))} +
+
+ {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 ( + + ); + })} +
+
+

+ Urlaub/Feiertag · Zahl = bereits gebucht +

+
+ + +
+
+
+ + {dates.length > 0 && ( +

+ Gewählt: + {dates.map((d) => formatDate(d)).join(', ')} +

+ )} +
+
+ ); +}; diff --git a/src/components/booking/EntryList.tsx b/src/components/booking/EntryList.tsx index 37382b3..e48a303 100644 --- a/src/components/booking/EntryList.tsx +++ b/src/components/booking/EntryList.tsx @@ -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" > diff --git a/src/components/booking/EntryModal.tsx b/src/components/booking/EntryModal.tsx index 52fbcc2..93c3ace 100644 --- a/src/components/booking/EntryModal.tsx +++ b/src/components/booking/EntryModal.tsx @@ -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 = ({ projects, date: initialDate, entry, template, onClose, onSaved, onDuplicate }) => { +export const EntryModal: React.FC = ({ 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(() => source?.project_id ?? defaultProject(bookable, initialDate, settings)); + const [projectId, setProjectId] = useState(() => entry?.project_id ?? defaultProject(bookable, initialDate, settings)); // Neue Buchung: erster aktiver Task des vorausgewaehlten Projekts. const [taskId, setTaskId] = useState(() => - 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(null); @@ -105,7 +96,7 @@ export const EntryModal: React.FC = ({ 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 = ({ projects, date: initialDate, entry return ( <> = ({ projects, date: initialDate, entry Löschen {onDuplicate && ( - )} diff --git a/src/components/booking/WeekView.tsx b/src/components/booking/WeekView.tsx index 4dd5f90..313d7ed 100644 --- a/src/components/booking/WeekView.tsx +++ b/src/components/booking/WeekView.tsx @@ -169,7 +169,7 @@ ${entry.note}` : ''}`} {canCopy && (