"use client"; import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { ExternalLink, GitPullRequestArrow, Loader2, Trash2 } from "lucide-react"; import type { Recurrence, TaskStatus } from "@prisma/client"; import { Modal } from "@/components/ui/Modal"; import { RECURRENCE_META, TASK_STATUS_META, TASK_STATUS_ORDER } from "@/lib/tasks"; import { cn, parseTags } from "@/lib/utils"; /** * Was der Dialog von einer Aufgabe tatsaechlich liest. * * Bewusst nicht der vollstaendige Prisma-Datensatz: die Aufgabenuebersicht * ueber alle Projekte reicht eine schlankere Fassung durch, in der das Datum * als Zeichenkette ankommt – ein Date ueberlebt den Weg vom Server zum * Browser nicht. Ein echtes Task erfuellt diese Form ebenfalls. */ export interface EditableTask { id: string; title: string; description: string | null; status: TaskStatus; dueDate: Date | string | null; labels: string[]; recurrence: Recurrence | null; /** Gesetzt, sobald die Aufgabe an einem Git-Issue haengt. */ sourceIssue?: number | null; sourceUrl?: string | null; } /** 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: EditableTask | 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 [recurrence, setRecurrence] = useState(""); const [busy, setBusy] = useState(false); const [issueBusy, setIssueBusy] = useState(false); const [error, setError] = useState(null); const isEdit = Boolean(task); /** * Legt zur Aufgabe ein Issue im verknuepften Repository an. * * Ausdruecklich per Knopf und nicht beim Speichern: was in ein fremdes * System geschrieben wird, soll jemand gewollt haben. */ async function createIssue() { if (!task) return; setIssueBusy(true); setError(null); try { const response = await fetch(`/api/tasks/${task.id}/issue`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }); const payload = await response.json().catch(() => ({})); if (!response.ok) throw new Error(payload.error ?? "Issue konnte nicht angelegt werden."); onClose(); router.refresh(); } catch (caught) { setError(caught instanceof Error ? caught.message : String(caught)); } finally { setIssueBusy(false); } } useEffect(() => { if (!open) return; setTitle(task?.title ?? ""); setDescription(task?.description ?? ""); setStatus(task?.status ?? initialStatus); setDueDate(toDateInput(task?.dueDate ?? null)); setLabels(task?.labels.join(", ") ?? ""); setRecurrence(task?.recurrence ?? ""); 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), recurrence: recurrence || null, }; 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 ( Titel * setTitle(event.target.value)} required maxLength={200} autoFocus /> Spalte {TASK_STATUS_ORDER.map((value) => { const meta = TASK_STATUS_META[value]; return ( setStatus(value)} className={cn( "rounded-lg border px-2 py-2 text-xs transition-all", status === value ? "border-violet-400/60 bg-violet-500/12 text-white" : "border-white/9 text-slate-400 hover:border-white/20", )} > {meta.label} ); })} Beschreibung setDescription(event.target.value)} placeholder="Markdown wird dargestellt." /> Faellig am setDueDate(event.target.value)} /> Labels (kommagetrennt) setLabels(event.target.value)} placeholder="bug, ui" /> Wiederholung setRecurrence(event.target.value)} > – einmalig – {(Object.keys(RECURRENCE_META) as Recurrence[]).map((key) => ( {RECURRENCE_META[key].label} ))} {recurrence ? ( Sobald die Aufgabe nach „Erledigt“ wandert, entsteht die nächste. Gerechnet wird vom Fälligkeitsdatum, nicht vom Abhaken — eine wöchentliche Aufgabe bleibt also auf ihrem Wochentag. ) : null} {error ? ( {error} ) : null} {/* Verknuepfung mit einem Git-Issue. Nur beim Bearbeiten – zu einer Aufgabe, die es noch nicht gibt, laesst sich keins anlegen. */} {isEdit ? ( {task?.sourceUrl || task?.sourceIssue ? ( Verknuepft mit {task.sourceUrl ? ( Issue #{task.sourceIssue} ) : ( Issue #{task.sourceIssue} )} Erledigt schliesst das Issue, ein Zurueckziehen oeffnet es wieder. ) : ( Noch nicht mit einem Issue verknuepft. {issueBusy ? ( ) : ( )} Als Issue anlegen )} ) : null} {isEdit ? ( Loeschen ) : ( )} Abbrechen {busy ? : null} {isEdit ? "Speichern" : "Anlegen"} ); }
Sobald die Aufgabe nach „Erledigt“ wandert, entsteht die nächste. Gerechnet wird vom Fälligkeitsdatum, nicht vom Abhaken — eine wöchentliche Aufgabe bleibt also auf ihrem Wochentag.
{error}
Verknuepft mit {task.sourceUrl ? ( Issue #{task.sourceIssue} ) : ( Issue #{task.sourceIssue} )} Erledigt schliesst das Issue, ein Zurueckziehen oeffnet es wieder.