'use client' import { useState, useTransition } from 'react' import { useRouter } from 'next/navigation' import { DndContext, DragOverlay, PointerSensor, useDraggable, useDroppable, useSensor, useSensors, type DragEndEvent, type DragStartEvent, } from '@dnd-kit/core' import { AlertTriangle, CalendarClock, GripVertical, X } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { Kennzeichen } from '@/components/ui/kennzeichen' import { ZEILE_HOEHE, balkenlage, spurenVerteilen, zeilenBeschriftung, zeitpunktAus, type Raster, } from '@/lib/woche' import { alsDauer, alsUhrzeitAnzeige } from '@/lib/zeit' import { cn } from '@/lib/utils' import { zeitblockAnlegen, zeitblockLoesen, zeitblockVerschieben, type Rueckmeldung, } from '@/server/aktionen/zeitbloecke' export type PlanbareAufgabe = { id: string titel: string aufwandMinuten: number | null bereichName: string | null bereichFarbe: string | null faelligText: string | null ueberfaellig: boolean bereitsGeplant: number } export type RasterZeitblock = { id: string beginnMs: number endeMs: number titel: string bereichFarbe: string | null kalenderName: string letzterFehler: string | null offen: boolean } export type RasterTermin = { id: string beginnMs: number endeMs: number betreff: string | null ganztaegig: boolean frei: boolean kalenderName: string } export type RasterSpalte = { schluessel: string tagesBeginnMs: number name: string datum: string heute: boolean kapazitaet: number belegt: number ueberbucht: boolean } type Eigenschaften = { spalten: RasterSpalte[] zeilen: number[] vonMinute: number bisMinute: number zeitbloecke: RasterZeitblock[] termine: RasterTermin[] aufgaben: PlanbareAufgabe[] kalenderkontoId: string | null } /** Standarddauer, wenn eine Aufgabe keinen geschätzten Aufwand trägt. */ const STANDARD_MINUTEN = 60 export function Wochenraster({ spalten, zeilen, vonMinute, bisMinute, zeitbloecke, termine, aufgaben, kalenderkontoId, }: Eigenschaften) { const router = useRouter() const [laeuft, starte] = useTransition() const [gezogen, setGezogen] = useState(null) const sensoren = useSensors( // Erst ab acht Pixeln gilt es als Ziehen – sonst löst jeder Klick einen // Zug aus und Knöpfe im Block wären nicht mehr bedienbar. useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), ) const raster: Raster = { spalten: spalten.map((s) => ({ tagesBeginn: new Date(s.tagesBeginnMs), wochentag: 0, schluessel: s.schluessel, })), zeilen, vonMinute, bisMinute, } function mitRueckmeldung(tun: () => Promise) { starte(async () => { const antwort = await tun() if (antwort.ok) toast.success(antwort.meldung) else toast.error(antwort.fehler) router.refresh() }) } function beimStart(ereignis: DragStartEvent) { setGezogen(String(ereignis.active.id)) } function beimEnde(ereignis: DragEndEvent) { setGezogen(null) const ziel = ereignis.over?.id if (!ziel) return const [schluessel, minuteRoh] = String(ziel).split('@') if (!schluessel || minuteRoh === undefined) return const beginn = zeitpunktAus(schluessel, Number(minuteRoh)) const kennung = String(ereignis.active.id) if (kennung.startsWith('aufgabe:')) { if (!kalenderkontoId) { toast.error('Erst einen Kalender verbinden, dann lässt sich einplanen.') return } const aufgabeId = kennung.slice('aufgabe:'.length) const aufgabe = aufgaben.find((a) => a.id === aufgabeId) const dauer = aufgabe?.aufwandMinuten ?? STANDARD_MINUTEN const ende = new Date(beginn.getTime() + dauer * 60_000) const daten = new FormData() daten.set('aufgabeId', aufgabeId) daten.set('kalenderkontoId', kalenderkontoId) daten.set('beginn', beginn.toISOString()) daten.set('ende', ende.toISOString()) mitRueckmeldung(() => zeitblockAnlegen(daten)) return } if (kennung.startsWith('block:')) { const id = kennung.slice('block:'.length) const block = zeitbloecke.find((z) => z.id === id) if (!block) return const dauer = block.endeMs - block.beginnMs const ende = new Date(beginn.getTime() + dauer) const daten = new FormData() daten.set('id', id) daten.set('beginn', beginn.toISOString()) daten.set('ende', ende.toISOString()) mitRueckmeldung(() => zeitblockVerschieben(daten)) } } function ausplanen(id: string) { const daten = new FormData() daten.set('id', id) mitRueckmeldung(() => zeitblockLoesen(daten)) } const gezogeneAufgabe = gezogen?.startsWith('aufgabe:') ? aufgaben.find((a) => a.id === gezogen.slice('aufgabe:'.length)) : null const gezogenerBlock = gezogen?.startsWith('block:') ? zeitbloecke.find((z) => z.id === gezogen.slice('block:'.length)) : null return (
{/* Vorrat: was noch eingeplant werden kann */} {/* Das Wochenraster */}
{spalten.map((spalte) => (

{spalte.name}

{spalte.datum}

{spalte.kapazitaet === 0 ? '—' : `${alsDauer(spalte.belegt)} / ${alsDauer(spalte.kapazitaet)}`}

))}
{/* Stundenleiste */}
{zeilen.map((minute) => (
{zeilenBeschriftung(minute)}
))}
{spalten.map((spalte) => { const blockeHier = zeitbloecke.filter( (z) => z.beginnMs < spalte.tagesBeginnMs + 86_400_000 && z.endeMs > spalte.tagesBeginnMs, ) const termineHier = termine.filter( (t) => !t.ganztaegig && t.beginnMs < spalte.tagesBeginnMs + 86_400_000 && t.endeMs > spalte.tagesBeginnMs, ) const verteilteTermine = spurenVerteilen( termineHier.map((t) => ({ ...t, beginn: new Date(t.beginnMs), ende: new Date(t.endeMs), })), ) return (
{zeilen.map((minute) => ( ))} {/* Fremde Termine liegen hinten und lassen sich nicht ziehen */} {verteilteTermine.map(({ eintrag, spur, spuren }) => { const lage = balkenlage( eintrag.beginn, eintrag.ende, raster.spalten.find((s) => s.schluessel === spalte.schluessel)!, raster, ) if (lage.ausserhalb) return null return (
{eintrag.betreff ?? 'Termin'}
) })} {/* Eigene Zeitblöcke liegen vorn und lassen sich ziehen */} {blockeHier.map((block) => { const lage = balkenlage( new Date(block.beginnMs), new Date(block.endeMs), raster.spalten.find((s) => s.schluessel === spalte.schluessel)!, raster, ) if (lage.ausserhalb) return null return ( ausplanen(block.id)} /> ) })}
) })}
{gezogeneAufgabe && (
{gezogeneAufgabe.titel} {alsDauer(gezogeneAufgabe.aufwandMinuten ?? STANDARD_MINUTEN)}
)} {gezogenerBlock && (
{gezogenerBlock.titel}
)}
) } function Zielfeld({ kennung, volleStunde }: { kennung: string; volleStunde: boolean }) { const { setNodeRef, isOver } = useDroppable({ id: kennung }) return (
) } function AufgabenKachel({ aufgabe, gesperrt, }: { aufgabe: PlanbareAufgabe gesperrt: boolean }) { const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id: `aufgabe:${aufgabe.id}`, disabled: gesperrt, }) return (
  • {aufgabe.titel}

    {aufgabe.bereichName && ( {aufgabe.bereichName} )} {alsDauer(aufgabe.aufwandMinuten ?? STANDARD_MINUTEN)} {aufgabe.faelligText && ( {aufgabe.faelligText} )} {aufgabe.bereitsGeplant > 0 && ( {aufgabe.bereitsGeplant} )}
  • ) } function Zeitblockbalken({ block, oben, hoehe, gesperrt, onAusplanen, }: { block: RasterZeitblock oben: number hoehe: number gesperrt: boolean onAusplanen: () => void }) { const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id: `block:${block.id}`, disabled: gesperrt, }) const farbe = block.bereichFarbe ?? 'var(--betont)' return (
    {block.titel} {(block.offen || block.letzterFehler) && ( )}
    ) }