2026-06-23 08:52:36 +02:00

1311 lines
45 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 {
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>
)
}