Files
perinco-Pulse/src/components/booking/Booking.tsx
T
schakal00783andClaude Opus 5.5 5275f7c03d fix(buchung): Plus-Button mobil unten mittig
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>
2026-09-25 13:55:50 +02:00

355 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);