1311 lines
45 KiB
TypeScript
1311 lines
45 KiB
TypeScript
import {
|
||
ChevronLeftIcon,
|
||
ChevronRightIcon,
|
||
ClockIcon,
|
||
MapPinIcon,
|
||
PlusIcon,
|
||
} from '@heroicons/react/20/solid'
|
||
import { useEffect, useRef, useState, type PointerEvent } from 'react'
|
||
import ButtonGroup from '../ButtonGroup'
|
||
import {
|
||
timeToMinutes,
|
||
workingHoursForDate,
|
||
type CalendarCoreWorkingHours,
|
||
} from './calendarSettings'
|
||
import { toDateKey } from './dateUtils'
|
||
|
||
export type CalendarView = 'month' | 'week' | 'day' | 'year'
|
||
export type CalendarEventColor =
|
||
| 'indigo'
|
||
| 'blue'
|
||
| 'emerald'
|
||
| 'amber'
|
||
| 'rose'
|
||
| 'slate'
|
||
|
||
export type CalendarEvent = {
|
||
id: string
|
||
title: string
|
||
start: string
|
||
end: string
|
||
allDay: boolean
|
||
location: string
|
||
description: string
|
||
color: CalendarEventColor
|
||
}
|
||
|
||
type CalendarProps = {
|
||
events: CalendarEvent[]
|
||
coreWorkingHours: CalendarCoreWorkingHours
|
||
selectedDate: Date
|
||
view: CalendarView
|
||
onSelectedDateChange: (date: Date) => void
|
||
onViewChange: (view: CalendarView) => void
|
||
onCreateEvent: (start: Date, end?: Date, allDay?: boolean) => void
|
||
onEditEvent: (event: CalendarEvent) => void
|
||
}
|
||
|
||
const weekdayLabels = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||
|
||
const viewLabels: Record<CalendarView, string> = {
|
||
day: 'Tag',
|
||
week: 'Woche',
|
||
month: 'Monat',
|
||
year: 'Jahr',
|
||
}
|
||
|
||
const viewOptions = (Object.keys(viewLabels) as CalendarView[]).map((view) => ({
|
||
id: view,
|
||
label: viewLabels[view],
|
||
}))
|
||
|
||
const eventColors: Record<
|
||
CalendarEventColor,
|
||
{ card: string; dot: string; text: string }
|
||
> = {
|
||
indigo: {
|
||
card:
|
||
'bg-indigo-50 text-indigo-700 ring-indigo-200 hover:bg-indigo-100 dark:bg-indigo-500/15 dark:text-indigo-200 dark:ring-indigo-400/20 dark:hover:bg-indigo-500/25',
|
||
dot: 'bg-indigo-500',
|
||
text: 'text-indigo-600 dark:text-indigo-300',
|
||
},
|
||
blue: {
|
||
card:
|
||
'bg-blue-50 text-blue-700 ring-blue-200 hover:bg-blue-100 dark:bg-blue-500/15 dark:text-blue-200 dark:ring-blue-400/20 dark:hover:bg-blue-500/25',
|
||
dot: 'bg-blue-500',
|
||
text: 'text-blue-600 dark:text-blue-300',
|
||
},
|
||
emerald: {
|
||
card:
|
||
'bg-emerald-50 text-emerald-700 ring-emerald-200 hover:bg-emerald-100 dark:bg-emerald-500/15 dark:text-emerald-200 dark:ring-emerald-400/20 dark:hover:bg-emerald-500/25',
|
||
dot: 'bg-emerald-500',
|
||
text: 'text-emerald-600 dark:text-emerald-300',
|
||
},
|
||
amber: {
|
||
card:
|
||
'bg-amber-50 text-amber-800 ring-amber-200 hover:bg-amber-100 dark:bg-amber-500/15 dark:text-amber-200 dark:ring-amber-400/20 dark:hover:bg-amber-500/25',
|
||
dot: 'bg-amber-500',
|
||
text: 'text-amber-600 dark:text-amber-300',
|
||
},
|
||
rose: {
|
||
card:
|
||
'bg-rose-50 text-rose-700 ring-rose-200 hover:bg-rose-100 dark:bg-rose-500/15 dark:text-rose-200 dark:ring-rose-400/20 dark:hover:bg-rose-500/25',
|
||
dot: 'bg-rose-500',
|
||
text: 'text-rose-600 dark:text-rose-300',
|
||
},
|
||
slate: {
|
||
card:
|
||
'bg-slate-100 text-slate-700 ring-slate-200 hover:bg-slate-200 dark:bg-white/10 dark:text-slate-200 dark:ring-white/10 dark:hover:bg-white/15',
|
||
dot: 'bg-slate-500',
|
||
text: 'text-slate-600 dark:text-slate-300',
|
||
},
|
||
}
|
||
|
||
function classNames(...classes: Array<string | false | null | undefined>) {
|
||
return classes.filter(Boolean).join(' ')
|
||
}
|
||
|
||
function startOfDay(date: Date) {
|
||
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||
}
|
||
|
||
function addDays(date: Date, amount: number) {
|
||
const next = new Date(date)
|
||
next.setDate(next.getDate() + amount)
|
||
return startOfDay(next)
|
||
}
|
||
|
||
function addMonths(date: Date, amount: number) {
|
||
const next = new Date(date.getFullYear(), date.getMonth() + amount, 1)
|
||
return startOfDay(next)
|
||
}
|
||
|
||
function addYears(date: Date, amount: number) {
|
||
return new Date(date.getFullYear() + amount, date.getMonth(), date.getDate())
|
||
}
|
||
|
||
function startOfWeek(date: Date) {
|
||
const normalized = startOfDay(date)
|
||
const mondayOffset = (normalized.getDay() + 6) % 7
|
||
return addDays(normalized, -mondayOffset)
|
||
}
|
||
|
||
function isSameDay(left: Date, right: Date) {
|
||
return toDateKey(left) === toDateKey(right)
|
||
}
|
||
|
||
function isToday(date: Date) {
|
||
return isSameDay(date, new Date())
|
||
}
|
||
|
||
function eventsForDay(events: CalendarEvent[], date: Date) {
|
||
const dayStart = startOfDay(date)
|
||
const dayEnd = addDays(dayStart, 1)
|
||
|
||
return events
|
||
.filter((event) => {
|
||
const eventStart = new Date(event.start)
|
||
const eventEnd = new Date(event.end)
|
||
return eventStart < dayEnd && eventEnd > dayStart
|
||
})
|
||
.sort(
|
||
(left, right) =>
|
||
Number(right.allDay) - Number(left.allDay) ||
|
||
new Date(left.start).getTime() - new Date(right.start).getTime(),
|
||
)
|
||
}
|
||
|
||
function formatTime(value: string | Date) {
|
||
return new Intl.DateTimeFormat('de-DE', {
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
}).format(typeof value === 'string' ? new Date(value) : value)
|
||
}
|
||
|
||
function dateAtMinutes(date: Date, minutes: number) {
|
||
const result = startOfDay(date)
|
||
result.setMinutes(minutes)
|
||
return result
|
||
}
|
||
|
||
function addMinutes(date: Date, minutes: number) {
|
||
return new Date(date.getTime() + minutes * 60_000)
|
||
}
|
||
|
||
function normalizeTimeRange(anchor: Date, focus: Date) {
|
||
const start = anchor <= focus ? anchor : focus
|
||
const lastSlot = anchor <= focus ? focus : anchor
|
||
return {
|
||
start,
|
||
end: addMinutes(lastSlot, 30),
|
||
}
|
||
}
|
||
|
||
function formatSlotTime(minutes: number) {
|
||
return `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(
|
||
minutes % 60,
|
||
).padStart(2, '0')}`
|
||
}
|
||
|
||
function titleForView(date: Date, view: CalendarView) {
|
||
if (view === 'day') {
|
||
return new Intl.DateTimeFormat('de-DE', {
|
||
weekday: 'long',
|
||
day: '2-digit',
|
||
month: 'long',
|
||
year: 'numeric',
|
||
}).format(date)
|
||
}
|
||
|
||
if (view === 'week') {
|
||
const first = startOfWeek(date)
|
||
const last = addDays(first, 6)
|
||
const firstLabel = new Intl.DateTimeFormat('de-DE', {
|
||
day: '2-digit',
|
||
month: first.getMonth() === last.getMonth() ? undefined : 'short',
|
||
}).format(first)
|
||
const lastLabel = new Intl.DateTimeFormat('de-DE', {
|
||
day: '2-digit',
|
||
month: 'short',
|
||
year: 'numeric',
|
||
}).format(last)
|
||
return `${firstLabel} - ${lastLabel}`
|
||
}
|
||
|
||
return new Intl.DateTimeFormat('de-DE', {
|
||
month: view === 'month' ? 'long' : undefined,
|
||
year: 'numeric',
|
||
}).format(date)
|
||
}
|
||
|
||
function MonthView({
|
||
events,
|
||
selectedDate,
|
||
onSelectedDateChange,
|
||
onCreateEvent,
|
||
onEditEvent,
|
||
onOpenDay,
|
||
}: Omit<CalendarProps, 'view' | 'onViewChange' | 'coreWorkingHours'> & {
|
||
onOpenDay: (date: Date) => void
|
||
}) {
|
||
const monthStart = new Date(
|
||
selectedDate.getFullYear(),
|
||
selectedDate.getMonth(),
|
||
1,
|
||
)
|
||
const gridStart = startOfWeek(monthStart)
|
||
const days = Array.from({ length: 42 }, (_, index) =>
|
||
addDays(gridStart, index),
|
||
)
|
||
const selectedEvents = eventsForDay(events, selectedDate)
|
||
|
||
const gridRef = useRef<HTMLDivElement>(null)
|
||
const dragRef = useRef<{
|
||
pointerId: number
|
||
anchorIndex: number
|
||
focusIndex: number
|
||
} | null>(null)
|
||
const [daySelection, setDaySelection] = useState<{
|
||
startIndex: number
|
||
endIndex: number
|
||
} | null>(null)
|
||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
|
||
|
||
// Spiegel der aktuellen Props für den nativen Wheel-Listener, der nur einmal
|
||
// registriert wird und sonst veraltete Werte einfangen würde.
|
||
const selectedDateRef = useRef(selectedDate)
|
||
selectedDateRef.current = selectedDate
|
||
const onSelectedDateChangeRef = useRef(onSelectedDateChange)
|
||
onSelectedDateChangeRef.current = onSelectedDateChange
|
||
|
||
// Mausrad über dem Raster wechselt den Monat. Die 250-ms-Sperre verhindert,
|
||
// dass ein einzelner Scroll-Schub (Trackpad-Momentum) mehrere Monate springt.
|
||
useEffect(() => {
|
||
const grid = gridRef.current
|
||
if (!grid) {
|
||
return
|
||
}
|
||
|
||
let lastSwitch = 0
|
||
function handleWheel(event: WheelEvent) {
|
||
if (
|
||
event.deltaY === 0 ||
|
||
Math.abs(event.deltaX) > Math.abs(event.deltaY)
|
||
) {
|
||
return
|
||
}
|
||
|
||
event.preventDefault()
|
||
const now = Date.now()
|
||
if (now - lastSwitch < 250) {
|
||
return
|
||
}
|
||
|
||
lastSwitch = now
|
||
onSelectedDateChangeRef.current(
|
||
addMonths(selectedDateRef.current, event.deltaY > 0 ? 1 : -1),
|
||
)
|
||
}
|
||
|
||
grid.addEventListener('wheel', handleWheel, { passive: false })
|
||
return () => grid.removeEventListener('wheel', handleWheel)
|
||
}, [])
|
||
|
||
function getDayIndexFromPointer(
|
||
clientX: number,
|
||
clientY: number,
|
||
clampToGrid = false,
|
||
) {
|
||
const grid = gridRef.current
|
||
if (!grid) {
|
||
return null
|
||
}
|
||
|
||
const bounds = grid.getBoundingClientRect()
|
||
const rawX = clientX - bounds.left
|
||
const rawY = clientY - bounds.top
|
||
|
||
if (
|
||
!clampToGrid &&
|
||
(rawX < 0 || rawX >= bounds.width || rawY < 0 || rawY >= bounds.height)
|
||
) {
|
||
return null
|
||
}
|
||
|
||
const relativeX = Math.min(bounds.width - 1, Math.max(0, rawX))
|
||
const relativeY = Math.min(bounds.height - 1, Math.max(0, rawY))
|
||
const column = Math.min(6, Math.floor((relativeX / bounds.width) * 7))
|
||
const row = Math.min(5, Math.floor((relativeY / bounds.height) * 6))
|
||
|
||
return row * 7 + column
|
||
}
|
||
|
||
function handleGridPointerDown(event: PointerEvent<HTMLDivElement>) {
|
||
if (event.button !== 0) {
|
||
return
|
||
}
|
||
|
||
if (
|
||
event.target instanceof HTMLElement &&
|
||
event.target.closest('[data-calendar-event], [data-calendar-skip-drag]')
|
||
) {
|
||
return
|
||
}
|
||
|
||
const index = getDayIndexFromPointer(event.clientX, event.clientY)
|
||
if (index === null || !gridRef.current) {
|
||
return
|
||
}
|
||
|
||
event.preventDefault()
|
||
dragRef.current = {
|
||
pointerId: event.pointerId,
|
||
anchorIndex: index,
|
||
focusIndex: index,
|
||
}
|
||
setHoveredIndex(null)
|
||
setDaySelection({ startIndex: index, endIndex: index })
|
||
gridRef.current.setPointerCapture(event.pointerId)
|
||
}
|
||
|
||
function handleGridPointerMove(event: PointerEvent<HTMLDivElement>) {
|
||
const drag = dragRef.current
|
||
|
||
if (drag) {
|
||
const index = getDayIndexFromPointer(event.clientX, event.clientY, true)
|
||
if (index === null || index === drag.focusIndex) {
|
||
return
|
||
}
|
||
|
||
drag.focusIndex = index
|
||
setDaySelection({
|
||
startIndex: Math.min(drag.anchorIndex, index),
|
||
endIndex: Math.max(drag.anchorIndex, index),
|
||
})
|
||
return
|
||
}
|
||
|
||
if (event.pointerType !== 'mouse') {
|
||
return
|
||
}
|
||
|
||
const index = getDayIndexFromPointer(event.clientX, event.clientY)
|
||
setHoveredIndex((current) => (current === index ? current : index))
|
||
}
|
||
|
||
function finishGridSelection(event: PointerEvent<HTMLDivElement>) {
|
||
const drag = dragRef.current
|
||
if (!drag) {
|
||
return
|
||
}
|
||
|
||
const index =
|
||
getDayIndexFromPointer(event.clientX, event.clientY, true) ??
|
||
drag.focusIndex
|
||
const startIndex = Math.min(drag.anchorIndex, index)
|
||
const endIndex = Math.max(drag.anchorIndex, index)
|
||
|
||
if (gridRef.current?.hasPointerCapture(drag.pointerId)) {
|
||
gridRef.current.releasePointerCapture(drag.pointerId)
|
||
}
|
||
|
||
dragRef.current = null
|
||
setDaySelection(null)
|
||
|
||
if (startIndex === endIndex) {
|
||
onOpenDay(days[startIndex])
|
||
return
|
||
}
|
||
|
||
onSelectedDateChange(days[startIndex])
|
||
onCreateEvent(days[startIndex], days[endIndex], true)
|
||
}
|
||
|
||
function cancelGridSelection() {
|
||
const drag = dragRef.current
|
||
|
||
if (drag && gridRef.current?.hasPointerCapture(drag.pointerId)) {
|
||
gridRef.current.releasePointerCapture(drag.pointerId)
|
||
}
|
||
|
||
dragRef.current = null
|
||
setDaySelection(null)
|
||
}
|
||
|
||
return (
|
||
<div className="flex min-h-0 flex-1 flex-col">
|
||
<div className="grid grid-cols-7 border-y border-gray-200 bg-gray-50 text-center text-xs font-semibold text-gray-600 dark:border-white/10 dark:bg-white/5 dark:text-gray-300">
|
||
{weekdayLabels.map((weekday) => (
|
||
<div key={weekday} className="py-2">
|
||
{weekday}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div
|
||
ref={gridRef}
|
||
onPointerDown={handleGridPointerDown}
|
||
onPointerMove={handleGridPointerMove}
|
||
onPointerUp={finishGridSelection}
|
||
onPointerCancel={cancelGridSelection}
|
||
onPointerLeave={() => {
|
||
if (!dragRef.current) {
|
||
setHoveredIndex(null)
|
||
}
|
||
}}
|
||
className="grid min-h-[32rem] flex-1 grid-cols-7 grid-rows-6 gap-px bg-gray-200 select-none dark:bg-white/10"
|
||
>
|
||
{days.map((day, index) => {
|
||
const dayEvents = eventsForDay(events, day)
|
||
const currentMonth = day.getMonth() === selectedDate.getMonth()
|
||
const selected = isSameDay(day, selectedDate)
|
||
const inSelection =
|
||
daySelection !== null &&
|
||
index >= daySelection.startIndex &&
|
||
index <= daySelection.endIndex
|
||
|
||
return (
|
||
<div
|
||
key={toDateKey(day)}
|
||
role="button"
|
||
tabIndex={0}
|
||
className={classNames(
|
||
'group relative min-h-20 cursor-pointer bg-white p-1.5 text-gray-900 shadow-[inset_0_1px_0_rgb(255_255_255)] outline-none transition hover:bg-indigo-50/40 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 sm:min-h-24 sm:p-2 dark:bg-gray-900 dark:text-white dark:shadow-none dark:hover:bg-indigo-500/5',
|
||
!currentMonth &&
|
||
'bg-gray-100/70 text-gray-300 opacity-55 saturate-50 hover:bg-gray-100 dark:bg-gray-950/70 dark:text-gray-600 dark:opacity-45 dark:hover:bg-gray-950',
|
||
selected && 'relative ring-2 ring-inset ring-indigo-500',
|
||
inSelection && 'relative z-0 ring-2 ring-inset ring-indigo-400',
|
||
)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter' || event.key === ' ') {
|
||
event.preventDefault()
|
||
onOpenDay(day)
|
||
}
|
||
}}
|
||
>
|
||
{inSelection && (
|
||
<span
|
||
aria-hidden="true"
|
||
className="pointer-events-none absolute inset-0 -z-10 bg-indigo-500/20 dark:bg-indigo-400/15"
|
||
/>
|
||
)}
|
||
<div className="flex items-center">
|
||
{daySelection === null && hoveredIndex === index && (
|
||
<button
|
||
type="button"
|
||
data-calendar-skip-drag
|
||
onPointerDown={(pointerEvent) =>
|
||
pointerEvent.stopPropagation()
|
||
}
|
||
onClick={(clickEvent) => {
|
||
clickEvent.stopPropagation()
|
||
onCreateEvent(day)
|
||
}}
|
||
className="hidden min-w-0 items-center gap-1 truncate rounded border border-dashed border-indigo-400 bg-indigo-50/95 px-1.5 py-0.5 text-[11px] font-medium text-indigo-600 shadow-sm transition hover:bg-indigo-100 sm:flex dark:border-indigo-400/60 dark:bg-indigo-500/25 dark:text-indigo-100 dark:hover:bg-indigo-500/40"
|
||
>
|
||
<PlusIcon className="size-3 shrink-0" />
|
||
<span className="truncate">Neuer Termin</span>
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={() => onOpenDay(day)}
|
||
className={classNames(
|
||
'ml-auto flex size-7 shrink-0 items-center justify-center rounded-full text-sm font-medium',
|
||
isToday(day)
|
||
? 'bg-indigo-600 text-white dark:bg-indigo-500'
|
||
: selected
|
||
? 'bg-gray-900 text-white dark:bg-white dark:text-gray-900'
|
||
: currentMonth
|
||
? 'font-semibold text-gray-900 hover:bg-indigo-50 hover:text-indigo-700 dark:text-white dark:hover:bg-indigo-500/15 dark:hover:text-indigo-300'
|
||
: 'text-gray-400 hover:bg-gray-200/70 dark:text-gray-600 dark:hover:bg-white/5',
|
||
)}
|
||
>
|
||
{day.getDate()}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="mt-1 hidden space-y-1 sm:block">
|
||
{dayEvents.slice(0, 3).map((event) => (
|
||
<button
|
||
key={event.id}
|
||
type="button"
|
||
data-calendar-event
|
||
onPointerDown={(pointerEvent) =>
|
||
pointerEvent.stopPropagation()
|
||
}
|
||
onClick={(clickEvent) => {
|
||
clickEvent.stopPropagation()
|
||
onSelectedDateChange(day)
|
||
onEditEvent(event)
|
||
}}
|
||
onDoubleClick={(doubleClickEvent) =>
|
||
doubleClickEvent.stopPropagation()
|
||
}
|
||
className={classNames(
|
||
'flex w-full items-center gap-1 truncate rounded px-1.5 py-0.5 text-left text-xs font-medium ring-1 ring-inset',
|
||
eventColors[event.color].card,
|
||
!currentMonth && 'opacity-45 grayscale',
|
||
)}
|
||
>
|
||
<span className="truncate">{event.title}</span>
|
||
{!event.allDay && (
|
||
<span className="ml-auto hidden shrink-0 xl:inline">
|
||
{formatTime(event.start)}
|
||
</span>
|
||
)}
|
||
</button>
|
||
))}
|
||
{dayEvents.length > 3 && (
|
||
<button
|
||
type="button"
|
||
data-calendar-skip-drag
|
||
onClick={() => onOpenDay(day)}
|
||
className="px-1 text-xs font-medium text-gray-500 hover:text-indigo-600 dark:text-gray-400 dark:hover:text-indigo-300"
|
||
>
|
||
+ {dayEvents.length - 3} weitere
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{dayEvents.length > 0 && (
|
||
<div className="mt-auto flex flex-wrap justify-end gap-1 pt-2 sm:hidden">
|
||
{dayEvents.slice(0, 4).map((event) => (
|
||
<span
|
||
key={event.id}
|
||
className={classNames(
|
||
'size-1.5 rounded-full',
|
||
eventColors[event.color].dot,
|
||
)}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
<div className="border-t border-gray-200 bg-white p-4 sm:hidden dark:border-white/10 dark:bg-gray-900">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||
{new Intl.DateTimeFormat('de-DE', {
|
||
weekday: 'long',
|
||
day: '2-digit',
|
||
month: 'long',
|
||
}).format(selectedDate)}
|
||
</h3>
|
||
<button
|
||
type="button"
|
||
onClick={() => onCreateEvent(selectedDate)}
|
||
className="inline-flex size-8 items-center justify-center rounded-full bg-indigo-600 text-white"
|
||
>
|
||
<PlusIcon className="size-4" />
|
||
</button>
|
||
</div>
|
||
{selectedEvents.length === 0 ? (
|
||
<p className="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||
Keine Termine an diesem Tag.
|
||
</p>
|
||
) : (
|
||
<ol className="mt-3 space-y-2">
|
||
{selectedEvents.map((event) => (
|
||
<li key={event.id}>
|
||
<EventListButton event={event} onClick={onEditEvent} />
|
||
</li>
|
||
))}
|
||
</ol>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function EventListButton({
|
||
event,
|
||
onClick,
|
||
}: {
|
||
event: CalendarEvent
|
||
onClick: (event: CalendarEvent) => void
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => onClick(event)}
|
||
className={classNames(
|
||
'flex w-full items-start gap-3 rounded-xl p-3 text-left ring-1 ring-inset transition',
|
||
eventColors[event.color].card,
|
||
)}
|
||
>
|
||
<span
|
||
className={classNames(
|
||
'mt-1 size-2 shrink-0 rounded-full',
|
||
eventColors[event.color].dot,
|
||
)}
|
||
/>
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block truncate text-sm font-semibold">
|
||
{event.title}
|
||
</span>
|
||
<span className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs opacity-80">
|
||
<span className="inline-flex items-center gap-1">
|
||
<ClockIcon className="size-3.5" />
|
||
{event.allDay
|
||
? 'Ganztägig'
|
||
: `${formatTime(event.start)} - ${formatTime(event.end)}`}
|
||
</span>
|
||
{event.location && (
|
||
<span className="inline-flex items-center gap-1 truncate">
|
||
<MapPinIcon className="size-3.5" />
|
||
{event.location}
|
||
</span>
|
||
)}
|
||
</span>
|
||
</span>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function TimeGridView({
|
||
events,
|
||
coreWorkingHours,
|
||
selectedDate,
|
||
days,
|
||
onSelectedDateChange,
|
||
onCreateEvent,
|
||
onEditEvent,
|
||
}: Omit<CalendarProps, 'view' | 'onViewChange'> & { days: Date[] }) {
|
||
const timeGridRef = useRef<HTMLDivElement>(null)
|
||
const interactionRef = useRef<{
|
||
pointerId: number
|
||
anchor: Date
|
||
focus: Date
|
||
} | null>(null)
|
||
const [hoveredSlot, setHoveredSlot] = useState<{
|
||
dayKey: string
|
||
minutes: number
|
||
} | null>(null)
|
||
const [timeSelection, setTimeSelection] = useState<{
|
||
anchor: Date
|
||
focus: Date
|
||
} | null>(null)
|
||
const hours = Array.from({ length: 24 }, (_, index) => index)
|
||
const allDayEvents = days.flatMap((day) =>
|
||
eventsForDay(events, day).filter((event) => event.allDay),
|
||
)
|
||
const selectedRange = timeSelection
|
||
? normalizeTimeRange(timeSelection.anchor, timeSelection.focus)
|
||
: null
|
||
const gridTemplateColumns = `3.5rem repeat(${days.length}, minmax(7rem, 1fr))`
|
||
const showHoverSlotTopEdge = days.length === 1
|
||
|
||
function getSlotFromPointer(
|
||
clientX: number,
|
||
clientY: number,
|
||
clampToGrid = false,
|
||
) {
|
||
const grid = timeGridRef.current
|
||
if (!grid) {
|
||
return null
|
||
}
|
||
|
||
const bounds = grid.getBoundingClientRect()
|
||
const timeGutterWidth = 56
|
||
const daysWidth = bounds.width - timeGutterWidth
|
||
const rawX = clientX - bounds.left - timeGutterWidth
|
||
const rawY = clientY - bounds.top
|
||
|
||
if (
|
||
!clampToGrid &&
|
||
(rawX < 0 || rawX >= daysWidth || rawY < 0 || rawY >= bounds.height)
|
||
) {
|
||
return null
|
||
}
|
||
|
||
const relativeX = Math.min(daysWidth - 1, Math.max(0, rawX))
|
||
const relativeY = Math.min(bounds.height - 1, Math.max(0, rawY))
|
||
const dayIndex = Math.min(
|
||
days.length - 1,
|
||
Math.floor((relativeX / daysWidth) * days.length),
|
||
)
|
||
const minutes =
|
||
Math.min(47, Math.floor((relativeY / bounds.height) * 48)) * 30
|
||
|
||
return dateAtMinutes(days[dayIndex], minutes)
|
||
}
|
||
|
||
function handleTimeGridPointerDown(event: PointerEvent<HTMLDivElement>) {
|
||
if (event.button !== 0) {
|
||
return
|
||
}
|
||
|
||
if (
|
||
event.target instanceof HTMLElement &&
|
||
event.target.closest('[data-calendar-event]')
|
||
) {
|
||
return
|
||
}
|
||
|
||
const slot = getSlotFromPointer(event.clientX, event.clientY)
|
||
if (!slot || !timeGridRef.current) {
|
||
return
|
||
}
|
||
|
||
event.preventDefault()
|
||
interactionRef.current = {
|
||
pointerId: event.pointerId,
|
||
anchor: slot,
|
||
focus: slot,
|
||
}
|
||
setHoveredSlot(null)
|
||
setTimeSelection({ anchor: slot, focus: slot })
|
||
timeGridRef.current.setPointerCapture(event.pointerId)
|
||
}
|
||
|
||
function handleTimeGridPointerMove(event: PointerEvent<HTMLDivElement>) {
|
||
const interaction = interactionRef.current
|
||
const slot = getSlotFromPointer(
|
||
event.clientX,
|
||
event.clientY,
|
||
Boolean(interaction),
|
||
)
|
||
|
||
if (!slot) {
|
||
setHoveredSlot(null)
|
||
return
|
||
}
|
||
|
||
if (interaction) {
|
||
interaction.focus = slot
|
||
setTimeSelection({
|
||
anchor: interaction.anchor,
|
||
focus: slot,
|
||
})
|
||
return
|
||
}
|
||
|
||
setHoveredSlot({
|
||
dayKey: toDateKey(slot),
|
||
minutes: slot.getHours() * 60 + slot.getMinutes(),
|
||
})
|
||
}
|
||
|
||
function finishTimeGridSelection(event: PointerEvent<HTMLDivElement>) {
|
||
const interaction = interactionRef.current
|
||
if (!interaction) {
|
||
return
|
||
}
|
||
|
||
const focus =
|
||
getSlotFromPointer(event.clientX, event.clientY, true) ??
|
||
interaction.focus
|
||
const range = normalizeTimeRange(interaction.anchor, focus)
|
||
|
||
if (timeGridRef.current?.hasPointerCapture(interaction.pointerId)) {
|
||
timeGridRef.current.releasePointerCapture(interaction.pointerId)
|
||
}
|
||
|
||
interactionRef.current = null
|
||
setTimeSelection(null)
|
||
setHoveredSlot(null)
|
||
onSelectedDateChange(range.start)
|
||
onCreateEvent(range.start, range.end)
|
||
}
|
||
|
||
function cancelTimeGridSelection() {
|
||
const interaction = interactionRef.current
|
||
|
||
if (
|
||
interaction &&
|
||
timeGridRef.current?.hasPointerCapture(interaction.pointerId)
|
||
) {
|
||
timeGridRef.current.releasePointerCapture(interaction.pointerId)
|
||
}
|
||
|
||
interactionRef.current = null
|
||
setTimeSelection(null)
|
||
setHoveredSlot(null)
|
||
}
|
||
|
||
return (
|
||
<div className="flex min-h-0 flex-1 flex-col bg-white dark:bg-gray-900">
|
||
<div className="min-h-0 flex-1 overflow-auto">
|
||
<div className="min-w-max">
|
||
<div className="sticky top-0 z-20">
|
||
<div
|
||
className="grid border-b border-gray-200 bg-white dark:border-white/10 dark:bg-gray-900"
|
||
style={{ gridTemplateColumns }}
|
||
>
|
||
<div className="bg-white dark:bg-gray-900" />
|
||
{days.map((day) => (
|
||
<button
|
||
key={toDateKey(day)}
|
||
type="button"
|
||
onClick={() => onSelectedDateChange(day)}
|
||
className={classNames(
|
||
'border-l border-gray-200 px-2 py-3 text-center dark:border-white/10',
|
||
isSameDay(day, selectedDate) &&
|
||
'bg-indigo-50 dark:bg-indigo-500/10',
|
||
)}
|
||
>
|
||
<span className="block text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
|
||
{new Intl.DateTimeFormat('de-DE', {
|
||
weekday: 'short',
|
||
}).format(day)}
|
||
</span>
|
||
<span
|
||
className={classNames(
|
||
'mx-auto mt-1 flex size-8 items-center justify-center rounded-full text-sm font-semibold',
|
||
isToday(day)
|
||
? 'bg-indigo-600 text-white dark:bg-indigo-500'
|
||
: 'text-gray-900 dark:text-white',
|
||
)}
|
||
>
|
||
{day.getDate()}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{allDayEvents.length > 0 && (
|
||
<div
|
||
className="grid border-b border-gray-200 bg-gray-50 dark:border-white/10 dark:bg-white/5"
|
||
style={{ gridTemplateColumns }}
|
||
>
|
||
<div className="px-2 py-2 text-right text-[10px] font-medium uppercase text-gray-400">
|
||
Ganz
|
||
</div>
|
||
{days.map((day) => (
|
||
<div
|
||
key={toDateKey(day)}
|
||
className="min-h-10 space-y-1 border-l border-gray-200 p-1 dark:border-white/10"
|
||
>
|
||
{eventsForDay(events, day)
|
||
.filter((event) => event.allDay)
|
||
.map((event) => (
|
||
<button
|
||
key={event.id}
|
||
type="button"
|
||
onClick={() => onEditEvent(event)}
|
||
className={classNames(
|
||
'w-full truncate rounded px-2 py-1 text-left text-xs font-semibold ring-1 ring-inset',
|
||
eventColors[event.color].card,
|
||
)}
|
||
>
|
||
{event.title}
|
||
</button>
|
||
))}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div
|
||
ref={timeGridRef}
|
||
onPointerDown={handleTimeGridPointerDown}
|
||
onPointerMove={handleTimeGridPointerMove}
|
||
onPointerUp={finishTimeGridSelection}
|
||
onPointerCancel={cancelTimeGridSelection}
|
||
onPointerLeave={() => {
|
||
if (!interactionRef.current) {
|
||
setHoveredSlot(null)
|
||
}
|
||
}}
|
||
className="grid w-full"
|
||
style={{ gridTemplateColumns }}
|
||
>
|
||
<div className="relative h-[96rem]">
|
||
{hours.map((hour) => (
|
||
<div
|
||
key={hour}
|
||
className="absolute right-2 -translate-y-2 text-[11px] text-gray-400 dark:text-gray-500"
|
||
style={{ top: `${(hour / 24) * 100}%` }}
|
||
>
|
||
{String(hour).padStart(2, '0')}:00
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{days.map((day) => {
|
||
const timedEvents = eventsForDay(events, day).filter(
|
||
(event) => !event.allDay,
|
||
)
|
||
const coreHours = workingHoursForDate(coreWorkingHours, day)
|
||
const coreStartMinutes = coreHours
|
||
? timeToMinutes(coreHours.start)
|
||
: 0
|
||
const coreEndMinutes = coreHours
|
||
? timeToMinutes(coreHours.end)
|
||
: 0
|
||
const dayStart = startOfDay(day)
|
||
const dayEnd = addDays(dayStart, 1)
|
||
const selectionStart =
|
||
selectedRange && selectedRange.end > dayStart
|
||
? new Date(
|
||
Math.max(
|
||
selectedRange.start.getTime(),
|
||
dayStart.getTime(),
|
||
),
|
||
)
|
||
: null
|
||
const selectionEnd =
|
||
selectedRange && selectedRange.start < dayEnd
|
||
? new Date(
|
||
Math.min(selectedRange.end.getTime(), dayEnd.getTime()),
|
||
)
|
||
: null
|
||
const hasSelection =
|
||
selectionStart &&
|
||
selectionEnd &&
|
||
selectionEnd > selectionStart
|
||
const selectionStartMinutes = selectionStart
|
||
? (selectionStart.getTime() - dayStart.getTime()) / 60_000
|
||
: 0
|
||
const selectionDurationMinutes =
|
||
selectionStart && selectionEnd
|
||
? (selectionEnd.getTime() - selectionStart.getTime()) / 60_000
|
||
: 0
|
||
|
||
return (
|
||
<div
|
||
key={toDateKey(day)}
|
||
data-calendar-time-column
|
||
className="relative h-[96rem] cursor-crosshair border-l border-gray-200 bg-gray-100/80 dark:border-white/10 dark:bg-gray-950/70"
|
||
>
|
||
{coreHours?.enabled &&
|
||
coreEndMinutes > coreStartMinutes && (
|
||
<div
|
||
aria-label={`Kernarbeitszeit ${coreHours.start} bis ${coreHours.end}`}
|
||
className="pointer-events-none absolute inset-x-0 z-0 border-y border-gray-200 bg-white shadow-[inset_0_1px_0_rgb(255_255_255)] dark:border-white/10 dark:bg-gray-900 dark:shadow-none"
|
||
style={{
|
||
top: `${(coreStartMinutes / 1440) * 100}%`,
|
||
height: `${
|
||
((coreEndMinutes - coreStartMinutes) / 1440) * 100
|
||
}%`,
|
||
}}
|
||
/>
|
||
)}
|
||
<div
|
||
aria-hidden="true"
|
||
className="pointer-events-none absolute inset-0 z-[1]"
|
||
>
|
||
{Array.from({ length: 49 }, (_, index) => (
|
||
<span
|
||
key={index}
|
||
className={classNames(
|
||
'absolute inset-x-0 border-t',
|
||
index % 2 === 0
|
||
? 'border-gray-200 dark:border-white/10'
|
||
: 'border-gray-100 dark:border-white/5',
|
||
)}
|
||
style={{ top: `${(index / 48) * 100}%` }}
|
||
/>
|
||
))}
|
||
</div>
|
||
{hasSelection && (
|
||
<div
|
||
aria-hidden="true"
|
||
className="pointer-events-none absolute right-1 left-1 z-[6] flex items-start overflow-hidden rounded-lg border-2 border-indigo-500 bg-indigo-100/90 px-2 py-1 text-xs font-semibold text-indigo-800 shadow-sm dark:border-indigo-400 dark:bg-indigo-500/30 dark:text-indigo-100"
|
||
style={{
|
||
top: `${(selectionStartMinutes / 1440) * 100}%`,
|
||
height: `${Math.max(
|
||
2.1,
|
||
(selectionDurationMinutes / 1440) * 100,
|
||
)}%`,
|
||
}}
|
||
>
|
||
<span className="truncate">
|
||
{selectionStart &&
|
||
selectionEnd &&
|
||
`${formatTime(selectionStart)} – ${formatTime(
|
||
selectionEnd,
|
||
)}`}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{!timeSelection &&
|
||
hoveredSlot?.dayKey === toDateKey(day) && (
|
||
<div
|
||
aria-hidden="true"
|
||
className={classNames(
|
||
'pointer-events-none absolute right-1 left-1 z-[5] flex items-center gap-1.5 overflow-hidden rounded-lg border border-dashed border-indigo-400 bg-indigo-50/90 px-2 text-xs font-semibold text-indigo-700 shadow-sm dark:border-indigo-400/70 dark:bg-indigo-500/15 dark:text-indigo-200',
|
||
showHoverSlotTopEdge &&
|
||
'before:pointer-events-none before:absolute before:inset-x-0 before:top-0 before:z-10 before:border-t before:border-dashed before:border-indigo-400 dark:before:border-indigo-400/70',
|
||
)}
|
||
style={{
|
||
top: `${(hoveredSlot.minutes / 1440) * 100}%`,
|
||
height: `${(30 / 1440) * 100}%`,
|
||
}}
|
||
>
|
||
<PlusIcon className="size-3.5 shrink-0" />
|
||
<span className="truncate">
|
||
{formatSlotTime(hoveredSlot.minutes)} Neuer Termin
|
||
</span>
|
||
</div>
|
||
)}
|
||
{timedEvents.map((event) => {
|
||
const eventStart = new Date(event.start)
|
||
const eventEnd = new Date(event.end)
|
||
const segmentStart = new Date(
|
||
Math.max(eventStart.getTime(), dayStart.getTime()),
|
||
)
|
||
const segmentEnd = new Date(
|
||
Math.min(eventEnd.getTime(), dayEnd.getTime()),
|
||
)
|
||
const startMinutes =
|
||
(segmentStart.getTime() - dayStart.getTime()) / 60_000
|
||
const durationMinutes = Math.max(
|
||
30,
|
||
(segmentEnd.getTime() - segmentStart.getTime()) / 60_000,
|
||
)
|
||
|
||
return (
|
||
<button
|
||
key={event.id}
|
||
data-calendar-event
|
||
type="button"
|
||
onPointerDown={(pointerEvent) =>
|
||
pointerEvent.stopPropagation()
|
||
}
|
||
onClick={(clickEvent) => {
|
||
clickEvent.stopPropagation()
|
||
onEditEvent(event)
|
||
}}
|
||
onDoubleClick={(doubleClickEvent) =>
|
||
doubleClickEvent.stopPropagation()
|
||
}
|
||
onMouseEnter={() => setHoveredSlot(null)}
|
||
onPointerMove={(moveEvent) =>
|
||
moveEvent.stopPropagation()
|
||
}
|
||
className={classNames(
|
||
'absolute right-1 left-1 z-10 overflow-hidden rounded-lg p-2 text-left text-xs ring-1 ring-inset transition',
|
||
eventColors[event.color].card,
|
||
)}
|
||
style={{
|
||
top: `${(startMinutes / 1440) * 100}%`,
|
||
height: `${Math.max(
|
||
2.1,
|
||
(durationMinutes / 1440) * 100,
|
||
)}%`,
|
||
}}
|
||
>
|
||
<span className="block truncate font-semibold">
|
||
{event.title}
|
||
</span>
|
||
<span className="mt-0.5 block truncate opacity-80">
|
||
{formatTime(segmentStart)} - {formatTime(segmentEnd)}
|
||
{event.location ? ` · ${event.location}` : ''}
|
||
</span>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function YearView({
|
||
events,
|
||
selectedDate,
|
||
onOpenDay,
|
||
}: Pick<CalendarProps, 'events' | 'selectedDate'> & {
|
||
onOpenDay: (date: Date) => void
|
||
}) {
|
||
const months = Array.from(
|
||
{ length: 12 },
|
||
(_, month) => new Date(selectedDate.getFullYear(), month, 1),
|
||
)
|
||
|
||
return (
|
||
<div className="min-h-0 flex-1 overflow-auto bg-gray-50 p-4 sm:p-6 dark:bg-gray-950">
|
||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||
{months.map((month) => {
|
||
const gridStart = startOfWeek(month)
|
||
const days = Array.from({ length: 42 }, (_, index) =>
|
||
addDays(gridStart, index),
|
||
)
|
||
|
||
return (
|
||
<section
|
||
key={month.getMonth()}
|
||
className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-white/10"
|
||
>
|
||
<h2 className="text-sm font-semibold capitalize text-gray-900 dark:text-white">
|
||
{new Intl.DateTimeFormat('de-DE', { month: 'long' }).format(
|
||
month,
|
||
)}
|
||
</h2>
|
||
<div className="mt-4 grid grid-cols-7 text-center text-[10px] font-semibold uppercase text-gray-400">
|
||
{weekdayLabels.map((weekday) => (
|
||
<div key={weekday}>{weekday.slice(0, 1)}</div>
|
||
))}
|
||
</div>
|
||
<div className="mt-2 grid grid-cols-7 gap-y-1 text-center text-xs">
|
||
{days.map((day) => {
|
||
const inMonth = day.getMonth() === month.getMonth()
|
||
const dayEvents = eventsForDay(events, day)
|
||
const selected = isSameDay(day, selectedDate)
|
||
|
||
return (
|
||
<button
|
||
key={toDateKey(day)}
|
||
type="button"
|
||
onClick={() => onOpenDay(day)}
|
||
className={classNames(
|
||
'relative mx-auto flex size-8 items-center justify-center rounded-full',
|
||
!inMonth && 'text-gray-300 dark:text-gray-700',
|
||
inMonth &&
|
||
'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-white/10',
|
||
isToday(day) &&
|
||
'bg-indigo-600 font-semibold text-white hover:bg-indigo-500 dark:bg-indigo-500',
|
||
selected &&
|
||
!isToday(day) &&
|
||
'bg-gray-900 text-white dark:bg-white dark:text-gray-900',
|
||
)}
|
||
>
|
||
{day.getDate()}
|
||
{dayEvents.length > 0 && (
|
||
<span
|
||
className={classNames(
|
||
'absolute bottom-0.5 size-1 rounded-full',
|
||
isToday(day) || selected
|
||
? 'bg-white dark:bg-gray-900'
|
||
: eventColors[dayEvents[0].color].dot,
|
||
)}
|
||
/>
|
||
)}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</section>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function Calendar({
|
||
events,
|
||
coreWorkingHours,
|
||
selectedDate,
|
||
view,
|
||
onSelectedDateChange,
|
||
onViewChange,
|
||
onCreateEvent,
|
||
onEditEvent,
|
||
}: CalendarProps) {
|
||
function openDay(date: Date) {
|
||
onSelectedDateChange(date)
|
||
onViewChange('day')
|
||
}
|
||
|
||
function move(amount: number) {
|
||
if (view === 'day') {
|
||
onSelectedDateChange(addDays(selectedDate, amount))
|
||
return
|
||
}
|
||
if (view === 'week') {
|
||
onSelectedDateChange(addDays(selectedDate, amount * 7))
|
||
return
|
||
}
|
||
if (view === 'month') {
|
||
onSelectedDateChange(addMonths(selectedDate, amount))
|
||
return
|
||
}
|
||
onSelectedDateChange(addYears(selectedDate, amount))
|
||
}
|
||
|
||
const weekDays = Array.from({ length: 7 }, (_, index) =>
|
||
addDays(startOfWeek(selectedDate), index),
|
||
)
|
||
|
||
return (
|
||
<div className="flex h-full min-h-0 flex-col bg-white dark:bg-gray-900">
|
||
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-gray-200 px-4 py-3 sm:px-6 dark:border-white/10">
|
||
<div className="min-w-0">
|
||
<p className="text-xs font-medium uppercase tracking-wide text-indigo-600 dark:text-indigo-400">
|
||
Kalender
|
||
</p>
|
||
<h1 className="truncate text-lg font-semibold capitalize text-gray-950 dark:text-white">
|
||
{titleForView(selectedDate, view)}
|
||
</h1>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<div className="inline-flex rounded-lg bg-white shadow-xs ring-1 ring-gray-300 dark:bg-white/5 dark:ring-white/10">
|
||
<button
|
||
type="button"
|
||
onClick={() => move(-1)}
|
||
className="inline-flex size-9 items-center justify-center rounded-l-lg text-gray-500 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-white"
|
||
>
|
||
<span className="sr-only">Zurück</span>
|
||
<ChevronLeftIcon className="size-5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => onSelectedDateChange(startOfDay(new Date()))}
|
||
className="border-x border-gray-300 px-3 text-sm font-semibold text-gray-700 hover:bg-gray-50 dark:border-white/10 dark:text-gray-200 dark:hover:bg-white/10"
|
||
>
|
||
Heute
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => move(1)}
|
||
className="inline-flex size-9 items-center justify-center rounded-r-lg text-gray-500 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-white"
|
||
>
|
||
<span className="sr-only">Weiter</span>
|
||
<ChevronRightIcon className="size-5" />
|
||
</button>
|
||
</div>
|
||
|
||
<ButtonGroup
|
||
ariaLabel="Kalenderansicht wechseln"
|
||
size="lg"
|
||
value={view}
|
||
items={viewOptions}
|
||
onChange={(nextView) => onViewChange(nextView as CalendarView)}
|
||
/>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => onCreateEvent(selectedDate)}
|
||
className="inline-flex h-9 items-center gap-1.5 rounded-lg bg-indigo-600 px-3 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
|
||
>
|
||
<PlusIcon className="size-4" />
|
||
<span className="hidden sm:inline">Termin</span>
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{view === 'month' && (
|
||
<MonthView
|
||
events={events}
|
||
selectedDate={selectedDate}
|
||
onSelectedDateChange={onSelectedDateChange}
|
||
onCreateEvent={onCreateEvent}
|
||
onEditEvent={onEditEvent}
|
||
onOpenDay={openDay}
|
||
/>
|
||
)}
|
||
{view === 'week' && (
|
||
<TimeGridView
|
||
events={events}
|
||
coreWorkingHours={coreWorkingHours}
|
||
selectedDate={selectedDate}
|
||
days={weekDays}
|
||
onSelectedDateChange={onSelectedDateChange}
|
||
onCreateEvent={onCreateEvent}
|
||
onEditEvent={onEditEvent}
|
||
/>
|
||
)}
|
||
{view === 'day' && (
|
||
<TimeGridView
|
||
events={events}
|
||
coreWorkingHours={coreWorkingHours}
|
||
selectedDate={selectedDate}
|
||
days={[selectedDate]}
|
||
onSelectedDateChange={onSelectedDateChange}
|
||
onCreateEvent={onCreateEvent}
|
||
onEditEvent={onEditEvent}
|
||
/>
|
||
)}
|
||
{view === 'year' && (
|
||
<YearView
|
||
events={events}
|
||
selectedDate={selectedDate}
|
||
onOpenDay={openDay}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|