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>
531 lines
16 KiB
TypeScript
531 lines
16 KiB
TypeScript
'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>
|
||
)
|
||
}
|