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 <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-25 13:45:44 +02:00
co-authored by Claude Opus 5.5
parent b6ba7860bf
commit 2b9b7a6ae3
4 changed files with 113 additions and 40 deletions
+19 -3
View File
@@ -53,7 +53,7 @@ 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 } | 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<number>(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 = () => {
<EntryList
entries={list}
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'}.`}
/>
</Card>
)}
{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' && (
<MonthView
@@ -273,7 +280,16 @@ export const Booking: React.FC = () => {
)}
{modal && projects.data && (
<EntryModal projects={projects.data} date={modal.date} entry={modal.entry} onClose={() => setModal(null)} onSaved={() => { entries.reload(); projects.reload(); }} />
<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}
/>
)}
{exportOpen && (
<ExportDialog
+24 -5
View File
@@ -17,8 +17,10 @@ export const EntryList: React.FC<{
entries: TimeEntry[];
showUser?: boolean;
onEdit?: (entry: TimeEntry) => 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 && <th className="w-[48px] pr-3" aria-label="Kopieren" />}
</tr>
</thead>
<tbody>
@@ -93,7 +96,7 @@ export const EntryList: React.FC<{
<tr
key={e.id}
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' : ''
}`}
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'}`}>
{bill ? formatHours(bill) : '–'}
</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) : '–'}
</td>
{onCopy && (
<td className="py-1.5 pr-3 text-right align-top">
<button
onClick={(event) => {
event.stopPropagation();
onCopy(e);
}}
title="Als neue Buchung 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>
);
})}
@@ -148,7 +166,8 @@ export const EntryList: React.FC<{
</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 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>
</tfoot>
</table>
+26 -9
View File
@@ -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<Props> = ({ projects, date: initialDate, entry, onClose, onSaved }) => {
export const EntryModal: React.FC<Props> = ({ 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<number | ''>(() => entry?.project_id ?? defaultProject(bookable, initialDate, settings));
const [projectId, setProjectId] = useState<number | ''>(() => source?.project_id ?? defaultProject(bookable, initialDate, settings));
// Neue Buchung: erster aktiver Task des vorausgewaehlten Projekts.
const [taskId, setTaskId] = useState<number | ''>(() =>
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<HTMLInputElement>(null);
@@ -95,7 +105,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 && 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<Props> = ({ projects, date: initialDate, entry
return (
<>
<Modal
title={entry ? 'Buchung bearbeiten' : 'Zeit buchen'}
title={entry ? 'Buchung bearbeiten' : copyFrom ? 'Buchung kopieren' : 'Zeit buchen'}
subtitle={formatDate(date, 'long')}
onClose={onClose}
size="md"
footer={
<div className="flex items-center justify-between gap-3">
{entry ? (
<Button variant="ghost" icon="trash" className="text-red-600 hover:bg-red-50" onClick={() => setConfirmDelete(true)}>
Löschen
<div className="flex gap-1">
<Button variant="ghost" icon="trash" className="text-red-600 hover:bg-red-50" onClick={() => setConfirmDelete(true)} aria-label="Löschen">
<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>
)}
</div>
) : (
<span className="hidden text-[11px] text-ink-400 sm:block">Enter im Zeitfeld bucht · sonst Strg + Enter</span>
)}
+26 -5
View File
@@ -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<Props> = ({ from, entries, readOnly, dailyTarget, onAdd, onEdit }) => {
export const WeekView: React.FC<Props> = ({ 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<Props> = ({ from, entries, readOnly, dailyTarget
</div>
<div key={selected} className="space-y-2">
{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 && (
<button
@@ -102,7 +103,7 @@ export const WeekView: React.FC<Props> = ({ from, entries, readOnly, dailyTarget
</div>
<div className="flex flex-1 flex-col gap-2 p-2">
{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 && (
<button
@@ -131,15 +132,19 @@ 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,
index,
onClick,
onCopy,
readOnly,
large,
}) => {
const color = colorFor(entry.customer_id, entry.customer_color);
const canCopy = !readOnly && !!onCopy;
return (
// Kopier-Knopf als Geschwister der Karte - ein Button im Button ist kein gueltiges HTML.
<div className="group/card relative">
<button
onClick={onClick}
disabled={readOnly && !large}
@@ -147,16 +152,32 @@ export const EntryCard: React.FC<{ entry: TimeEntry; index: number; onClick: ()
readOnly ? 'cursor-default' : 'hover:-translate-y-0.5 hover:border-brand-300 hover:shadow-lift'
} ${large ? 'p-4 pl-5' : 'p-2.5 pl-3.5'}`}
style={{ '--i': index } as React.CSSProperties}
title={`${entry.project_display}${entry.task_name ? ` · ${entry.task_name}` : ''}${entry.note ? `\n${entry.note}` : ''}`}
title={`${entry.project_display}${entry.task_name ? ` · ${entry.task_name}` : ''}${entry.note ? `
${entry.note}` : ''}`}
>
<span aria-hidden className="absolute inset-y-0 left-0 w-1.5" style={{ background: color }} />
<div className="flex items-start justify-between gap-2">
<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>
</div>
<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="Als neue Buchung 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>
);
};