+ Aufgabe aus der linken Spalte in ein Zeitfenster ziehen, um sie einzuplanen. Ein
+ Zeitblock lässt sich am Griff verschieben; das Kreuz plant ihn wieder aus. Graue
+ Balken sind Termine aus dem Kalender — sie lassen sich hier nicht verschieben.
+
+
+ )
+}
diff --git a/src/app/(app)/woche/wochenraster.tsx b/src/app/(app)/woche/wochenraster.tsx
new file mode 100644
index 0000000..d1de1ed
--- /dev/null
+++ b/src/app/(app)/woche/wochenraster.tsx
@@ -0,0 +1,530 @@
+'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 */}
+