diff --git a/src/app/projects/[id]/page.tsx b/src/app/projects/[id]/page.tsx index 049401e..e8cf60d 100644 --- a/src/app/projects/[id]/page.tsx +++ b/src/app/projects/[id]/page.tsx @@ -5,6 +5,7 @@ import { GitPanel } from "@/components/GitPanel"; import { Markdown } from "@/components/Markdown"; import { NotesPanel } from "@/components/NotesPanel"; import { ProjectHeader } from "@/components/ProjectHeader"; +import { TaskBoard } from "@/components/tasks/TaskBoard"; import { requireUserPage } from "@/lib/auth/guard"; import { getSessionUser } from "@/lib/auth/session"; import { prisma } from "@/lib/db"; @@ -59,6 +60,12 @@ export default async function ProjectPage({ params }: PageProps) { connections={connections as unknown as ConnectionSafe[]} /> + +
diff --git a/src/components/ProjectCard.tsx b/src/components/ProjectCard.tsx index cb4629e..9f199e7 100644 --- a/src/components/ProjectCard.tsx +++ b/src/components/ProjectCard.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { useState } from "react"; import { AlertTriangle, + CheckSquare, GitBranch, GitCommitHorizontal, NotebookPen, @@ -155,6 +156,16 @@ export function ProjectCard({ project, index = 0 }: { project: ProjectListItem; ) : null} + {project._count.tasks > 0 ? ( + + + {project.tasks.length}/{project._count.tasks} + + ) : null} + {project._count.notes > 0 ? ( diff --git a/src/components/ProjectDialog.tsx b/src/components/ProjectDialog.tsx index 8d52aca..472f3ec 100644 --- a/src/components/ProjectDialog.tsx +++ b/src/components/ProjectDialog.tsx @@ -32,6 +32,7 @@ interface FormState { repoOwner: string; repoName: string; autoSync: boolean; + progressFromTasks: boolean; } const EMPTY: FormState = { @@ -49,6 +50,7 @@ const EMPTY: FormState = { repoOwner: "", repoName: "", autoSync: true, + progressFromTasks: false, }; export function ProjectDialog({ open, onClose, connections, project }: ProjectDialogProps) { @@ -90,6 +92,7 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi repoOwner: project.repoOwner ?? "", repoName: project.repoName ?? "", autoSync: project.autoSync, + progressFromTasks: project.progressFromTasks, } : EMPTY, ); @@ -158,6 +161,7 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi repoOwner: form.repoOwner, repoName: form.repoName, autoSync: form.autoSync, + progressFromTasks: form.progressFromTasks, }; try { @@ -291,9 +295,25 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi step={5} value={form.progress} onChange={(event) => update("progress", Number(event.target.value))} - className="range" + disabled={form.progressFromTasks} + className={cn("range", form.progressFromTasks && "cursor-not-allowed opacity-40")} style={{ "--fill": `${form.progress}%` } as React.CSSProperties} /> +
diff --git a/src/components/ProjectHeader.tsx b/src/components/ProjectHeader.tsx index 3eaf576..f8ac77c 100644 --- a/src/components/ProjectHeader.tsx +++ b/src/components/ProjectHeader.tsx @@ -6,6 +6,7 @@ import Link from "next/link"; import { ArrowLeft, CalendarDays, + CheckSquare, Download, ExternalLink, FileJson, @@ -207,7 +208,15 @@ export function ProjectHeader({ {/* Fortschritt direkt regelbar */}
- Fortschritt + + Fortschritt + {project.progressFromTasks ? ( + + + aus Aufgaben + + ) : null} + {progress}%
{/* Eigener Regler statt eines unsichtbaren Eingabefelds ueber einem @@ -223,7 +232,11 @@ export function ProjectHeader({ onTouchEnd={() => patch({ progress })} onKeyUp={() => patch({ progress })} aria-label="Fortschritt anpassen" - className="range" + // Wird der Wert aus Aufgaben abgeleitet, waere ein Zug hier + // wirkungslos – er wuerde beim naechsten Aufgabenwechsel wieder + // ueberschrieben. + disabled={project.progressFromTasks} + className={cn("range", project.progressFromTasks && "cursor-not-allowed opacity-50")} style={{ "--fill": `${progress}%` } as React.CSSProperties} />
diff --git a/src/components/tasks/TaskBoard.tsx b/src/components/tasks/TaskBoard.tsx new file mode 100644 index 0000000..da61210 --- /dev/null +++ b/src/components/tasks/TaskBoard.tsx @@ -0,0 +1,329 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { + DndContext, + DragOverlay, + PointerSensor, + TouchSensor, + pointerWithin, + useDroppable, + useSensor, + useSensors, + type DragEndEvent, + type DragOverEvent, + type DragStartEvent, +} from "@dnd-kit/core"; +import { restrictToWindowEdges } from "@dnd-kit/modifiers"; +import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { CheckSquare, Loader2, Plus } from "lucide-react"; +import type { Task, TaskStatus } from "@prisma/client"; +import { TaskCard } from "@/components/tasks/TaskCard"; +import { TaskDialog } from "@/components/tasks/TaskDialog"; +import { TASK_STATUS_META, TASK_STATUS_ORDER } from "@/lib/tasks"; +import { cn } from "@/lib/utils"; + +type Columns = Record; + +function group(tasks: Task[]): Columns { + const columns = { TODO: [], DOING: [], BLOCKED: [], DONE: [] } as Columns; + for (const task of [...tasks].sort((a, b) => a.position - b.position)) { + columns[task.status].push(task); + } + return columns; +} + +/** + * Aufgabenbrett eines Projekts. + * + * Anders als beim Projektbrett ist die Reihenfolge innerhalb einer Spalte + * hier bedeutsam, deshalb @dnd-kit/sortable statt einfacher Ablageflaechen. + * Waehrend des Ziehens fuehrt der oertliche Zustand, danach wird die neue + * Ordnung als Ganzes gespeichert: je Spalte die vollstaendige Liste. Das ist + * wiederholbar und kommt ohne Nachrechnen von Positionen im Browser aus. + */ +export function TaskBoard({ + projectId, + tasks, + progressFromTasks, +}: { + projectId: string; + tasks: Task[]; + progressFromTasks: boolean; +}) { + const router = useRouter(); + const [columns, setColumns] = useState(() => group(tasks)); + const [activeId, setActiveId] = useState(null); + const [editing, setEditing] = useState(null); + const [creatingIn, setCreatingIn] = useState(null); + const [saving, setSaving] = useState(false); + const dragging = useRef(false); + + // Serverstand uebernehmen, solange nicht gerade gezogen wird – sonst + // wuerde die Karte mitten in der Bewegung zurueckspringen. + useEffect(() => { + if (!dragging.current) setColumns(group(tasks)); + }, [tasks]); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(TouchSensor, { activationConstraint: { delay: 180, tolerance: 8 } }), + ); + + const allTasks = useMemo(() => Object.values(columns).flat(), [columns]); + const active = activeId ? allTasks.find((task) => task.id === activeId) ?? null : null; + const total = allTasks.length; + const done = columns.DONE.length; + + function containerOf(id: string): TaskStatus | null { + if (TASK_STATUS_ORDER.includes(id as TaskStatus)) return id as TaskStatus; + for (const status of TASK_STATUS_ORDER) { + if (columns[status].some((task) => task.id === id)) return status; + } + return null; + } + + function onDragStart(event: DragStartEvent) { + dragging.current = true; + setActiveId(String(event.active.id)); + } + + /** Wechsel der Spalte passiert schon beim Darueberziehen, nicht erst beim Ablegen. */ + function onDragOver(event: DragOverEvent) { + const activeKey = String(event.active.id); + const overKey = event.over ? String(event.over.id) : null; + if (!overKey) return; + + const from = containerOf(activeKey); + const to = containerOf(overKey); + if (!from || !to || from === to) return; + + setColumns((current) => { + const moved = current[from].find((task) => task.id === activeKey); + if (!moved) return current; + + const targetIndex = current[to].findIndex((task) => task.id === overKey); + const insertAt = targetIndex >= 0 ? targetIndex : current[to].length; + + return { + ...current, + [from]: current[from].filter((task) => task.id !== activeKey), + [to]: [ + ...current[to].slice(0, insertAt), + { ...moved, status: to }, + ...current[to].slice(insertAt), + ], + }; + }); + } + + async function onDragEnd(event: DragEndEvent) { + const activeKey = String(event.active.id); + const overKey = event.over ? String(event.over.id) : null; + + let next = columns; + + if (overKey) { + const container = containerOf(activeKey); + const overContainer = containerOf(overKey); + if (container && container === overContainer) { + const list = columns[container]; + const oldIndex = list.findIndex((task) => task.id === activeKey); + const newIndex = list.findIndex((task) => task.id === overKey); + if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) { + const reordered = [...list]; + const [moved] = reordered.splice(oldIndex, 1); + reordered.splice(newIndex, 0, moved); + next = { ...columns, [container]: reordered }; + setColumns(next); + } + } + } + + setActiveId(null); + dragging.current = false; + await persist(next); + } + + async function persist(next: Columns) { + setSaving(true); + try { + const payload = { + columns: Object.fromEntries( + TASK_STATUS_ORDER.map((status) => [status, next[status].map((task) => task.id)]), + ), + }; + const response = await fetch(`/api/projects/${projectId}/tasks/reorder`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!response.ok) throw new Error("failed"); + router.refresh(); + } catch { + // Zurueck auf den Serverstand – die Bewegung war dann nur optisch. + setColumns(group(tasks)); + } finally { + setSaving(false); + } + } + + return ( +
+
+
+ + + +
+

+ Aufgaben + {total > 0 ? ( + + {done}/{total} + + ) : null} + {saving ? : null} +

+

+ {progressFromTasks + ? "Der Fortschritt des Projekts folgt diesen Aufgaben." + : "Ziehen zum Verschieben und Sortieren."} +

+
+
+ + +
+ + { + setActiveId(null); + dragging.current = false; + setColumns(group(tasks)); + }} + > +
+ {TASK_STATUS_ORDER.map((status) => ( + setCreatingIn(status)} + onEdit={setEditing} + /> + ))} +
+ + + {active ? : null} + +
+ + { + setCreatingIn(null); + setEditing(null); + }} + /> +
+ ); +} + +function TaskColumn({ + status, + tasks, + onAdd, + onEdit, +}: { + status: TaskStatus; + tasks: Task[]; + onAdd: () => void; + onEdit: (task: Task) => void; +}) { + const meta = TASK_STATUS_META[status]; + const { setNodeRef, isOver } = useDroppable({ id: status }); + + return ( +
+
+
+ + task.id)} + strategy={verticalListSortingStrategy} + > +
+ {tasks.map((task) => ( + + ))} + {tasks.length === 0 ? ( +

+ {isOver ? "Hier ablegen" : "leer"} +

+ ) : null} +
+
+ + +
+ ); +} + +function SortableTask({ task, onEdit }: { task: Task; onEdit: (task: Task) => void }) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: task.id, + }); + + return ( +
+ +
+ ); +} diff --git a/src/components/tasks/TaskCard.tsx b/src/components/tasks/TaskCard.tsx new file mode 100644 index 0000000..e1a9b98 --- /dev/null +++ b/src/components/tasks/TaskCard.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { AlignLeft, CalendarClock, GripVertical } from "lucide-react"; +import type { Task } from "@prisma/client"; +import { cn, formatDate } from "@/lib/utils"; + +/** Faelligkeit einordnen: ueberfaellig, heute, demnaechst. */ +function dueTone(due: Date | string | null, done: boolean) { + if (!due || done) return null; + const date = new Date(due); + if (Number.isNaN(date.getTime())) return null; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + const target = new Date(date); + target.setHours(0, 0, 0, 0); + + const days = Math.round((target.getTime() - today.getTime()) / 86_400_000); + if (days < 0) return { className: "text-rose-300", label: "ueberfaellig" }; + if (days === 0) return { className: "text-amber-300", label: "heute" }; + if (days <= 3) return { className: "text-amber-200/70", label: null }; + return { className: "text-slate-500", label: null }; +} + +export function TaskCard({ + task, + onEdit, + dragHandle, + overlay = false, +}: { + task: Task; + onEdit?: (task: Task) => void; + dragHandle?: Record; + overlay?: boolean; +}) { + const done = task.status === "DONE"; + const due = dueTone(task.dueDate, done); + + return ( +
+
+ + + +
+
+ ); +} diff --git a/src/components/tasks/TaskDialog.tsx b/src/components/tasks/TaskDialog.tsx new file mode 100644 index 0000000..f0542d2 --- /dev/null +++ b/src/components/tasks/TaskDialog.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2, Trash2 } from "lucide-react"; +import type { Task, TaskStatus } from "@prisma/client"; +import { Modal } from "@/components/ui/Modal"; +import { TASK_STATUS_META, TASK_STATUS_ORDER } from "@/lib/tasks"; +import { cn, parseTags } from "@/lib/utils"; + +/** Datum fuer – ortszeitbezogen, nicht UTC. */ +function toDateInput(value: Date | string | null): string { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + const offset = date.getTimezoneOffset() * 60_000; + return new Date(date.getTime() - offset).toISOString().slice(0, 10); +} + +export function TaskDialog({ + open, + projectId, + task, + initialStatus, + onClose, +}: { + open: boolean; + projectId: string; + task: Task | null; + initialStatus: TaskStatus; + onClose: () => void; +}) { + const router = useRouter(); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [status, setStatus] = useState(initialStatus); + const [dueDate, setDueDate] = useState(""); + const [labels, setLabels] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const isEdit = Boolean(task); + + useEffect(() => { + if (!open) return; + setTitle(task?.title ?? ""); + setDescription(task?.description ?? ""); + setStatus(task?.status ?? initialStatus); + setDueDate(toDateInput(task?.dueDate ?? null)); + setLabels(task?.labels.join(", ") ?? ""); + setError(null); + }, [open, task, initialStatus]); + + async function submit(event: React.FormEvent) { + event.preventDefault(); + setBusy(true); + setError(null); + + const body = { + title, + description, + status, + // Ohne Uhrzeit auf Mittag legen, damit Zeitzonenverschiebungen das + // Datum nicht auf den Vortag ziehen. + dueDate: dueDate ? `${dueDate}T12:00:00` : null, + labels: parseTags(labels), + }; + + try { + const response = await fetch( + isEdit ? `/api/tasks/${task?.id}` : `/api/projects/${projectId}/tasks`, + { + method: isEdit ? "PATCH" : "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error ?? "Speichern fehlgeschlagen."); + onClose(); + router.refresh(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + } finally { + setBusy(false); + } + } + + async function remove() { + if (!task) return; + if (!window.confirm(`Aufgabe „${task.title}“ loeschen?`)) return; + setBusy(true); + try { + const response = await fetch(`/api/tasks/${task.id}`, { method: "DELETE" }); + if (!response.ok) throw new Error("Loeschen fehlgeschlagen."); + onClose(); + router.refresh(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + } finally { + setBusy(false); + } + } + + return ( + +
+
+ + setTitle(event.target.value)} + required + maxLength={200} + autoFocus + /> +
+ +
+ Spalte +
+ {TASK_STATUS_ORDER.map((value) => { + const meta = TASK_STATUS_META[value]; + return ( + + ); + })} +
+
+ +
+ +