Files
nebula/src/components/tasks/TaskOverview.tsx
T
florian.wilde afe57c4428
CI / pruefen (pull_request) Canceled after 0s
Suche ueber Aufgaben
Zwei Luecken, die im Issue zusammen stehen.

**Im Aufgabendashboard fehlte ein Suchfeld.** Es gab Filter nach Status und
Projekt, aber keine Moeglichkeit, nach einem Wort zu suchen. Das Suchfeld
deckt Titel, Beschreibung, Labels und Projektname ab. Gefiltert wird im
Browser - die Aufgaben liegen dort ohnehin alle vor, es braucht also keine
zusaetzliche Abfrage und es gibt keine Verzoegerung beim Tippen.

Wer sucht, sucht dabei auch in Erledigtem: sonst faende man die Aufgabe von
letzter Woche nicht wieder, obwohl man genau sie meint. Der Schalter
"Erledigte einblenden" gilt nur ohne Suchbegriff.

**Die Volltextsuche kannte nur Notizen.** Task ist jetzt ebenfalls dabei,
nach demselben Muster: deutsche Textsuche, ts_headline fuer den Ausschnitt,
Hervorhebung als [[…]] statt als HTML. Deutsche Stammformen greifen damit
auch hier - "Rechnung" findet "Rechnungen".

Dazu ein Ausdrucksindex Task_fulltext_idx wie Note_fulltext_idx. Der
Ausdruck muss Zeichen fuer Zeichen dem in der Abfrage entsprechen, sonst
greift er nicht; nachgeprueft mit EXPLAIN, der Plan zeigt einen
Bitmap Index Scan darauf. "prisma migrate diff" laesst ihn in Ruhe -
Gegenprobe kommt leer heraus.

Das Antwortfeld heisst weiterhin "hits" und meint die Notizen. Umbenennen
haette nur Aufrufer gebrochen, ohne etwas zu verbessern; Aufgaben kommen als
eigenes Feld dazu. Beide Stellen, die Treffer anzeigen - Projektbrett und
Schnellsuche -, zeigen sie jetzt.

Geprueft: 261 Unit-Tests, 36 E2E-Tests. Die Volltextsuche zusaetzlich gegen
die laufende Anwendung: Treffer in Titel und Beschreibung, Erledigtes
inbegriffen, Stammformen greifen, fremde Konten finden nichts.

Refs #4
2026-08-18 03:39:02 +02:00

444 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
CalendarClock,
CheckSquare,
ExternalLink,
Loader2,
Plus,
Repeat,
Search,
Square,
X,
} from "lucide-react";
import type { TaskStatus } from "@prisma/client";
import { QuickCapture } from "@/components/QuickCapture";
import { TaskDialog } from "@/components/tasks/TaskDialog";
import {
DUE_BUCKET_META,
DUE_BUCKET_ORDER,
RECURRENCE_META,
TASK_STATUS_META,
TASK_STATUS_ORDER,
} from "@/lib/tasks";
import { cn, formatDate } from "@/lib/utils";
import type { TaskOverviewItem } from "@/types";
/** Wo Filter und Schalter im Browser liegen. */
const ANSICHT_SCHLUESSEL = "nebula.tasks";
/**
* Aufgaben ueber alle Projekte hinweg.
*
* Geordnet wird nach Faelligkeit, nicht nach Spalte: projektuebergreifend
* lautet die Frage "was ist ueberfaellig", nicht "was steht wo im Brett".
* Aus demselben Grund laesst sich hier bewusst **nichts ziehen** – die
* Position einer Aufgabe gilt innerhalb ihrer Spalte in ihrem Projekt und
* waere hier ohne Bedeutung. Eine Reihenfolge anzubieten, die nirgends
* ankommt, waere ein Versprechen, das die Seite nicht halten kann.
*/
export function TaskOverview({ tasks }: { tasks: TaskOverviewItem[] }) {
const router = useRouter();
const [statusFilter, setStatusFilter] = useState<TaskStatus[]>([]);
const [projektFilter, setProjektFilter] = useState("");
const [suche, setSuche] = useState("");
const [zeigeErledigte, setZeigeErledigte] = useState(false);
const [erfassenOffen, setErfassenOffen] = useState(false);
const [laufend, setLaufend] = useState<Set<string>>(new Set());
/** Welche Aufgabe gerade im Dialog liegt. null heisst: keiner offen. */
const [bearbeiten, setBearbeiten] = useState<TaskOverviewItem | null>(null);
const projekte = useMemo(() => {
const map = new Map<string, string>();
for (const task of tasks) map.set(task.project.id, task.project.name);
return [...map.entries()]
.map(([id, name]) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name, "de"));
}, [tasks]);
const zaehler = useMemo(() => {
const map = new Map<TaskStatus, number>();
for (const task of tasks) map.set(task.status, (map.get(task.status) ?? 0) + 1);
return map;
}, [tasks]);
const sichtbar = useMemo(() => {
const nadel = suche.toLowerCase().trim();
return tasks.filter((task) => {
// Erledigtes fuellt die Liste sonst mit dem, was gerade nicht ansteht.
// Wer allerdings sucht, sucht auch in Erledigtem – sonst findet man
// die Aufgabe von letzter Woche nicht wieder.
if (
!zeigeErledigte &&
!nadel &&
task.status === "DONE" &&
!statusFilter.includes("DONE")
) {
return false;
}
if (statusFilter.length > 0 && !statusFilter.includes(task.status)) return false;
if (projektFilter && task.project.id !== projektFilter) return false;
if (!nadel) return true;
// Die Aufgaben liegen hier ohnehin alle vor – das filtert ohne
// zusaetzliche Abfrage und ohne Verzoegerung beim Tippen.
return (
task.title.toLowerCase().includes(nadel) ||
(task.description ?? "").toLowerCase().includes(nadel) ||
task.labels.some((label) => label.toLowerCase().includes(nadel)) ||
task.project.name.toLowerCase().includes(nadel)
);
});
}, [tasks, statusFilter, projektFilter, zeigeErledigte, suche]);
const faecher = useMemo(() => {
return DUE_BUCKET_ORDER.map((bucket) => ({
bucket,
items: sichtbar.filter((task) => task.bucket === bucket),
})).filter((fach) => fach.items.length > 0);
}, [sichtbar]);
const offen = sichtbar.filter((task) => task.status !== "DONE").length;
const ueberfaellig = sichtbar.filter(
(task) => task.bucket === "overdue" && task.status !== "DONE",
).length;
function toggleStatus(status: TaskStatus) {
setStatusFilter((current) =>
current.includes(status)
? current.filter((entry) => entry !== status)
: [...current, status],
);
}
/**
* Abhaken direkt aus der Uebersicht. Der Endpunkt erledigt dabei den Rest:
* doneAt, abgeleiteter Fortschritt und – bei wiederkehrenden Aufgaben –
* die naechste Fassung.
*/
async function abhaken(task: TaskOverviewItem) {
const ziel: TaskStatus = task.status === "DONE" ? "TODO" : "DONE";
setLaufend((current) => new Set(current).add(task.id));
try {
const antwort = await fetch(`/api/tasks/${task.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: ziel }),
});
if (!antwort.ok) throw new Error("fehlgeschlagen");
router.refresh();
} catch {
// Beim naechsten Laden steht wieder der echte Stand da.
} finally {
setLaufend((current) => {
const next = new Set(current);
next.delete(task.id);
return next;
});
}
}
return (
<div className="space-y-5">
<header className="fade-up flex flex-wrap items-end justify-between gap-3 pt-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.3em] text-violet-300/70">
Über alle Projekte
</p>
<h1 className="mt-2 text-2xl font-semibold tracking-tight text-white sm:text-3xl">
Aufgaben
</h1>
<p className="mt-1.5 text-sm text-slate-400">
{offen} offen
{ueberfaellig > 0 ? (
<>
{" · "}
<span className="text-rose-300">{ueberfaellig} überfällig</span>
</>
) : null}
</p>
</div>
<button
type="button"
className="btn btn-primary"
onClick={() => setErfassenOffen(true)}
aria-label="Neue Aufgabe"
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">Neue Aufgabe</span>
</button>
</header>
{/* Steuerleiste */}
<div className="glass flex flex-col gap-3 p-3 sm:p-4">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" />
<input
className="field pl-9"
placeholder="Aufgaben durchsuchen – Titel, Beschreibung, Labels…"
value={suche}
onChange={(event) => setSuche(event.target.value)}
aria-label="Aufgaben durchsuchen"
/>
{suche ? (
<button
type="button"
onClick={() => setSuche("")}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-slate-500 hover:text-white"
aria-label="Suche zuruecksetzen"
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
{TASK_STATUS_ORDER.map((status) => {
const meta = TASK_STATUS_META[status];
const aktiv = statusFilter.includes(status);
return (
<button
key={status}
type="button"
onClick={() => toggleStatus(status)}
aria-pressed={aktiv}
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs ring-1 ring-inset transition-all",
aktiv
? meta.className
: "text-slate-400 ring-white/10 hover:bg-white/5 hover:text-slate-200",
)}
>
<span
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: meta.color, opacity: aktiv ? 1 : 0.55 }}
/>
{meta.label}
<span className="tabular-nums text-slate-500">{zaehler.get(status) ?? 0}</span>
</button>
);
})}
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<label className="sr-only" htmlFor="task-project-filter">
Projekt
</label>
<select
id="task-project-filter"
className="field w-auto min-w-0 sm:min-w-[14rem] sm:flex-none"
value={projektFilter}
onChange={(event) => setProjektFilter(event.target.value)}
>
<option value="">Alle Projekte</option>
{projekte.map((projekt) => (
<option key={projekt.id} value={projekt.id}>
{projekt.name}
</option>
))}
</select>
<label className="flex cursor-pointer items-center gap-2 text-xs text-slate-500 sm:ml-auto">
<input
type="checkbox"
checked={zeigeErledigte}
onChange={(event) => setZeigeErledigte(event.target.checked)}
className="h-4 w-4 accent-violet-500"
/>
Erledigte einblenden
</label>
</div>
</div>
{faecher.length === 0 ? (
<div className="glass fade-up flex flex-col items-center justify-center gap-4 px-6 py-16 text-center">
<span className="grid h-14 w-14 place-items-center rounded-2xl bg-gradient-to-br from-violet-500/25 to-cyan-400/20 text-violet-200">
<CheckSquare className="h-6 w-6" />
</span>
<div>
<h2 className="text-lg font-semibold text-white">
{tasks.length === 0
? "Noch keine Aufgaben"
: suche.trim()
? "Keine Treffer"
: "Nichts offen"}
</h2>
<p className="mt-1 max-w-md text-sm text-slate-400">
{tasks.length === 0
? "Aufgaben entstehen im Projekt oder hier über „Neue Aufgabe“."
: suche.trim()
? `Keine Aufgabe passt zu „${suche.trim()}“ – gesucht wird in Titel, Beschreibung, Labels und Projektname.`
: "Alles abgehakt, was zu den Filtern passt."}
</p>
</div>
</div>
) : (
<div className="space-y-6">
{faecher.map((fach) => {
const meta = DUE_BUCKET_META[fach.bucket];
return (
<section key={fach.bucket}>
<div className="mb-2.5 flex items-center gap-2.5">
<span
className="accent-dot h-2 w-2 rounded-full"
style={{ backgroundColor: meta.color, color: meta.color }}
/>
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-300">
{meta.label}
</h2>
<span className="rounded-full bg-white/6 px-2 py-0.5 text-[11px] tabular-nums text-slate-400">
{fach.items.length}
</span>
<span className="h-px flex-1 bg-gradient-to-r from-white/10 to-transparent" />
</div>
<ul className="glass divide-y divide-white/6 overflow-hidden">
{fach.items.map((task) => (
<TaskRow
key={task.id}
task={task}
busy={laufend.has(task.id)}
onToggle={() => abhaken(task)}
onEdit={() => setBearbeiten(task)}
/>
))}
</ul>
</section>
);
})}
</div>
)}
<QuickCapture
open={erfassenOffen}
onClose={() => setErfassenOffen(false)}
initialMode="aufgabe"
/>
{/* Derselbe Dialog wie im Projektbrett – er kennt seine Aufgabe ueber
die ID und braucht das Projekt nur zum Anlegen. */}
{bearbeiten ? (
<TaskDialog
open
projectId={bearbeiten.project.id}
task={bearbeiten}
initialStatus={bearbeiten.status}
onClose={() => setBearbeiten(null)}
/>
) : null}
</div>
);
}
function TaskRow({
task,
busy,
onToggle,
onEdit,
}: {
task: TaskOverviewItem;
busy: boolean;
onToggle: () => void;
onEdit: () => void;
}) {
const meta = TASK_STATUS_META[task.status];
const erledigt = task.status === "DONE";
return (
<li className="flex items-center gap-2.5 px-3 py-2.5 transition-colors hover:bg-white/4 sm:gap-3 sm:px-4">
<button
type="button"
onClick={onToggle}
disabled={busy}
aria-label={erledigt ? `„${task.title}“ wieder öffnen` : `„${task.title}“ abhaken`}
className="shrink-0 rounded-lg p-1.5 text-slate-500 transition-colors hover:text-emerald-300 disabled:opacity-50"
>
{busy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : erledigt ? (
<CheckSquare className="h-4 w-4 text-emerald-300" />
) : (
<Square className="h-4 w-4" />
)}
</button>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
{/* Der Titel oeffnet den Bearbeiten-Dialog. Frueher fuehrte von
hier nur der Weg ins Projekt – wer eine Faelligkeit aendern
wollte, musste erst dorthin springen und die Aufgabe im Brett
wiederfinden. */}
<button
type="button"
onClick={onEdit}
className={cn(
"truncate text-left text-sm transition-colors hover:text-violet-200",
erledigt ? "text-slate-500 line-through" : "text-slate-100",
)}
title="Aufgabe bearbeiten"
>
{task.title}
</button>
{task.recurrence ? (
<Repeat
className="h-3 w-3 shrink-0 text-slate-500"
aria-label={RECURRENCE_META[task.recurrence].label}
/>
) : null}
{task.sourceUrl ? (
<a
href={task.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 text-slate-500 transition-colors hover:text-violet-300"
aria-label="Zugehöriges Issue öffnen"
>
<ExternalLink className="h-3 w-3" />
</a>
) : null}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-slate-500">
{/* Das Projekt gehoert in jede Zeile – ohne es ist eine
projektuebergreifende Liste nicht zu lesen. */}
<Link
href={`/projects/${task.project.id}`}
className="max-w-[10rem] truncate text-slate-400 underline-offset-2 hover:text-violet-300 hover:underline"
>
{task.project.name}
</Link>
<span
className={cn("inline-flex items-center gap-1 rounded-full px-1.5 ring-1 ring-inset", meta.className)}
>
{meta.label}
</span>
{task.dueDate ? (
<span
className={cn(
"inline-flex shrink-0 items-center gap-1",
task.bucket === "overdue" && !erledigt && "text-rose-300",
)}
>
<CalendarClock className="h-3 w-3" />
{formatDate(task.dueDate)}
</span>
) : null}
{task.labels.slice(0, 2).map((label) => (
<span key={label} className="hidden rounded bg-white/5 px-1.5 text-slate-400 xs:inline">
#{label}
</span>
))}
</div>
</div>
</li>
);
}