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