feat(woche): Wochenansicht mit Ziehen und Ablegen

Links die einzuplanenden Aufgaben, rechts sieben Tage im
Viertelstundenraster. Eine Aufgabe ins Raster ziehen legt einen Zeitblock
an und schreibt ihn in den Kalender; ein Block lässt sich am Griff
verschieben, das Kreuz plant ihn wieder aus.

Die Dauer eines neuen Blocks kommt aus dem geschätzten Aufwand der
Aufgabe — wer schätzt, bekommt die passende Größe geschenkt; ohne
Schätzung sind es 60 Minuten.

Fremde Termine liegen grau im Hintergrund und lassen sich hier nicht
anfassen. Sie gehören dem Kalender, nicht dem Planer.

Zwei Details:
- Ziehen beginnt erst nach acht Pixeln Bewegung. Sonst löst jeder Klick
  einen Zug aus und die Knöpfe im Block wären nicht mehr bedienbar.
- Ein Zeitblock steht auch im Terminspiegel; er würde sonst doppelt
  erscheinen, einmal als eigene Planung und einmal als fremder Termin.
  Die Abfrage filtert ihn heraus.

Der Wochenversatz steht in der Adresse, damit eine bestimmte Woche als
Lesezeichen taugt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-10 22:37:53 +02:00
co-authored by Claude Opus 5
parent bcb4b8eb02
commit fada4450bd
4 changed files with 891 additions and 0 deletions
+530
View File
@@ -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<string | null>(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<Rueckmeldung>) {
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 (
<DndContext sensors={sensoren} onDragStart={beimStart} onDragEnd={beimEnde}>
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
{/* Vorrat: was noch eingeplant werden kann */}
<aside className="lg:w-72 lg:shrink-0">
<h2 className="mb-2 text-sm font-medium">
Einzuplanen
<span className="text-gedaempft-vordergrund ml-2 font-normal">
{aufgaben.length}
</span>
</h2>
{!kalenderkontoId && (
<p className="bg-warnung/15 text-warnung mb-3 rounded-md px-3 py-2 text-xs">
Ohne verbundenen Kalender lässt sich nichts einplanen.
</p>
)}
<ul className="border-rand bg-karte max-h-[32rem] divide-y divide-[var(--rand)] overflow-y-auto rounded-xl border">
{aufgaben.length === 0 ? (
<li className="text-gedaempft-vordergrund p-4 text-center text-sm">
Nichts offen. Ein guter Tag.
</li>
) : (
aufgaben.map((aufgabe) => (
<AufgabenKachel key={aufgabe.id} aufgabe={aufgabe} gesperrt={laeuft} />
))
)}
</ul>
</aside>
{/* Das Wochenraster */}
<div className="min-w-0 flex-1 overflow-x-auto">
<div className="min-w-[46rem]">
<div className="grid grid-cols-[3.5rem_repeat(7,1fr)]">
<div />
{spalten.map((spalte) => (
<div
key={spalte.schluessel}
className={cn(
'border-rand border-b px-1 pb-2 text-center',
spalte.heute && 'bg-betont/5',
)}
>
<p className="text-sm font-medium">{spalte.name}</p>
<p className="text-gedaempft-vordergrund text-xs">{spalte.datum}</p>
<p
className={cn(
'mt-1 text-xs',
spalte.ueberbucht ? 'text-gefahr' : 'text-gedaempft-vordergrund',
)}
>
{spalte.kapazitaet === 0
? '—'
: `${alsDauer(spalte.belegt)} / ${alsDauer(spalte.kapazitaet)}`}
</p>
</div>
))}
</div>
<div className="grid grid-cols-[3.5rem_repeat(7,1fr)]">
{/* Stundenleiste */}
<div className="relative">
{zeilen.map((minute) => (
<div
key={minute}
style={{ height: ZEILE_HOEHE }}
className="text-gedaempft-vordergrund pr-2 text-right text-[0.6875rem] leading-none"
>
{zeilenBeschriftung(minute)}
</div>
))}
</div>
{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 (
<div
key={spalte.schluessel}
className={cn(
'border-rand relative border-l',
spalte.heute && 'bg-betont/5',
)}
>
{zeilen.map((minute) => (
<Zielfeld
key={minute}
kennung={`${spalte.schluessel}@${minute}`}
volleStunde={minute % 60 === 0}
/>
))}
{/* 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 (
<div
key={eintrag.id}
className={cn(
'pointer-events-none absolute rounded-sm px-1 text-[0.6875rem] leading-tight',
eintrag.frei
? 'bg-gedaempft/60 text-gedaempft-vordergrund'
: 'bg-gedaempft text-gedaempft-vordergrund',
)}
style={{
top: lage.oben,
height: lage.hoehe,
left: `${(spur / spuren) * 100}%`,
width: `${100 / spuren}%`,
}}
title={`${eintrag.betreff ?? 'Termin'} · ${eintrag.kalenderName}`}
>
<span className="line-clamp-2">
{eintrag.betreff ?? 'Termin'}
</span>
</div>
)
})}
{/* 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 (
<Zeitblockbalken
key={block.id}
block={block}
oben={lage.oben}
hoehe={lage.hoehe}
gesperrt={laeuft}
onAusplanen={() => ausplanen(block.id)}
/>
)
})}
</div>
)
})}
</div>
</div>
</div>
</div>
<DragOverlay>
{gezogeneAufgabe && (
<div className="bg-betont text-betont-vordergrund rounded-md px-2 py-1 text-xs shadow-lg">
{gezogeneAufgabe.titel}
<span className="ml-1 opacity-80">
{alsDauer(gezogeneAufgabe.aufwandMinuten ?? STANDARD_MINUTEN)}
</span>
</div>
)}
{gezogenerBlock && (
<div className="bg-betont text-betont-vordergrund rounded-md px-2 py-1 text-xs shadow-lg">
{gezogenerBlock.titel}
</div>
)}
</DragOverlay>
</DndContext>
)
}
function Zielfeld({ kennung, volleStunde }: { kennung: string; volleStunde: boolean }) {
const { setNodeRef, isOver } = useDroppable({ id: kennung })
return (
<div
ref={setNodeRef}
style={{ height: ZEILE_HOEHE }}
className={cn(
'border-rand/40',
volleStunde ? 'border-t' : 'border-t border-dashed',
isOver && 'bg-betont/20',
)}
/>
)
}
function AufgabenKachel({
aufgabe,
gesperrt,
}: {
aufgabe: PlanbareAufgabe
gesperrt: boolean
}) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `aufgabe:${aufgabe.id}`,
disabled: gesperrt,
})
return (
<li
ref={setNodeRef}
className={cn('flex items-start gap-2 p-2', isDragging && 'opacity-40')}
>
<button
type="button"
className="text-gedaempft-vordergrund mt-0.5 cursor-grab touch-none active:cursor-grabbing"
aria-label={`${aufgabe.titel} zum Einplanen ziehen`}
{...listeners}
{...attributes}
>
<GripVertical className="size-4" aria-hidden />
</button>
<div className="min-w-0 flex-1">
<p className="text-sm leading-tight">{aufgabe.titel}</p>
<div className="mt-1 flex flex-wrap items-center gap-1">
{aufgabe.bereichName && (
<Kennzeichen>
<span
aria-hidden
className="size-2 rounded-full ring-1 ring-black/10"
style={{ backgroundColor: aufgabe.bereichFarbe ?? undefined }}
/>
{aufgabe.bereichName}
</Kennzeichen>
)}
<Kennzeichen>
{alsDauer(aufgabe.aufwandMinuten ?? STANDARD_MINUTEN)}
</Kennzeichen>
{aufgabe.faelligText && (
<Kennzeichen art={aufgabe.ueberfaellig ? 'gefahr' : 'neutral'}>
{aufgabe.faelligText}
</Kennzeichen>
)}
{aufgabe.bereitsGeplant > 0 && (
<Kennzeichen art="betont">
<CalendarClock className="size-3" aria-hidden />
{aufgabe.bereitsGeplant}
</Kennzeichen>
)}
</div>
</div>
</li>
)
}
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 (
<div
ref={setNodeRef}
style={{
top: oben,
height: hoehe,
borderLeftColor: farbe,
backgroundColor: `color-mix(in oklab, ${farbe} 18%, var(--karte))`,
}}
className={cn(
'absolute right-0.5 left-0.5 overflow-hidden rounded-sm border-l-4 px-1 text-[0.6875rem] leading-tight',
isDragging && 'opacity-40',
)}
title={`${block.titel} · ${alsUhrzeitAnzeige(new Date(block.beginnMs))}–${alsUhrzeitAnzeige(new Date(block.endeMs))} · ${block.kalenderName}`}
>
<div className="flex items-start gap-0.5">
<button
type="button"
className="shrink-0 cursor-grab touch-none active:cursor-grabbing"
aria-label={`${block.titel} verschieben`}
{...listeners}
{...attributes}
>
<GripVertical className="size-3 opacity-60" aria-hidden />
</button>
<span className="line-clamp-3 flex-1 font-medium">{block.titel}</span>
{(block.offen || block.letzterFehler) && (
<AlertTriangle
className="text-warnung size-3 shrink-0"
aria-label="Noch nicht im Kalender"
/>
)}
<Button
art="leise"
className="size-4 shrink-0 p-0"
disabled={gesperrt}
title="Ausplanen"
onClick={onAusplanen}
>
<X className="size-3" aria-hidden />
<span className="sr-only">Ausplanen</span>
</Button>
</div>
</div>
)
}