From 2b9b7a6ae3772c23e5e3754cf7186474adad2957 Mon Sep 17 00:00:00 2001 From: schakal00783 Date: Fri, 25 Sep 2026 13:45:44 +0200 Subject: [PATCH] feat(buchung): Buchungen kopieren Duplizieren-Button im Bearbeiten-Dialog und Kopier-Icon je Buchung in Liste und Wochenansicht. Die Kopie oeffnet den Dialog als neue Buchung fuer heute mit Projekt, Task, Notiz und Zeit der Vorlage. Inaktive Projekte oder Tasks fallen auf die Standardauswahl zurueck. Co-Authored-By: Claude Opus 5.5 --- src/components/booking/Booking.tsx | 22 +++++++-- src/components/booking/EntryList.tsx | 29 +++++++++--- src/components/booking/EntryModal.tsx | 37 ++++++++++----- src/components/booking/WeekView.tsx | 65 ++++++++++++++++++--------- 4 files changed, 113 insertions(+), 40 deletions(-) diff --git a/src/components/booking/Booking.tsx b/src/components/booking/Booking.tsx index a58955f..f06c985 100644 --- a/src/components/booking/Booking.tsx +++ b/src/components/booking/Booking.tsx @@ -53,7 +53,7 @@ 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 } | null>(null); + const [modal, setModal] = useState<{ date: string; entry?: TimeEntry | null; template?: TimeEntry | null } | null>(null); const [exportOpen, setExportOpen] = useState(false); const [viewUserId, setViewUserId] = useState(user!.id); const isLead = atLeast(user, 'lead'); @@ -100,6 +100,12 @@ 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. */ + const copy = (entry: TimeEntry) => { + if (readOnly) return; + setModal({ date: today, template: entry }); + }; + /* * Im Export stehen nur die eigenen Projekte zur Wahl - auch fuer * Vorgesetzte. Wer einen Nachweis fuer einen Kunden erstellt, filtert nach @@ -228,12 +234,13 @@ export const Booking: React.FC = () => { 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'}.`} /> )} {layout === 'calendar' && mode === 'week' && ( - !readOnly && setModal({ date: entry.entry_date, entry })} /> + !readOnly && setModal({ date: entry.entry_date, entry })} onCopy={copy} /> )} {layout === 'calendar' && mode === 'month' && ( { )} {modal && projects.data && ( - setModal(null)} onSaved={() => { entries.reload(); projects.reload(); }} /> + setModal(null)} + onSaved={() => { entries.reload(); projects.reload(); }} + onDuplicate={copy} + /> )} {exportOpen && ( void; + /** Kopier-Knopf je Zeile: legt die Buchung als neue Buchung an. */ + onCopy?: (entry: TimeEntry) => void; emptyText?: string; -}> = ({ entries, showUser = false, onEdit, emptyText = 'Keine Buchungen im Zeitraum.' }) => { +}> = ({ entries, showUser = false, onEdit, onCopy, emptyText = 'Keine Buchungen im Zeitraum.' }) => { const [sort, setSort] = useState<{ key: SortKey; dir: 1 | -1 }>({ key: 'entry_date', dir: 1 }); const sorted = useMemo(() => { @@ -79,7 +81,8 @@ export const EntryList: React.FC<{ {head('project_display', 'Projekt', 'left', 'w-[24%]')} {head('task_name', 'Task', 'left')} {head('billable', 'Billable Zeit', 'right', 'w-[104px]')} - {head('non_billable', 'Non-billable Zeit', 'right', 'w-[124px] pr-4')} + {head('non_billable', 'Non-billable Zeit', 'right', `w-[124px] ${onCopy ? '' : 'pr-4'}`)} + {onCopy && } @@ -93,7 +96,7 @@ export const EntryList: React.FC<{ onEdit?.(e)} - className={`animate-rise border-b border-ink-100 last:border-0 ${onEdit ? 'cursor-pointer hover:bg-brand-50/50' : 'hover:bg-ink-50'} ${ + className={`animate-rise group border-b border-ink-100 last:border-0 ${onEdit ? 'cursor-pointer hover:bg-brand-50/50' : 'hover:bg-ink-50'} ${ first && i > 0 ? 'border-t border-t-ink-200' : '' }`} style={{ '--i': Math.min(i, 15) } as React.CSSProperties} @@ -134,9 +137,24 @@ export const EntryList: React.FC<{ {bill ? formatHours(bill) : '–'} - + {non ? formatHours(non) : '–'} + {onCopy && ( + + + + )} ); })} @@ -148,7 +166,8 @@ export const EntryList: React.FC<{ {formatHours(sum.billable + sum.non)} h gesamt {formatHours(sum.billable)} - {formatHours(sum.non)} + {formatHours(sum.non)} + {onCopy && } diff --git a/src/components/booking/EntryModal.tsx b/src/components/booking/EntryModal.tsx index eb65d3b..52fbcc2 100644 --- a/src/components/booking/EntryModal.tsx +++ b/src/components/booking/EntryModal.tsx @@ -13,8 +13,11 @@ 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; } /** Reihenfolge wie in der Auswahlliste: nach Kunde gruppiert, innerhalb in API-Reihenfolge. */ @@ -51,19 +54,26 @@ 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, onClose, onSaved }) => { +export const EntryModal: React.FC = ({ projects, date: initialDate, entry, template, 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(() => entry?.project_id ?? defaultProject(bookable, initialDate, settings)); + const [projectId, setProjectId] = useState(() => source?.project_id ?? defaultProject(bookable, initialDate, settings)); // Neue Buchung: erster aktiver Task des vorausgewaehlten Projekts. const [taskId, setTaskId] = useState(() => - entry ? entry.task_id ?? '' : firstTask(bookable.find((p) => p.id === projectId)) + source ? source.task_id ?? '' : firstTask(bookable.find((p) => p.id === projectId)) ); - const [note, setNote] = useState(entry?.note ?? ''); + const [note, setNote] = useState(source?.note ?? ''); const [date, setDate] = useState(entry?.entry_date ?? initialDate); - const [timeText, setTimeText] = useState(entry ? formatDuration(entry.minutes) : '0:00'); + const [timeText, setTimeText] = useState(source ? formatDuration(source.minutes) : '0:00'); const [busy, setBusy] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); const timeRef = useRef(null); @@ -95,7 +105,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 && entry && entry.project_id === projectId) return; // bewusst ohne Task gebucht + if (!taskId && source && source.project_id === projectId) return; // bewusst ohne Task gebucht setTaskId(firstTask(project ?? undefined)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectId]); @@ -172,16 +182,23 @@ export const EntryModal: React.FC = ({ projects, date: initialDate, entry return ( <> {entry ? ( - +
+ + {onDuplicate && ( + + )} +
) : ( Enter im Zeitfeld bucht · sonst Strg + Enter )} diff --git a/src/components/booking/WeekView.tsx b/src/components/booking/WeekView.tsx index af6fcf7..4dd5f90 100644 --- a/src/components/booking/WeekView.tsx +++ b/src/components/booking/WeekView.tsx @@ -11,13 +11,14 @@ interface Props { dailyTarget: number; onAdd: (date: string) => void; onEdit: (entry: TimeEntry) => void; + onCopy: (entry: TimeEntry) => void; } /** * Wochenansicht wie ein moderner Stundenzettel: sieben Spalten, jede Buchung * als Karte mit Kundenfarbe. Auf dem Handy ein Tagesstreifen mit Liste. */ -export const WeekView: React.FC = ({ from, entries, readOnly, dailyTarget, onAdd, onEdit }) => { +export const WeekView: React.FC = ({ from, entries, readOnly, dailyTarget, onAdd, onEdit, onCopy }) => { const isMobile = useIsMobile(); const today = todayIso(); const days = Array.from({ length: 7 }, (_, i) => addDays(from, i)); @@ -58,7 +59,7 @@ export const WeekView: React.FC = ({ from, entries, readOnly, dailyTarget
{list.map((entry, i) => ( - onEdit(entry)} readOnly={readOnly} large /> + onEdit(entry)} onCopy={() => onCopy(entry)} readOnly={readOnly} large /> ))} {list.length === 0 && (
{list.map((entry, j) => ( - onEdit(entry)} readOnly={readOnly} /> + onEdit(entry)} onCopy={() => onCopy(entry)} readOnly={readOnly} /> ))} {!readOnly && ( + // Kopier-Knopf als Geschwister der Karte - ein Button im Button ist kein gueltiges HTML. +
+ + {canCopy && ( + + )} +
); };