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('pulse_booking_mode', 'week'); const [layout, setLayout] = usePersistentState('pulse_booking_layout', 'calendar'); const [anchor, setAnchor] = useState(todayIso()); const [modal, setModal] = useState<{ date: string; entry?: TimeEntry | null } | null>(null); const [copyEntry, setCopyEntry] = useState(null); const [exportOpen, setExportOpen] = useState(false); const [viewUserId, setViewUserId] = useState(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([]); 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 (
{/* Werkzeugleiste */}

{label}

{isLead && team.length > 1 && ( )} {!isMobile && !readOnly && ( )}
{readOnly && viewedUser && (
Du siehst die Zeiten von {viewedUser.name} (nur lesen).
)} {!readOnly && warnings.length > 0 && (
{warnings.slice(0, 3).map(({ project, pot }) => (
{project.display_name} – {pot.warning} {pot.remaining_hours != null && ` Noch ${pot.remaining_hours.toLocaleString('de-DE')} h frei.`}
))}
)} {entries.error && } {entries.loading && !entries.data ? ( ) : (
{layout === 'list' && ( 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'}.`} /> )} {layout === 'calendar' && mode === 'week' && ( !readOnly && setModal({ date: entry.entry_date, entry })} onCopy={copy} /> )} {layout === 'calendar' && mode === 'month' && ( { setAnchor(date); setMode('week'); }} onAdd={openNew} readOnly={readOnly} /> )} {layout === 'calendar' && mode === 'year' && ( { setAnchor(`${month}-01`); setMode('month'); }} /> )}
)} {isMobile && !readOnly && ( )} {modal && projects.data && ( setModal(null)} onSaved={() => { entries.reload(); projects.reload(); }} onDuplicate={copy} /> )} {copyEntry && projects.data && ( setCopyEntry(null)} onSaved={() => { entries.reload(); projects.reload(); }} /> )} {exportOpen && ( setExportOpen(false)} /> )}
); }; 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 (

Gebucht {mode === 'week' ? 'diese Woche' : mode === 'month' ? 'im Monat' : 'im Jahr'}

{formatDuration(shown)} h

= 0 ? '+' : '−'}${formatDuration(Math.abs(diff))} h`} tone={diff >= 0 ? 'good' : 'bad'} />
); }; const Mini: React.FC<{ label: string; value: string; hint?: string; tone?: 'good' | 'bad' }> = ({ label, value, hint, tone }) => (

{label}

{value}

{hint &&

{hint}

}
);