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 (
+
+
+
+
+ {meta.label}
+
+ {tasks.length}
+
+
+
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 (
+
+
+
+ );
+}
diff --git a/src/lib/projects.ts b/src/lib/projects.ts
index 78a21b0..6153cdc 100644
--- a/src/lib/projects.ts
+++ b/src/lib/projects.ts
@@ -49,7 +49,10 @@ export const connectionSafeSelect = Prisma.validator
export const projectListInclude = Prisma.validator()({
connection: { select: connectionSafeSelect },
snapshot: true,
- _count: { select: { notes: true } },
+ _count: { select: { notes: true, tasks: true } },
+ // Nur die offenen Aufgaben zaehlen – fuer die Anzeige "3 von 8" auf der
+ // Karte, ohne die Aufgaben selbst mitzuladen.
+ tasks: { where: { status: "DONE" }, select: { id: true } },
});
export const projectDetailInclude = Prisma.validator()({
@@ -58,5 +61,8 @@ export const projectDetailInclude = Prisma.validator()({
notes: {
orderBy: [{ pinned: "desc" }, { updatedAt: "desc" }],
},
- _count: { select: { notes: true } },
+ tasks: {
+ orderBy: [{ status: "asc" }, { position: "asc" }],
+ },
+ _count: { select: { notes: true, tasks: true } },
});
diff --git a/src/types/index.ts b/src/types/index.ts
index bfc1da2..055dfcd 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -1,4 +1,12 @@
-import type { GitProvider, Note, Project, ProjectStatus, RepoSnapshot } from "@prisma/client";
+import type {
+ GitProvider,
+ Note,
+ Project,
+ ProjectStatus,
+ RepoSnapshot,
+ Task,
+ TaskStatus,
+} from "@prisma/client";
import type {
BranchInfo,
CiInfo,
@@ -30,14 +38,17 @@ export interface ConnectionWithCount extends ConnectionSafe {
export interface ProjectListItem extends Project {
connection: ConnectionSafe | null;
snapshot: RepoSnapshot | null;
- _count: { notes: number };
+ _count: { notes: number; tasks: number };
+ /** Nur die erledigten – siehe projectListInclude. */
+ tasks: { id: string }[];
}
export interface ProjectDetail extends Project {
connection: ConnectionSafe | null;
snapshot: RepoSnapshot | null;
notes: Note[];
- _count: { notes: number };
+ tasks: Task[];
+ _count: { notes: number; tasks: number };
}
export interface RepoListEntry {
@@ -60,6 +71,8 @@ export type {
ReleaseInfo,
RepoSnapshot,
TagInfo,
+ Task,
+ TaskStatus,
TicketInfo,
};