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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2b9b7a6ae3
commit
ef709d423c
@@ -3,6 +3,7 @@ import { Avatar, Button, Card, ErrorBox, Segmented, Spinner, inputClass } from '
|
|||||||
import { Icon } from '../ui/Icon';
|
import { Icon } from '../ui/Icon';
|
||||||
import { Donut } from '../ui/charts';
|
import { Donut } from '../ui/charts';
|
||||||
import { EntryModal } from './EntryModal';
|
import { EntryModal } from './EntryModal';
|
||||||
|
import { CopyDialog } from './CopyDialog';
|
||||||
import { ExportDialog } from './ExportDialog';
|
import { ExportDialog } from './ExportDialog';
|
||||||
import { WeekView } from './WeekView';
|
import { WeekView } from './WeekView';
|
||||||
import { MonthView } from './MonthView';
|
import { MonthView } from './MonthView';
|
||||||
@@ -53,7 +54,8 @@ export const Booking: React.FC = () => {
|
|||||||
const [mode, setMode] = usePersistentState<BookingMode>('pulse_booking_mode', 'week');
|
const [mode, setMode] = usePersistentState<BookingMode>('pulse_booking_mode', 'week');
|
||||||
const [layout, setLayout] = usePersistentState<BookingLayout>('pulse_booking_layout', 'calendar');
|
const [layout, setLayout] = usePersistentState<BookingLayout>('pulse_booking_layout', 'calendar');
|
||||||
const [anchor, setAnchor] = useState(todayIso());
|
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<TimeEntry | null>(null);
|
||||||
const [exportOpen, setExportOpen] = useState(false);
|
const [exportOpen, setExportOpen] = useState(false);
|
||||||
const [viewUserId, setViewUserId] = useState<number>(user!.id);
|
const [viewUserId, setViewUserId] = useState<number>(user!.id);
|
||||||
const isLead = atLeast(user, 'lead');
|
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) });
|
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) => {
|
const copy = (entry: TimeEntry) => {
|
||||||
if (readOnly) return;
|
if (readOnly) return;
|
||||||
setModal({ date: today, template: entry });
|
setModal(null);
|
||||||
|
setCopyEntry(entry);
|
||||||
};
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -281,16 +284,22 @@ export const Booking: React.FC = () => {
|
|||||||
|
|
||||||
{modal && projects.data && (
|
{modal && projects.data && (
|
||||||
<EntryModal
|
<EntryModal
|
||||||
key={modal.entry?.id ?? `new-${modal.template?.id ?? ''}`}
|
|
||||||
projects={projects.data}
|
projects={projects.data}
|
||||||
date={modal.date}
|
date={modal.date}
|
||||||
entry={modal.entry}
|
entry={modal.entry}
|
||||||
template={modal.template}
|
|
||||||
onClose={() => setModal(null)}
|
onClose={() => setModal(null)}
|
||||||
onSaved={() => { entries.reload(); projects.reload(); }}
|
onSaved={() => { entries.reload(); projects.reload(); }}
|
||||||
onDuplicate={copy}
|
onDuplicate={copy}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{copyEntry && projects.data && (
|
||||||
|
<CopyDialog
|
||||||
|
entry={copyEntry}
|
||||||
|
projects={projects.data}
|
||||||
|
onClose={() => setCopyEntry(null)}
|
||||||
|
onSaved={() => { entries.reload(); projects.reload(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{exportOpen && (
|
{exportOpen && (
|
||||||
<ExportDialog
|
<ExportDialog
|
||||||
month={anchor.slice(0, 7)}
|
month={anchor.slice(0, 7)}
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { Modal } from '../ui/Modal';
|
||||||
|
import { Button } from '../ui/controls';
|
||||||
|
import { Icon } from '../ui/Icon';
|
||||||
|
import { useAuth } from '../../lib/auth';
|
||||||
|
import { calendarApi, entriesApi } from '../../lib/api';
|
||||||
|
import { useLoader } from '../../lib/hooks';
|
||||||
|
import {
|
||||||
|
MONTHS,
|
||||||
|
WEEKDAYS_SHORT,
|
||||||
|
addDays,
|
||||||
|
addMonths,
|
||||||
|
colorFor,
|
||||||
|
formatDate,
|
||||||
|
formatDuration,
|
||||||
|
fromIso,
|
||||||
|
mondayOf,
|
||||||
|
monthEnd,
|
||||||
|
monthStart,
|
||||||
|
todayIso,
|
||||||
|
weekdayIndex,
|
||||||
|
} from '../../lib/format';
|
||||||
|
import { useToast } from '../Toast';
|
||||||
|
import { checkPot } from './budget';
|
||||||
|
import type { Project, TimeEntry } from '../../types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
entry: TimeEntry;
|
||||||
|
projects: Project[];
|
||||||
|
onClose: () => 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<Props> = ({ entry, projects, onClose, onSaved }) => {
|
||||||
|
const { user, settings } = useAuth();
|
||||||
|
const { addToast } = useToast();
|
||||||
|
const [month, setMonth] = useState(() => monthStart(todayIso()));
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(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<string, number>();
|
||||||
|
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<string, string>();
|
||||||
|
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<string>();
|
||||||
|
// 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 (
|
||||||
|
<Modal
|
||||||
|
title="Buchung kopieren"
|
||||||
|
subtitle="Tage auswählen, auf die kopiert werden soll"
|
||||||
|
onClose={onClose}
|
||||||
|
size="md"
|
||||||
|
footer={
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="tabular text-xs font-bold text-ink-500">
|
||||||
|
{dates.length ? `${dates.length} ${dates.length === 1 ? 'Tag' : 'Tage'} · ${formatDuration(totalMinutes)} h` : 'Keine Tage gewählt'}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button icon="copy" busy={busy} onClick={save} disabled={!project || dates.length === 0}>
|
||||||
|
Buchen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Vorlage */}
|
||||||
|
<div className="relative overflow-hidden rounded-xl border border-ink-200 bg-ink-50/60 p-3 pl-4">
|
||||||
|
<span aria-hidden className="absolute inset-y-0 left-0 w-1.5" style={{ background: color }} />
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="break-words text-sm font-extrabold text-ink-900">{entry.project_display}</p>
|
||||||
|
{entry.task_name && <p className="break-words text-xs font-semibold text-ink-500">{entry.task_name}</p>}
|
||||||
|
{entry.note && <p className="mt-0.5 break-words text-xs text-ink-400">{entry.note}</p>}
|
||||||
|
</div>
|
||||||
|
<span className="tabular shrink-0 text-lg font-extrabold text-ink-800">{formatDuration(entry.minutes)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!project && (
|
||||||
|
<p className="rounded-xl border border-red-200 bg-red-50 p-3 text-sm font-semibold text-red-700">
|
||||||
|
Das Projekt ist nicht mehr aktiv oder dir nicht mehr zugewiesen – Kopieren ist nicht möglich.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{project && taskGone && (
|
||||||
|
<p className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm font-semibold text-amber-700">
|
||||||
|
Der Task „{entry.task_name}“ ist nicht mehr aktiv – die Kopien werden ohne Task gebucht.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Monatskalender */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() => setMonth(addMonths(month, -1))}
|
||||||
|
className="rounded-lg p-2 text-ink-500 transition hover:bg-ink-100 hover:text-ink-800"
|
||||||
|
aria-label="Vorheriger Monat"
|
||||||
|
>
|
||||||
|
<Icon name="chevronLeft" className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<p className="text-sm font-extrabold text-ink-900">
|
||||||
|
{MONTHS[Number(month.slice(5, 7)) - 1]} {month.slice(0, 4)}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setMonth(addMonths(month, 1))}
|
||||||
|
className="rounded-lg p-2 text-ink-500 transition hover:bg-ink-100 hover:text-ink-800"
|
||||||
|
aria-label="Nächster Monat"
|
||||||
|
>
|
||||||
|
<Icon name="chevronRight" className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mb-1 grid grid-cols-7 gap-1">
|
||||||
|
{WEEKDAYS_SHORT.map((d) => (
|
||||||
|
<p key={d} className="py-1 text-center text-[10px] font-extrabold uppercase tracking-wider text-ink-400">
|
||||||
|
{d}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-1">
|
||||||
|
{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 (
|
||||||
|
<button
|
||||||
|
key={date}
|
||||||
|
onClick={() => toggle(date)}
|
||||||
|
disabled={!!reason || busy}
|
||||||
|
aria-pressed={active}
|
||||||
|
title={title}
|
||||||
|
className={`relative flex h-12 flex-col items-center justify-center rounded-xl border text-sm font-extrabold transition disabled:cursor-not-allowed ${
|
||||||
|
active
|
||||||
|
? 'border-brand-500 bg-brand-500 text-white shadow-card'
|
||||||
|
: reason
|
||||||
|
? 'border-transparent text-ink-300 line-through'
|
||||||
|
: absence
|
||||||
|
? 'border-amber-200 bg-amber-50 text-amber-700 hover:border-brand-300'
|
||||||
|
: date === today
|
||||||
|
? 'border-brand-300 text-brand-700 hover:bg-brand-50'
|
||||||
|
: weekend
|
||||||
|
? 'border-ink-100 bg-ink-50/60 text-ink-500 hover:border-brand-300'
|
||||||
|
: 'border-ink-200 text-ink-700 hover:border-brand-300 hover:bg-brand-50'
|
||||||
|
} ${inMonth ? '' : 'opacity-40'}`}
|
||||||
|
>
|
||||||
|
{fromIso(date).getDate()}
|
||||||
|
<span className={`tabular text-[9px] font-bold leading-none ${active ? 'text-white/80' : 'text-ink-400'}`}>
|
||||||
|
{minutes ? formatDuration(minutes) : date === entry.entry_date ? 'Vorlage' : ' '}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<p className="flex items-center gap-1.5 text-[11px] text-ink-400">
|
||||||
|
<span className="h-2.5 w-2.5 rounded-sm border border-amber-200 bg-amber-50" /> Urlaub/Feiertag · Zahl = bereits gebucht
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button variant="ghost" size="sm" onClick={selectWorkdays} disabled={!project || busy}>
|
||||||
|
Werktage
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={clearMonth} disabled={busy}>
|
||||||
|
Leeren
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dates.length > 0 && (
|
||||||
|
<p className="break-words text-xs text-ink-500">
|
||||||
|
<span className="font-bold text-ink-700">Gewählt: </span>
|
||||||
|
{dates.map((d) => formatDate(d)).join(', ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -147,7 +147,7 @@ export const EntryList: React.FC<{
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
onCopy(e);
|
onCopy(e);
|
||||||
}}
|
}}
|
||||||
title="Als neue Buchung kopieren"
|
title="Auf andere Tage kopieren"
|
||||||
aria-label="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"
|
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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ interface Props {
|
|||||||
projects: Project[];
|
projects: Project[];
|
||||||
date: string;
|
date: string;
|
||||||
entry?: TimeEntry | null;
|
entry?: TimeEntry | null;
|
||||||
/** Vorlage fuer eine neue Buchung (Kopie): Projekt, Task, Notiz und Zeit werden uebernommen. */
|
|
||||||
template?: TimeEntry | null;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
onDuplicate?: (entry: TimeEntry) => 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
|
* Buchungsdialog: oben Projekt, darunter Task, darunter Freitext mit der Zeit
|
||||||
* rechts daneben. Die Zeit versteht "1:23" und "1,5".
|
* rechts daneben. Die Zeit versteht "1:23" und "1,5".
|
||||||
*/
|
*/
|
||||||
export const EntryModal: React.FC<Props> = ({ projects, date: initialDate, entry, template, onClose, onSaved, onDuplicate }) => {
|
export const EntryModal: React.FC<Props> = ({ projects, date: initialDate, entry, onClose, onSaved, onDuplicate }) => {
|
||||||
const { settings } = useAuth();
|
const { settings } = useAuth();
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
const bookable = useMemo(() => projects.filter((p) => p.is_active || p.id === entry?.project_id), [projects, entry]);
|
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 | ''>(() => source?.project_id ?? defaultProject(bookable, initialDate, settings));
|
const [projectId, setProjectId] = useState<number | ''>(() => entry?.project_id ?? defaultProject(bookable, initialDate, settings));
|
||||||
// Neue Buchung: erster aktiver Task des vorausgewaehlten Projekts.
|
// Neue Buchung: erster aktiver Task des vorausgewaehlten Projekts.
|
||||||
const [taskId, setTaskId] = useState<number | ''>(() =>
|
const [taskId, setTaskId] = useState<number | ''>(() =>
|
||||||
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 [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 [busy, setBusy] = useState(false);
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
const timeRef = useRef<HTMLInputElement>(null);
|
const timeRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -105,7 +96,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.
|
// Projekt gewechselt: passt der Task nicht mehr, den ersten Task des neuen Projekts nehmen.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (taskId && tasks.some((t) => t.id === taskId)) return;
|
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));
|
setTaskId(firstTask(project ?? undefined));
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
@@ -182,7 +173,7 @@ export const EntryModal: React.FC<Props> = ({ projects, date: initialDate, entry
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal
|
<Modal
|
||||||
title={entry ? 'Buchung bearbeiten' : copyFrom ? 'Buchung kopieren' : 'Zeit buchen'}
|
title={entry ? 'Buchung bearbeiten' : 'Zeit buchen'}
|
||||||
subtitle={formatDate(date, 'long')}
|
subtitle={formatDate(date, 'long')}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
size="md"
|
size="md"
|
||||||
@@ -194,8 +185,8 @@ export const EntryModal: React.FC<Props> = ({ projects, date: initialDate, entry
|
|||||||
<span className="hidden sm:inline">Löschen</span>
|
<span className="hidden sm:inline">Löschen</span>
|
||||||
</Button>
|
</Button>
|
||||||
{onDuplicate && (
|
{onDuplicate && (
|
||||||
<Button variant="ghost" icon="copy" onClick={() => onDuplicate(entry)} title="Als neue Buchung kopieren" aria-label="Duplizieren">
|
<Button variant="ghost" icon="copy" onClick={() => onDuplicate(entry)} title="Auf andere Tage kopieren" aria-label="Kopieren">
|
||||||
<span className="hidden sm:inline">Duplizieren</span>
|
<span className="hidden sm:inline">Kopieren</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ ${entry.note}` : ''}`}
|
|||||||
{canCopy && (
|
{canCopy && (
|
||||||
<button
|
<button
|
||||||
onClick={onCopy}
|
onClick={onCopy}
|
||||||
title="Als neue Buchung kopieren"
|
title="Auf andere Tage kopieren"
|
||||||
aria-label="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 ${
|
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'
|
large ? 'bottom-3 right-3 h-9 w-9' : 'bottom-1.5 right-1.5 h-6 w-6'
|
||||||
|
|||||||
Reference in New Issue
Block a user