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:
co-authored by
Claude Opus 5.5
parent
b6ba7860bf
commit
2b9b7a6ae3
@@ -53,7 +53,7 @@ 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 } | null>(null);
|
const [modal, setModal] = useState<{ date: string; entry?: TimeEntry | null; template?: TimeEntry | null } | 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,6 +100,12 @@ 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. */
|
||||||
|
const copy = (entry: TimeEntry) => {
|
||||||
|
if (readOnly) return;
|
||||||
|
setModal({ date: today, template: entry });
|
||||||
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Im Export stehen nur die eigenen Projekte zur Wahl - auch fuer
|
* Im Export stehen nur die eigenen Projekte zur Wahl - auch fuer
|
||||||
* Vorgesetzte. Wer einen Nachweis fuer einen Kunden erstellt, filtert nach
|
* Vorgesetzte. Wer einen Nachweis fuer einen Kunden erstellt, filtert nach
|
||||||
@@ -228,12 +234,13 @@ export const Booking: React.FC = () => {
|
|||||||
<EntryList
|
<EntryList
|
||||||
entries={list}
|
entries={list}
|
||||||
onEdit={readOnly ? undefined : (entry) => setModal({ date: entry.entry_date, entry })}
|
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'}.`}
|
emptyText={`Keine Buchungen ${mode === 'week' ? 'in dieser Woche' : mode === 'month' ? 'in diesem Monat' : 'in diesem Jahr'}.`}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
{layout === 'calendar' && mode === 'week' && (
|
{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' && (
|
{layout === 'calendar' && mode === 'month' && (
|
||||||
<MonthView
|
<MonthView
|
||||||
@@ -273,7 +280,16 @@ export const Booking: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{modal && projects.data && (
|
{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 && (
|
{exportOpen && (
|
||||||
<ExportDialog
|
<ExportDialog
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ export const EntryList: React.FC<{
|
|||||||
entries: TimeEntry[];
|
entries: TimeEntry[];
|
||||||
showUser?: boolean;
|
showUser?: boolean;
|
||||||
onEdit?: (entry: TimeEntry) => void;
|
onEdit?: (entry: TimeEntry) => void;
|
||||||
|
/** Kopier-Knopf je Zeile: legt die Buchung als neue Buchung an. */
|
||||||
|
onCopy?: (entry: TimeEntry) => void;
|
||||||
emptyText?: string;
|
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 [sort, setSort] = useState<{ key: SortKey; dir: 1 | -1 }>({ key: 'entry_date', dir: 1 });
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
@@ -79,7 +81,8 @@ export const EntryList: React.FC<{
|
|||||||
{head('project_display', 'Projekt', 'left', 'w-[24%]')}
|
{head('project_display', 'Projekt', 'left', 'w-[24%]')}
|
||||||
{head('task_name', 'Task', 'left')}
|
{head('task_name', 'Task', 'left')}
|
||||||
{head('billable', 'Billable Zeit', 'right', 'w-[104px]')}
|
{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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -93,7 +96,7 @@ export const EntryList: React.FC<{
|
|||||||
<tr
|
<tr
|
||||||
key={e.id}
|
key={e.id}
|
||||||
onClick={() => onEdit?.(e)}
|
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' : ''
|
first && i > 0 ? 'border-t border-t-ink-200' : ''
|
||||||
}`}
|
}`}
|
||||||
style={{ '--i': Math.min(i, 15) } as React.CSSProperties}
|
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'}`}>
|
<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) : '–'}
|
{bill ? formatHours(bill) : '–'}
|
||||||
</td>
|
</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) : '–'}
|
{non ? formatHours(non) : '–'}
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -148,7 +166,8 @@ export const EntryList: React.FC<{
|
|||||||
</td>
|
</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 + sum.non)} h gesamt</td>
|
||||||
<td className="tabular px-3 py-3 text-right">{formatHours(sum.billable)}</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>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reihenfolge wie in der Auswahlliste: nach Kunde gruppiert, innerhalb in API-Reihenfolge. */
|
/** 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
|
* 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, onClose, onSaved }) => {
|
export const EntryModal: React.FC<Props> = ({ projects, date: initialDate, entry, template, 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 | ''>(() => 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.
|
// Neue Buchung: erster aktiver Task des vorausgewaehlten Projekts.
|
||||||
const [taskId, setTaskId] = useState<number | ''>(() =>
|
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 [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 [busy, setBusy] = useState(false);
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
const timeRef = useRef<HTMLInputElement>(null);
|
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.
|
// 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 && 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));
|
setTaskId(firstTask(project ?? undefined));
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
@@ -172,16 +182,23 @@ export const EntryModal: React.FC<Props> = ({ projects, date: initialDate, entry
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal
|
<Modal
|
||||||
title={entry ? 'Buchung bearbeiten' : 'Zeit buchen'}
|
title={entry ? 'Buchung bearbeiten' : copyFrom ? 'Buchung kopieren' : 'Zeit buchen'}
|
||||||
subtitle={formatDate(date, 'long')}
|
subtitle={formatDate(date, 'long')}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
size="md"
|
size="md"
|
||||||
footer={
|
footer={
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
{entry ? (
|
{entry ? (
|
||||||
<Button variant="ghost" icon="trash" className="text-red-600 hover:bg-red-50" onClick={() => setConfirmDelete(true)}>
|
<div className="flex gap-1">
|
||||||
Löschen
|
<Button variant="ghost" icon="trash" className="text-red-600 hover:bg-red-50" onClick={() => setConfirmDelete(true)} aria-label="Löschen">
|
||||||
</Button>
|
<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>
|
<span className="hidden text-[11px] text-ink-400 sm:block">Enter im Zeitfeld bucht · sonst Strg + Enter</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,13 +11,14 @@ interface Props {
|
|||||||
dailyTarget: number;
|
dailyTarget: number;
|
||||||
onAdd: (date: string) => void;
|
onAdd: (date: string) => void;
|
||||||
onEdit: (entry: TimeEntry) => void;
|
onEdit: (entry: TimeEntry) => void;
|
||||||
|
onCopy: (entry: TimeEntry) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wochenansicht wie ein moderner Stundenzettel: sieben Spalten, jede Buchung
|
* Wochenansicht wie ein moderner Stundenzettel: sieben Spalten, jede Buchung
|
||||||
* als Karte mit Kundenfarbe. Auf dem Handy ein Tagesstreifen mit Liste.
|
* 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 isMobile = useIsMobile();
|
||||||
const today = todayIso();
|
const today = todayIso();
|
||||||
const days = Array.from({ length: 7 }, (_, i) => addDays(from, i));
|
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>
|
||||||
<div key={selected} className="space-y-2">
|
<div key={selected} className="space-y-2">
|
||||||
{list.map((entry, i) => (
|
{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 && (
|
{list.length === 0 && (
|
||||||
<button
|
<button
|
||||||
@@ -102,7 +103,7 @@ export const WeekView: React.FC<Props> = ({ from, entries, readOnly, dailyTarget
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 flex-col gap-2 p-2">
|
<div className="flex flex-1 flex-col gap-2 p-2">
|
||||||
{list.map((entry, j) => (
|
{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 && (
|
{!readOnly && (
|
||||||
<button
|
<button
|
||||||
@@ -131,32 +132,52 @@ 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,
|
entry,
|
||||||
index,
|
index,
|
||||||
onClick,
|
onClick,
|
||||||
|
onCopy,
|
||||||
readOnly,
|
readOnly,
|
||||||
large,
|
large,
|
||||||
}) => {
|
}) => {
|
||||||
const color = colorFor(entry.customer_id, entry.customer_color);
|
const color = colorFor(entry.customer_id, entry.customer_color);
|
||||||
|
const canCopy = !readOnly && !!onCopy;
|
||||||
return (
|
return (
|
||||||
<button
|
// Kopier-Knopf als Geschwister der Karte - ein Button im Button ist kein gueltiges HTML.
|
||||||
onClick={onClick}
|
<div className="group/card relative">
|
||||||
disabled={readOnly && !large}
|
<button
|
||||||
className={`animate-rise relative w-full overflow-hidden rounded-xl border border-ink-200 bg-surface text-left transition ${
|
onClick={onClick}
|
||||||
readOnly ? 'cursor-default' : 'hover:-translate-y-0.5 hover:border-brand-300 hover:shadow-lift'
|
disabled={readOnly && !large}
|
||||||
} ${large ? 'p-4 pl-5' : 'p-2.5 pl-3.5'}`}
|
className={`animate-rise relative w-full overflow-hidden rounded-xl border border-ink-200 bg-surface text-left transition ${
|
||||||
style={{ '--i': index } as React.CSSProperties}
|
readOnly ? 'cursor-default' : 'hover:-translate-y-0.5 hover:border-brand-300 hover:shadow-lift'
|
||||||
title={`${entry.project_display}${entry.task_name ? ` · ${entry.task_name}` : ''}${entry.note ? `\n${entry.note}` : ''}`}
|
} ${large ? 'p-4 pl-5' : 'p-2.5 pl-3.5'}`}
|
||||||
>
|
style={{ '--i': index } as React.CSSProperties}
|
||||||
<span aria-hidden className="absolute inset-y-0 left-0 w-1.5" style={{ background: color }} />
|
title={`${entry.project_display}${entry.task_name ? ` · ${entry.task_name}` : ''}${entry.note ? `
|
||||||
<div className="flex items-start justify-between gap-2">
|
${entry.note}` : ''}`}
|
||||||
<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>
|
<span aria-hidden className="absolute inset-y-0 left-0 w-1.5" style={{ background: color }} />
|
||||||
</div>
|
<div className="flex items-start justify-between gap-2">
|
||||||
{entry.task_name && <p className={`truncate font-semibold text-ink-500 ${large ? 'text-xs' : 'text-[11px]'}`}>{entry.task_name}</p>}
|
<p className={`min-w-0 font-extrabold text-ink-900 ${large ? 'text-sm' : 'line-clamp-2 text-xs leading-snug'}`}>{entry.project_display}</p>
|
||||||
{entry.note && <p className={`mt-0.5 text-ink-400 ${large ? 'text-xs' : 'line-clamp-2 text-[11px] leading-snug'}`}>{entry.note}</p>}
|
<span className={`tabular shrink-0 font-extrabold text-ink-800 ${large ? 'text-lg' : 'text-xs'}`}>{formatDuration(entry.minutes)}</span>
|
||||||
{!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>
|
<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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user