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>
184 lines
8.8 KiB
TypeScript
184 lines
8.8 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
||
import { Icon } from '../ui/Icon';
|
||
import { useIsMobile, useGrow } from '../../lib/hooks';
|
||
import { WEEKDAYS_LONG, WEEKDAYS_SHORT, addDays, colorFor, formatDuration, fromIso, todayIso } from '../../lib/format';
|
||
import type { TimeEntry } from '../../types';
|
||
|
||
interface Props {
|
||
from: string;
|
||
entries: TimeEntry[];
|
||
readOnly: boolean;
|
||
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, onCopy }) => {
|
||
const isMobile = useIsMobile();
|
||
const today = todayIso();
|
||
const days = Array.from({ length: 7 }, (_, i) => addDays(from, i));
|
||
const byDay = (date: string) => entries.filter((e) => e.entry_date === date);
|
||
const [selected, setSelected] = useState(() => (today >= from && today <= days[6] ? today : from));
|
||
|
||
useEffect(() => {
|
||
setSelected(today >= from && today <= addDays(from, 6) ? today : from);
|
||
}, [from, today]);
|
||
|
||
if (isMobile) {
|
||
const list = byDay(selected);
|
||
const total = list.reduce((s, e) => s + e.minutes, 0);
|
||
return (
|
||
<div>
|
||
<div className="mb-3 grid grid-cols-7 gap-1">
|
||
{days.map((date, i) => {
|
||
const minutes = byDay(date).reduce((s, e) => s + e.minutes, 0);
|
||
const active = date === selected;
|
||
return (
|
||
<button
|
||
key={date}
|
||
onClick={() => setSelected(date)}
|
||
className={`flex flex-col items-center rounded-2xl border py-2 transition ${
|
||
active ? 'border-brand-400 bg-brand-500 text-white shadow-card' : date === today ? 'border-brand-200 bg-brand-50 text-brand-700' : 'border-ink-200 bg-surface text-ink-600'
|
||
}`}
|
||
>
|
||
<span className="text-[10px] font-bold uppercase">{WEEKDAYS_SHORT[i]}</span>
|
||
<span className="text-lg font-extrabold leading-tight">{fromIso(date).getDate()}</span>
|
||
<span className={`tabular text-[10px] font-bold ${active ? 'text-white/85' : minutes ? 'text-ink-500' : 'text-ink-300'}`}>{minutes ? formatDuration(minutes) : '–'}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="mb-2 flex items-baseline justify-between px-1">
|
||
<p className="text-sm font-extrabold text-ink-900">{WEEKDAYS_LONG[days.indexOf(selected)]}</p>
|
||
<p className="tabular text-sm font-extrabold text-ink-600">{formatDuration(total)} h</p>
|
||
</div>
|
||
<div key={selected} className="space-y-2">
|
||
{list.map((entry, i) => (
|
||
<EntryCard key={entry.id} entry={entry} index={i} onClick={() => onEdit(entry)} onCopy={() => onCopy(entry)} readOnly={readOnly} large />
|
||
))}
|
||
{list.length === 0 && (
|
||
<button
|
||
disabled={readOnly}
|
||
onClick={() => onAdd(selected)}
|
||
className="flex w-full flex-col items-center gap-2 rounded-2xl border-2 border-dashed border-ink-200 py-10 text-sm font-bold text-ink-400"
|
||
>
|
||
<Icon name="plus" className="h-6 w-6" />
|
||
{readOnly ? 'Keine Buchungen' : 'Zeit für diesen Tag buchen'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="grid grid-cols-7 gap-2 xl:gap-3">
|
||
{days.map((date, i) => {
|
||
const list = byDay(date);
|
||
const minutes = list.reduce((s, e) => s + e.minutes, 0);
|
||
const weekend = i >= 5;
|
||
const isToday = date === today;
|
||
return (
|
||
<div
|
||
key={date}
|
||
className={`animate-rise group flex min-h-[340px] min-w-0 flex-col rounded-2xl border ${
|
||
isToday ? 'border-brand-300 bg-brand-50/50' : weekend ? 'border-ink-200 bg-ink-50/60' : 'border-ink-200 bg-surface'
|
||
} shadow-card`}
|
||
style={{ '--i': i } as React.CSSProperties}
|
||
>
|
||
<div className="flex items-start justify-between gap-1 border-b border-ink-100 px-3 pb-2 pt-3">
|
||
<div className="min-w-0">
|
||
<p className={`text-[11px] font-extrabold uppercase tracking-wider ${isToday ? 'text-brand-600' : 'text-ink-400'}`}>{WEEKDAYS_SHORT[i]}</p>
|
||
<p className={`text-2xl font-extrabold leading-none ${isToday ? 'text-brand-700' : 'text-ink-900'}`}>{fromIso(date).getDate()}.</p>
|
||
</div>
|
||
<div className="text-right">
|
||
<p className="tabular text-sm font-extrabold text-ink-800">{minutes ? formatDuration(minutes) : ''}</p>
|
||
{!weekend && <DayMeter minutes={minutes} target={dailyTarget} />}
|
||
</div>
|
||
</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)} onCopy={() => onCopy(entry)} readOnly={readOnly} />
|
||
))}
|
||
{!readOnly && (
|
||
<button
|
||
onClick={() => onAdd(date)}
|
||
className="mt-auto flex items-center justify-center gap-1 rounded-xl border border-dashed border-ink-200 py-2 text-xs font-bold text-ink-400 transition hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700"
|
||
>
|
||
<Icon name="plus" className="h-3.5 w-3.5" strokeWidth={2.4} />
|
||
Buchen
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const DayMeter: React.FC<{ minutes: number; target: number }> = ({ minutes, target }) => {
|
||
const grown = useGrow(minutes);
|
||
const p = target ? Math.min(100, (minutes / target) * 100) : 0;
|
||
return (
|
||
<div className="mt-1 h-1 w-12 overflow-hidden rounded-full bg-ink-100" title={`${Math.round(p)} % vom Tagessoll`}>
|
||
<div className={`h-full rounded-full transition-[width] duration-700 ${p >= 100 ? 'bg-brand-500' : 'bg-brand-300'}`} style={{ width: grown ? `${p}%` : 0 }} />
|
||
</div>
|
||
);
|
||
};
|
||
|
||
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}
|
||
className={`animate-rise relative w-full overflow-hidden rounded-xl border border-ink-200 bg-surface text-left transition ${
|
||
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 ? `
|
||
${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="Auf andere Tage 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>
|
||
);
|
||
};
|