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:
co-authored by
Claude Opus 5
parent
bcb4b8eb02
commit
fada4450bd
@@ -0,0 +1,205 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { CalendarRange } from 'lucide-react'
|
||||
import { Wochenraster } from '@/app/(app)/woche/wochenraster'
|
||||
import { Card, CardInhalt } from '@/components/ui/card'
|
||||
import { faelligkeitEinordnen, faelligkeitText, istUeberfaellig } from '@/lib/aufgabe'
|
||||
import {
|
||||
belegteMinuten,
|
||||
arbeitsfensterAmTag,
|
||||
kapazitaetAmTag,
|
||||
} from '@/lib/kalender/belegung'
|
||||
import { fensterHuelle, rasterBauen } from '@/lib/woche'
|
||||
import { alsDatum, gleicherTag, tagesBeginn } from '@/lib/zeit'
|
||||
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||
import { db } from '@/server/db'
|
||||
import { planbareAufgabenLaden, wocheLaden } from '@/server/abfragen/woche'
|
||||
import { jetzt } from '@/server/zeitgeber'
|
||||
|
||||
export const metadata: Metadata = { title: 'Woche' }
|
||||
|
||||
const WOCHENTAGE = [
|
||||
'Montag',
|
||||
'Dienstag',
|
||||
'Mittwoch',
|
||||
'Donnerstag',
|
||||
'Freitag',
|
||||
'Samstag',
|
||||
'Sonntag',
|
||||
]
|
||||
|
||||
export default async function WocheSeite({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ versatz?: string }>
|
||||
}) {
|
||||
const benutzer = await verlangeAnmeldung('/woche')
|
||||
const parameter = await searchParams
|
||||
|
||||
// Über den Versatz lässt sich blättern; er steht in der Adresse, damit eine
|
||||
// bestimmte Woche als Lesezeichen taugt.
|
||||
const versatz = Number.parseInt(parameter.versatz ?? '0', 10)
|
||||
const sichereWochen = Number.isFinite(versatz)
|
||||
? Math.max(-52, Math.min(52, versatz))
|
||||
: 0
|
||||
const heute = jetzt()
|
||||
const zeitpunkt = new Date(heute.getTime() + sichereWochen * 7 * 86_400_000)
|
||||
|
||||
const [daten, aufgaben, bereiche, konto] = await Promise.all([
|
||||
wocheLaden(benutzer.id, zeitpunkt),
|
||||
planbareAufgabenLaden(),
|
||||
db.bereich.findMany({
|
||||
where: { aktiv: true },
|
||||
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
|
||||
}),
|
||||
// Für den ersten Wurf: der Zeitblock landet im ersten verbundenen
|
||||
// Kalender. Die Auswahl je Bereich kommt, sobald es mehrere gibt.
|
||||
db.kalenderkonto.findFirst({
|
||||
where: { benutzerId: benutzer.id, aktiv: true },
|
||||
orderBy: { erstelltAm: 'asc' },
|
||||
select: { id: true, anzeigename: true },
|
||||
}),
|
||||
])
|
||||
|
||||
const huelle = fensterHuelle(bereiche)
|
||||
const raster = rasterBauen(zeitpunkt, huelle.von, huelle.bis)
|
||||
|
||||
// Kapazität und Belegung je Tag: alle Bereiche zusammengenommen, denn ein
|
||||
// Termin blockiert die Zeit für alles gleichermaßen.
|
||||
const spalten = raster.spalten.map((spalte) => {
|
||||
const tag = spalte.tagesBeginn
|
||||
const kapazitaet = bereiche.reduce((summe, b) => summe + kapazitaetAmTag(b, tag), 0)
|
||||
|
||||
const belegend = [
|
||||
...daten.termine.map((t) => ({
|
||||
beginn: t.beginn,
|
||||
ende: t.ende,
|
||||
ganztaegig: t.ganztaegig,
|
||||
zeigtAls: t.zeigtAls,
|
||||
})),
|
||||
...daten.zeitbloecke.map((z) => ({
|
||||
beginn: z.beginn,
|
||||
ende: z.ende,
|
||||
ganztaegig: false,
|
||||
zeigtAls: 'GEBUCHT' as const,
|
||||
})),
|
||||
]
|
||||
|
||||
// Gegen die Hülle rechnen, nicht gegen ein einzelnes Arbeitsfenster:
|
||||
// die Tageskapazität ist hier die Summe über alle Bereiche.
|
||||
const fenster = arbeitsfensterAmTag(tag, huelle.von, huelle.bis)
|
||||
const belegt = belegteMinuten(belegend, fenster)
|
||||
|
||||
return {
|
||||
schluessel: spalte.schluessel,
|
||||
tagesBeginnMs: spalte.tagesBeginn.getTime(),
|
||||
name: WOCHENTAGE[spalte.wochentag] ?? '',
|
||||
datum: alsDatum(tag).split(', ')[1] ?? '',
|
||||
heute: gleicherTag(tag, heute),
|
||||
kapazitaet,
|
||||
belegt,
|
||||
ueberbucht: kapazitaet > 0 && belegt > kapazitaet,
|
||||
}
|
||||
})
|
||||
|
||||
const montag = tagesBeginn(raster.spalten[0]?.tagesBeginn ?? zeitpunkt)
|
||||
const sonntag = raster.spalten[6]?.tagesBeginn ?? zeitpunkt
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
|
||||
<CalendarRange className="size-6" aria-hidden />
|
||||
Woche
|
||||
</h1>
|
||||
<p className="text-gedaempft-vordergrund mt-1 text-sm">
|
||||
{alsDatum(montag)} bis {alsDatum(sonntag)}
|
||||
{konto ? ` · schreibt nach ${konto.anzeigename}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex items-center gap-2" aria-label="Woche wechseln">
|
||||
<Link
|
||||
href={`/woche?versatz=${sichereWochen - 1}`}
|
||||
className="border-rand hover:bg-gedaempft rounded-md border px-3 py-1.5 text-sm"
|
||||
>
|
||||
Zurück
|
||||
</Link>
|
||||
<Link
|
||||
href="/woche"
|
||||
className="border-rand hover:bg-gedaempft rounded-md border px-3 py-1.5 text-sm"
|
||||
>
|
||||
Diese Woche
|
||||
</Link>
|
||||
<Link
|
||||
href={`/woche?versatz=${sichereWochen + 1}`}
|
||||
className="border-rand hover:bg-gedaempft rounded-md border px-3 py-1.5 text-sm"
|
||||
>
|
||||
Weiter
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{!konto && (
|
||||
<Card>
|
||||
<CardInhalt className="py-4 text-sm">
|
||||
Noch kein Kalender verbunden. Zeitblöcke landen erst dann im Kalender —{' '}
|
||||
<Link
|
||||
href="/kalenderkonten"
|
||||
className="text-betont underline underline-offset-4"
|
||||
>
|
||||
jetzt verbinden
|
||||
</Link>
|
||||
.
|
||||
</CardInhalt>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Wochenraster
|
||||
spalten={spalten}
|
||||
zeilen={raster.zeilen}
|
||||
vonMinute={raster.vonMinute}
|
||||
bisMinute={raster.bisMinute}
|
||||
kalenderkontoId={konto?.id ?? null}
|
||||
zeitbloecke={daten.zeitbloecke.map((z) => ({
|
||||
id: z.id,
|
||||
beginnMs: z.beginn.getTime(),
|
||||
endeMs: z.ende.getTime(),
|
||||
titel: z.titel,
|
||||
bereichFarbe: z.bereichFarbe,
|
||||
kalenderName: z.kalenderName,
|
||||
letzterFehler: z.letzterFehler,
|
||||
offen: z.offen,
|
||||
}))}
|
||||
termine={daten.termine.map((t) => ({
|
||||
id: t.id,
|
||||
beginnMs: t.beginn.getTime(),
|
||||
endeMs: t.ende.getTime(),
|
||||
betreff: t.betreff,
|
||||
ganztaegig: t.ganztaegig,
|
||||
frei: t.zeigtAls === 'FREI',
|
||||
kalenderName: t.kalenderName,
|
||||
}))}
|
||||
aufgaben={aufgaben.map((a) => ({
|
||||
id: a.id,
|
||||
titel: a.titel,
|
||||
aufwandMinuten: a.aufwandMinuten,
|
||||
bereichName: a.bereich?.name ?? null,
|
||||
bereichFarbe: a.bereich?.farbe ?? null,
|
||||
faelligText: a.faelligAm
|
||||
? faelligkeitText(faelligkeitEinordnen(a.faelligAm, heute))
|
||||
: null,
|
||||
ueberfaellig: istUeberfaellig(a.faelligAm, a.status, heute),
|
||||
bereitsGeplant: a._count.zeitbloecke,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<p className="text-gedaempft-vordergrund text-xs">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export function Kopfleiste({
|
||||
{ pfad: '/', name: 'Start' },
|
||||
{ pfad: '/inbox', name: 'Inbox', zaehler: inbox },
|
||||
{ pfad: '/aufgaben', name: 'Aufgaben' },
|
||||
{ pfad: '/woche', name: 'Woche' },
|
||||
{ pfad: '/projekte', name: 'Projekte' },
|
||||
{ pfad: '/ideen', name: 'Ideen' },
|
||||
{ pfad: '/bereiche', name: 'Bereiche' },
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import 'server-only'
|
||||
import type { ZeigtAls } from '@/generated/prisma/enums'
|
||||
import { tagesBeginn, wochenBeginn } from '@/lib/zeit'
|
||||
import { db } from '@/server/db'
|
||||
|
||||
/**
|
||||
* Alles, was die Wochenansicht braucht — in einem Zug geladen.
|
||||
*
|
||||
* Die Zeitblöcke des Benutzers und die Termine seiner Kalender kommen
|
||||
* getrennt zurück: ein Zeitblock ist eigene Planung und lässt sich
|
||||
* verschieben, ein Termin ist fremd und wird nur angezeigt.
|
||||
*/
|
||||
|
||||
export type WochenZeitblock = {
|
||||
id: string
|
||||
beginn: Date
|
||||
ende: Date
|
||||
aufgabeId: string
|
||||
titel: string
|
||||
bereichName: string | null
|
||||
bereichFarbe: string | null
|
||||
kalenderName: string
|
||||
letzterFehler: string | null
|
||||
/** Noch nicht in den Kalender geschrieben. */
|
||||
offen: boolean
|
||||
}
|
||||
|
||||
export type WochenTermin = {
|
||||
id: string
|
||||
beginn: Date
|
||||
ende: Date
|
||||
betreff: string | null
|
||||
ganztaegig: boolean
|
||||
zeigtAls: ZeigtAls
|
||||
kalenderName: string
|
||||
}
|
||||
|
||||
export type WocheDaten = {
|
||||
zeitbloecke: WochenZeitblock[]
|
||||
termine: WochenTermin[]
|
||||
}
|
||||
|
||||
/** Beginn und Ende der Woche, in der der Zeitpunkt liegt. */
|
||||
export function wochenFenster(zeitpunkt: Date): { von: Date; bis: Date } {
|
||||
const montag = wochenBeginn(zeitpunkt)
|
||||
return {
|
||||
von: montag,
|
||||
bis: new Date(tagesBeginn(new Date(montag.getTime() + 7 * 86_400_000)).getTime()),
|
||||
}
|
||||
}
|
||||
|
||||
export async function wocheLaden(
|
||||
benutzerId: string,
|
||||
zeitpunkt: Date,
|
||||
): Promise<WocheDaten> {
|
||||
const fenster = wochenFenster(zeitpunkt)
|
||||
|
||||
const [zeitbloecke, termine] = await Promise.all([
|
||||
db.zeitblock.findMany({
|
||||
where: {
|
||||
kalenderkonto: { benutzerId },
|
||||
beginn: { lt: fenster.bis },
|
||||
ende: { gt: fenster.von },
|
||||
},
|
||||
orderBy: { beginn: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
beginn: true,
|
||||
ende: true,
|
||||
extId: true,
|
||||
letzterFehler: true,
|
||||
aufgabe: {
|
||||
select: {
|
||||
id: true,
|
||||
titel: true,
|
||||
bereich: { select: { name: true, farbe: true } },
|
||||
},
|
||||
},
|
||||
kalenderkonto: { select: { anzeigename: true } },
|
||||
},
|
||||
}),
|
||||
db.termin.findMany({
|
||||
where: {
|
||||
kalenderkonto: { benutzerId, aktiv: true },
|
||||
beginn: { lt: fenster.bis },
|
||||
ende: { gt: fenster.von },
|
||||
},
|
||||
orderBy: { beginn: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
extId: true,
|
||||
beginn: true,
|
||||
ende: true,
|
||||
betreff: true,
|
||||
ganztaegig: true,
|
||||
zeigtAls: true,
|
||||
kalenderkonto: { select: { anzeigename: true } },
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
// Ein Zeitblock steht auch im Kalender und damit im Spiegel. Er würde sonst
|
||||
// doppelt erscheinen — einmal als eigene Planung, einmal als fremder Termin.
|
||||
const eigeneExtIds = new Set(
|
||||
zeitbloecke.map((z) => z.extId).filter((id): id is string => id !== null),
|
||||
)
|
||||
|
||||
return {
|
||||
zeitbloecke: zeitbloecke.map((z) => ({
|
||||
id: z.id,
|
||||
beginn: z.beginn,
|
||||
ende: z.ende,
|
||||
aufgabeId: z.aufgabe.id,
|
||||
titel: z.aufgabe.titel,
|
||||
bereichName: z.aufgabe.bereich?.name ?? null,
|
||||
bereichFarbe: z.aufgabe.bereich?.farbe ?? null,
|
||||
kalenderName: z.kalenderkonto.anzeigename,
|
||||
letzterFehler: z.letzterFehler,
|
||||
offen: z.extId === null,
|
||||
})),
|
||||
termine: termine
|
||||
.filter((t) => !eigeneExtIds.has(t.extId))
|
||||
.map((t) => ({
|
||||
id: t.id,
|
||||
beginn: t.beginn,
|
||||
ende: t.ende,
|
||||
betreff: t.betreff,
|
||||
ganztaegig: t.ganztaegig,
|
||||
zeigtAls: t.zeigtAls,
|
||||
kalenderName: t.kalenderkonto.anzeigename,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/** Aufgaben, die sich einplanen lassen: offen, keine Idee, mit Aufwand. */
|
||||
export async function planbareAufgabenLaden(grenze = 50) {
|
||||
return db.aufgabe.findMany({
|
||||
where: {
|
||||
status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] },
|
||||
typ: { not: 'IDEE' },
|
||||
},
|
||||
orderBy: [{ faelligAm: 'asc' }, { prioritaet: 'asc' }],
|
||||
take: grenze,
|
||||
select: {
|
||||
id: true,
|
||||
titel: true,
|
||||
aufwandMinuten: true,
|
||||
faelligAm: true,
|
||||
prioritaet: true,
|
||||
status: true,
|
||||
bereich: { select: { id: true, name: true, farbe: true } },
|
||||
_count: { select: { zeitbloecke: true } },
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user