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
+27 -10
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
</Button>
<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>
)}