Der schwebende Plus-Button sass rechts unten und verdeckte den Kopier-Knopf der Buchungskarten. Jetzt mittig, dazu Abstand am Seitenende, damit die letzte Karte ueber den Button scrollen kann. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
355 lines
15 KiB
TypeScript
355 lines
15 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
||
import { Avatar, Button, Card, ErrorBox, Segmented, Spinner, inputClass } from '../ui/controls';
|
||
import { Icon } from '../ui/Icon';
|
||
import { Donut } from '../ui/charts';
|
||
import { EntryModal } from './EntryModal';
|
||
import { CopyDialog } from './CopyDialog';
|
||
import { ExportDialog } from './ExportDialog';
|
||
import { WeekView } from './WeekView';
|
||
import { MonthView } from './MonthView';
|
||
import { YearView } from './YearView';
|
||
import { EntryList } from './EntryList';
|
||
import { useAuth } from '../../lib/auth';
|
||
import { entriesApi, projectsApi, usersApi } from '../../lib/api';
|
||
import { useIsMobile, useLoader, usePersistentState, useCountUp } from '../../lib/hooks';
|
||
import {
|
||
MONTHS,
|
||
addDays,
|
||
addMonths,
|
||
formatDate,
|
||
formatDuration,
|
||
isoWeek,
|
||
mondayOf,
|
||
monthEnd,
|
||
monthStart,
|
||
todayIso,
|
||
weekdayIndex,
|
||
} from '../../lib/format';
|
||
import { visibleTeam } from '../team/hierarchy';
|
||
import { atLeast, type DirectoryUser, type Project, type TimeEntry } from '../../types';
|
||
|
||
export type BookingMode = 'week' | 'month' | 'year';
|
||
/** Kalender = Wochen-/Monats-/Jahresraster, Liste = die einzelnen Buchungen als Tabelle. */
|
||
export type BookingLayout = 'calendar' | 'list';
|
||
|
||
function rangeFor(mode: BookingMode, anchor: string) {
|
||
if (mode === 'week') {
|
||
const from = mondayOf(anchor);
|
||
return { from, to: addDays(from, 6) };
|
||
}
|
||
if (mode === 'month') return { from: monthStart(anchor), to: monthEnd(anchor) };
|
||
return { from: `${anchor.slice(0, 4)}-01-01`, to: `${anchor.slice(0, 4)}-12-31` };
|
||
}
|
||
|
||
/** Arbeitstage Mo-Fr im Intervall, fuer das Soll. */
|
||
function workdays(from: string, to: string) {
|
||
let count = 0;
|
||
for (let d = from; d <= to; d = addDays(d, 1)) if (weekdayIndex(d) < 5) count++;
|
||
return count;
|
||
}
|
||
|
||
export const Booking: React.FC = () => {
|
||
const { user, settings } = useAuth();
|
||
const isMobile = useIsMobile();
|
||
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 [copyEntry, setCopyEntry] = useState<TimeEntry | null>(null);
|
||
const [exportOpen, setExportOpen] = useState(false);
|
||
const [viewUserId, setViewUserId] = useState<number>(user!.id);
|
||
const isLead = atLeast(user, 'lead');
|
||
const readOnly = viewUserId !== user!.id;
|
||
|
||
const range = rangeFor(mode, anchor);
|
||
const entries = useLoader(() => entriesApi.list(range.from, range.to, viewUserId), [range.from, range.to, viewUserId]);
|
||
const projects = useLoader(() => projectsApi.list('mine'), []);
|
||
const [team, setTeam] = useState<DirectoryUser[]>([]);
|
||
|
||
useEffect(() => {
|
||
if (!isLead) return;
|
||
usersApi
|
||
.directory()
|
||
.then((list) => setTeam(visibleTeam(list, user!)))
|
||
.catch(() => undefined);
|
||
}, [isLead, user]);
|
||
|
||
const list = entries.data ?? [];
|
||
const total = list.reduce((s, e) => s + e.minutes, 0);
|
||
const billable = list.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0);
|
||
const viewedUser = team.find((t) => t.id === viewUserId);
|
||
const weekly = (readOnly ? null : user!.weekly_hours) ?? settings.weekly_hours;
|
||
const today = todayIso();
|
||
// Soll bis heute (bzw. ganzer Zeitraum, wenn er vorbei ist).
|
||
const targetTo = range.to < today ? range.to : today < range.from ? range.from : today;
|
||
const target = today < range.from ? 0 : (weekly / 5) * workdays(range.from, targetTo) * 60;
|
||
|
||
const shift = (step: number) => {
|
||
if (mode === 'week') setAnchor(addDays(anchor, 7 * step));
|
||
else if (mode === 'month') setAnchor(addMonths(anchor, step));
|
||
else setAnchor(`${Number(anchor.slice(0, 4)) + step}-${anchor.slice(5)}`);
|
||
};
|
||
|
||
const label =
|
||
mode === 'week'
|
||
? `KW ${isoWeek(range.from)} · ${formatDate(range.from).slice(0, 6)} – ${formatDate(range.to)}`
|
||
: mode === 'month'
|
||
? `${MONTHS[Number(anchor.slice(5, 7)) - 1]} ${anchor.slice(0, 4)}`
|
||
: anchor.slice(0, 4);
|
||
|
||
const openNew = (date?: string) => {
|
||
if (readOnly) return;
|
||
setModal({ date: date ?? (today >= range.from && today <= range.to ? today : range.from) });
|
||
};
|
||
|
||
/** Kopieren: Tage im Monatskalender waehlen, auf die die Buchung kopiert wird. */
|
||
const copy = (entry: TimeEntry) => {
|
||
if (readOnly) return;
|
||
setModal(null);
|
||
setCopyEntry(entry);
|
||
};
|
||
|
||
/*
|
||
* Im Export stehen nur die eigenen Projekte zur Wahl - auch fuer
|
||
* Vorgesetzte. Wer einen Nachweis fuer einen Kunden erstellt, filtert nach
|
||
* dem, was er selbst gebucht hat; eine Liste aller Projekte des Hauses war
|
||
* dort nur Suchaufwand.
|
||
*/
|
||
const openExport = () => setExportOpen(true);
|
||
|
||
// Tastenkuerzel: N = neue Buchung, Pfeile = blaettern.
|
||
useEffect(() => {
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (modal || exportOpen) return;
|
||
const target = e.target as HTMLElement;
|
||
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) return;
|
||
if (e.key === 'n' || e.key === '+') {
|
||
e.preventDefault();
|
||
openNew();
|
||
}
|
||
if (e.key === 'ArrowLeft') shift(-1);
|
||
if (e.key === 'ArrowRight') shift(1);
|
||
};
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
});
|
||
|
||
const warnings = useMemo(
|
||
() => (projects.data ?? []).flatMap((p) => p.pots.filter((pot) => pot.warning && pot.status !== 'expired' && pot.valid_to >= today).map((pot) => ({ project: p, pot }))),
|
||
[projects.data, today]
|
||
);
|
||
|
||
return (
|
||
<div className={`mx-auto max-w-[1500px] ${isMobile && !readOnly ? 'pb-16' : ''}`}>
|
||
{/* Werkzeugleiste */}
|
||
<div className="mb-4 flex flex-wrap items-center gap-2 sm:gap-3">
|
||
<div className="flex items-center rounded-xl border border-ink-200 bg-surface shadow-card">
|
||
<button onClick={() => shift(-1)} className="rounded-l-xl p-2.5 text-ink-500 hover:bg-ink-50 hover:text-ink-900" aria-label="Zurück">
|
||
<Icon name="chevronLeft" className="h-4 w-4" strokeWidth={2.2} />
|
||
</button>
|
||
<button onClick={() => setAnchor(todayIso())} className="border-x border-ink-200 px-3 py-2 text-xs font-bold text-ink-600 hover:bg-ink-50">
|
||
Heute
|
||
</button>
|
||
<button onClick={() => shift(1)} className="rounded-r-xl p-2.5 text-ink-500 hover:bg-ink-50 hover:text-ink-900" aria-label="Weiter">
|
||
<Icon name="chevronRight" className="h-4 w-4" strokeWidth={2.2} />
|
||
</button>
|
||
</div>
|
||
<h2 className="tabular min-w-0 flex-1 truncate text-base text-ink-900 sm:flex-none sm:text-lg">{label}</h2>
|
||
|
||
<div className="order-last flex w-full flex-wrap items-center gap-2 sm:order-none sm:ml-auto sm:w-auto sm:flex-nowrap">
|
||
<Segmented
|
||
value={mode}
|
||
onChange={setMode}
|
||
className="flex-1 sm:flex-none"
|
||
options={[
|
||
{ value: 'week', label: 'Woche' },
|
||
{ value: 'month', label: 'Monat' },
|
||
{ value: 'year', label: 'Jahr' },
|
||
]}
|
||
/>
|
||
<Segmented
|
||
value={layout}
|
||
onChange={setLayout}
|
||
className="flex-1 sm:flex-none"
|
||
options={[
|
||
{ value: 'calendar', label: 'Kalender', icon: 'calendar', title: 'Kalenderansicht' },
|
||
{ value: 'list', label: 'Liste', icon: 'list', title: 'Alle Buchungen als Liste' },
|
||
]}
|
||
/>
|
||
{isLead && team.length > 1 && (
|
||
<select className={`${inputClass} w-auto max-w-[180px] py-2 text-xs font-bold`} value={viewUserId} onChange={(e) => setViewUserId(Number(e.target.value))} title="Zeiten ansehen von">
|
||
{team.map((t) => (
|
||
<option key={t.id} value={t.id}>
|
||
{t.id === user!.id ? 'Meine Zeiten' : t.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
<Button variant="secondary" icon="download" onClick={openExport} className="py-2">
|
||
<span className="hidden sm:inline">Export</span>
|
||
</Button>
|
||
{!isMobile && !readOnly && (
|
||
<button
|
||
onClick={() => openNew()}
|
||
title="Zeit buchen (N)"
|
||
aria-label="Zeit buchen"
|
||
className="animate-glow flex h-11 w-11 items-center justify-center rounded-full bg-brand-500 text-white shadow-lift transition hover:scale-105 hover:bg-brand-600 active:scale-95"
|
||
>
|
||
<Icon name="plus" className="h-6 w-6" strokeWidth={2.6} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{readOnly && viewedUser && (
|
||
<div className="animate-fade mb-4 flex items-center gap-3 rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm font-semibold text-sky-700">
|
||
<Avatar name={viewedUser.name} color={viewedUser.color} avatar={viewedUser.avatar} id={viewedUser.id} size={28} />
|
||
Du siehst die Zeiten von {viewedUser.name} (nur lesen).
|
||
<button className="ml-auto text-xs font-bold underline" onClick={() => setViewUserId(user!.id)}>
|
||
Zurück zu meinen Zeiten
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{!readOnly && warnings.length > 0 && (
|
||
<div className="animate-fade mb-4 space-y-2">
|
||
{warnings.slice(0, 3).map(({ project, pot }) => (
|
||
<div key={pot.id} className="flex items-start gap-3 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-2.5 text-xs font-semibold text-amber-700">
|
||
<Icon name="warning" className="mt-0.5 h-4 w-4 shrink-0" />
|
||
<span>
|
||
<b>{project.display_name}</b> – {pot.warning}
|
||
{pot.remaining_hours != null && ` Noch ${pot.remaining_hours.toLocaleString('de-DE')} h frei.`}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<SummaryStrip total={total} billable={billable} target={target} weekly={weekly} mode={mode} count={list.length} />
|
||
|
||
{entries.error && <ErrorBox message={entries.error} onRetry={entries.reload} />}
|
||
{entries.loading && !entries.data ? (
|
||
<Spinner />
|
||
) : (
|
||
<div key={`${layout}-${mode}-${range.from}`} className="animate-fade">
|
||
{layout === 'list' && (
|
||
<Card className="overflow-hidden">
|
||
<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 })} onCopy={copy} />
|
||
)}
|
||
{layout === 'calendar' && mode === 'month' && (
|
||
<MonthView
|
||
month={range.from}
|
||
entries={list}
|
||
dailyTarget={(weekly / 5) * 60}
|
||
onOpenDay={(date) => {
|
||
setAnchor(date);
|
||
setMode('week');
|
||
}}
|
||
onAdd={openNew}
|
||
readOnly={readOnly}
|
||
/>
|
||
)}
|
||
{layout === 'calendar' && mode === 'year' && (
|
||
<YearView
|
||
year={anchor.slice(0, 4)}
|
||
entries={list}
|
||
weeklyTarget={weekly}
|
||
onOpenMonth={(month) => {
|
||
setAnchor(`${month}-01`);
|
||
setMode('month');
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{isMobile && !readOnly && (
|
||
<button
|
||
onClick={() => openNew()}
|
||
aria-label="Zeit buchen"
|
||
className="animate-glow fixed bottom-24 left-1/2 z-40 flex -translate-x-1/2 h-16 w-16 items-center justify-center rounded-full bg-brand-500 text-white shadow-lift transition active:scale-95"
|
||
>
|
||
<Icon name="plus" className="h-8 w-8" strokeWidth={2.6} />
|
||
</button>
|
||
)}
|
||
|
||
{modal && projects.data && (
|
||
<EntryModal
|
||
projects={projects.data}
|
||
date={modal.date}
|
||
entry={modal.entry}
|
||
onClose={() => setModal(null)}
|
||
onSaved={() => { entries.reload(); projects.reload(); }}
|
||
onDuplicate={copy}
|
||
/>
|
||
)}
|
||
{copyEntry && projects.data && (
|
||
<CopyDialog
|
||
entry={copyEntry}
|
||
projects={projects.data}
|
||
onClose={() => setCopyEntry(null)}
|
||
onSaved={() => { entries.reload(); projects.reload(); }}
|
||
/>
|
||
)}
|
||
{exportOpen && (
|
||
<ExportDialog
|
||
month={anchor.slice(0, 7)}
|
||
projects={projects.data ?? []}
|
||
team={team}
|
||
viewUserId={viewUserId}
|
||
onClose={() => setExportOpen(false)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const SummaryStrip: React.FC<{ total: number; billable: number; target: number; weekly: number; mode: BookingMode; count: number }> = ({
|
||
total,
|
||
billable,
|
||
target,
|
||
weekly,
|
||
mode,
|
||
count,
|
||
}) => {
|
||
const shown = useCountUp(total);
|
||
const percent = target > 0 ? Math.round((total / target) * 100) : 0;
|
||
const diff = total - target;
|
||
return (
|
||
<Card className="animate-rise mb-4 flex flex-wrap items-center gap-4 p-4 sm:gap-6 sm:p-5">
|
||
<Donut percent={Math.min(100, percent)} size={isFinite(percent) ? 76 : 76} stroke={9} track="var(--color-ink-100)" label="Soll" />
|
||
<div className="min-w-[120px]">
|
||
<p className="text-[11px] font-extrabold uppercase tracking-[0.14em] text-ink-400">
|
||
Gebucht {mode === 'week' ? 'diese Woche' : mode === 'month' ? 'im Monat' : 'im Jahr'}
|
||
</p>
|
||
<p className="tabular text-3xl font-extrabold text-ink-900">
|
||
{formatDuration(shown)}
|
||
<span className="ml-1 text-base text-ink-400">h</span>
|
||
</p>
|
||
</div>
|
||
<div className="grid w-full grid-cols-3 gap-2 sm:w-auto sm:max-w-xl sm:flex-1 sm:gap-3">
|
||
<Mini label="Soll bis heute" value={`${formatDuration(target)} h`} hint={`${weekly} h/Woche`} />
|
||
<Mini label="Differenz" value={`${diff >= 0 ? '+' : '−'}${formatDuration(Math.abs(diff))} h`} tone={diff >= 0 ? 'good' : 'bad'} />
|
||
<Mini label="Billable" value={total ? `${Math.round((billable / total) * 100)} %` : '–'} hint={`${count} Buchungen`} />
|
||
</div>
|
||
</Card>
|
||
);
|
||
};
|
||
|
||
const Mini: React.FC<{ label: string; value: string; hint?: string; tone?: 'good' | 'bad' }> = ({ label, value, hint, tone }) => (
|
||
<div className="min-w-0 rounded-xl bg-ink-50 px-3 py-2">
|
||
<p className="truncate text-[10px] font-bold uppercase tracking-wider text-ink-400">{label}</p>
|
||
<p className={`tabular truncate text-sm font-extrabold sm:text-base ${tone === 'good' ? 'text-brand-700' : tone === 'bad' ? 'text-red-600' : 'text-ink-900'}`}>{value}</p>
|
||
{hint && <p className="truncate text-[10px] text-ink-400">{hint}</p>}
|
||
</div>
|
||
);
|