"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([]); const [projektFilter, setProjektFilter] = useState(""); const [suche, setSuche] = useState(""); const [zeigeErledigte, setZeigeErledigte] = useState(false); const [erfassenOffen, setErfassenOffen] = useState(false); const [laufend, setLaufend] = useState>(new Set()); /** Welche Aufgabe gerade im Dialog liegt. null heisst: keiner offen. */ const [bearbeiten, setBearbeiten] = useState(null); const projekte = useMemo(() => { const map = new Map(); 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(); 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 (

Über alle Projekte

Aufgaben

{offen} offen {ueberfaellig > 0 ? ( <> {" · "} {ueberfaellig} überfällig ) : null}

{/* Steuerleiste */}
setSuche(event.target.value)} aria-label="Aufgaben durchsuchen" /> {suche ? ( ) : null}
{TASK_STATUS_ORDER.map((status) => { const meta = TASK_STATUS_META[status]; const aktiv = statusFilter.includes(status); return ( ); })}
{faecher.length === 0 ? (

{tasks.length === 0 ? "Noch keine Aufgaben" : suche.trim() ? "Keine Treffer" : "Nichts offen"}

{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."}

) : (
{faecher.map((fach) => { const meta = DUE_BUCKET_META[fach.bucket]; return (

{meta.label}

{fach.items.length}
    {fach.items.map((task) => ( abhaken(task)} onEdit={() => setBearbeiten(task)} /> ))}
); })}
)} setErfassenOffen(false)} initialMode="aufgabe" /> {/* Derselbe Dialog wie im Projektbrett – er kennt seine Aufgabe ueber die ID und braucht das Projekt nur zum Anlegen. */} {bearbeiten ? ( setBearbeiten(null)} /> ) : null}
); } 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 (
  • {/* 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. */} {task.recurrence ? ( ) : null} {task.sourceUrl ? ( ) : null}
    {/* Das Projekt gehoert in jede Zeile – ohne es ist eine projektuebergreifende Liste nicht zu lesen. */} {task.project.name} {meta.label} {task.dueDate ? ( {formatDate(task.dueDate)} ) : null} {task.labels.slice(0, 2).map((label) => ( #{label} ))}
  • ); }