"use client"; import { useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { Loader2, RefreshCw, Search, Trash2 } from "lucide-react"; import type { ProjectStatus } from "@prisma/client"; import { Modal } from "@/components/ui/Modal"; import { ACCENTS, PRIORITY_META, STATUS_META, STATUS_ORDER } from "@/lib/status"; import { cn, parseTags } from "@/lib/utils"; import type { ConnectionSafe, ProjectListItem, RepoListEntry } from "@/types"; interface ProjectDialogProps { open: boolean; onClose: () => void; connections: ConnectionSafe[]; /** Vorhandenes Projekt = Bearbeiten, sonst Anlegen. */ project?: ProjectListItem | null; } /** Eine Vorlage, so wie sie /api/templates liefert. */ export interface ProjectTemplateEntry { id: string; name: string; description: string | null; summary: string | null; projectDescription: string | null; status: ProjectStatus; priority: number; accent: string; tags: string[]; progressFromTasks: boolean; notes: unknown[]; tasks: unknown[]; } /** "3 Notizen, 5 Aufgaben" – oder was davon zutrifft. */ function beschreibeVorlage(vorlage: ProjectTemplateEntry): string { const teile: string[] = []; const notizen = Array.isArray(vorlage.notes) ? vorlage.notes.length : 0; const aufgaben = Array.isArray(vorlage.tasks) ? vorlage.tasks.length : 0; if (notizen > 0) teile.push(`${notizen} ${notizen === 1 ? "Notiz" : "Notizen"}`); if (aufgaben > 0) teile.push(`${aufgaben} ${aufgaben === 1 ? "Aufgabe" : "Aufgaben"}`); if (vorlage.tags.length > 0) teile.push(vorlage.tags.map((tag) => `#${tag}`).join(" ")); return teile.length > 0 ? teile.join(" · ") : "nur Stammdaten"; } interface FormState { name: string; summary: string; description: string; status: ProjectStatus; priority: number; progress: number; accent: string; tags: string; favorite: boolean; connectionId: string; repoUrl: string; repoOwner: string; repoName: string; autoSync: boolean; progressFromTasks: boolean; /** Nur beim Anlegen: aus welcher Vorlage Notizen und Aufgaben kommen. */ templateId: string; } const EMPTY: FormState = { name: "", summary: "", description: "", status: "IDEA", priority: 2, progress: 0, accent: "violet", tags: "", favorite: false, connectionId: "", repoUrl: "", repoOwner: "", repoName: "", autoSync: true, progressFromTasks: false, templateId: "", }; export function ProjectDialog({ open, onClose, connections, project }: ProjectDialogProps) { const router = useRouter(); const [form, setForm] = useState(EMPTY); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [fieldErrors, setFieldErrors] = useState>({}); const isEdit = Boolean(project); const [templates, setTemplates] = useState([]); const gewaehlteVorlage = templates.find((entry) => entry.id === form.templateId) ?? null; /** * Die Vorlagen werden erst geladen, wenn der Dialog aufgeht – und nur zum * Anlegen. Beim Bearbeiten waeren sie nur Ballast. */ useEffect(() => { if (!open || isEdit) return; let abgebrochen = false; fetch("/api/templates") .then((response) => (response.ok ? response.json() : { templates: [] })) .then((payload) => { if (!abgebrochen) setTemplates(payload.templates ?? []); }) .catch(() => undefined); return () => { abgebrochen = true; }; }, [open, isEdit]); /** * Uebernimmt die Stammdaten sichtbar in die Felder. Notizen und Aufgaben * bleiben der Serverseite ueberlassen: sie liessen sich hier nicht * darstellen, und wer die Vorlage wieder abwaehlt, soll sie auch nicht * mitgeschleppt bekommen. * * Bereits Getipptes wird nicht ueberschrieben – nur leere Felder werden * gefuellt. */ function applyTemplate(templateId: string) { const vorlage = templates.find((entry) => entry.id === templateId); if (!vorlage) { update("templateId", ""); return; } setForm((previous) => ({ ...previous, templateId, name: previous.name || vorlage.name, summary: previous.summary || (vorlage.summary ?? ""), description: previous.description || (vorlage.projectDescription ?? ""), status: vorlage.status, priority: vorlage.priority, accent: vorlage.accent, tags: previous.tags || vorlage.tags.join(", "), progressFromTasks: vorlage.progressFromTasks, })); } async function removeTemplate(vorlage: ProjectTemplateEntry) { if (!window.confirm(`Vorlage „${vorlage.name}“ loeschen?`)) return; await fetch(`/api/templates/${vorlage.id}`, { method: "DELETE" }).catch(() => undefined); setTemplates((current) => current.filter((entry) => entry.id !== vorlage.id)); update("templateId", ""); } const [repoPickerOpen, setRepoPickerOpen] = useState(false); const [repos, setRepos] = useState([]); const [reposLoading, setReposLoading] = useState(false); const [repoFilter, setRepoFilter] = useState(""); const [repoError, setRepoError] = useState(null); useEffect(() => { if (!open) return; setError(null); setFieldErrors({}); setRepoPickerOpen(false); setRepos([]); setRepoError(null); setForm( project ? { name: project.name, summary: project.summary ?? "", description: project.description ?? "", status: project.status, priority: project.priority, progress: project.progress, accent: project.accent, tags: project.tags.join(", "), favorite: project.favorite, // Der Dialog bearbeitet das fuehrende Repository. Weitere // verwaltet das Git-Panel – hier wuerde eine Liste den Dialog // sprengen, in dem es eigentlich um das Projekt geht. connectionId: project.repositories[0]?.connectionId ?? "", repoUrl: project.repositories[0]?.repoUrl ?? "", repoOwner: project.repositories[0]?.repoOwner ?? "", repoName: project.repositories[0]?.repoName ?? "", autoSync: project.repositories[0]?.autoSync ?? true, progressFromTasks: project.progressFromTasks, templateId: "", } : EMPTY, ); }, [open, project]); const filteredRepos = useMemo(() => { const needle = repoFilter.toLowerCase().trim(); if (!needle) return repos.slice(0, 60); return repos.filter((repo) => repo.fullPath.toLowerCase().includes(needle)).slice(0, 60); }, [repos, repoFilter]); function update(key: K, value: FormState[K]) { setForm((previous) => ({ ...previous, [key]: value })); } async function loadRepos() { if (!form.connectionId) { setRepoError("Bitte zuerst eine Git-Verbindung auswaehlen."); return; } setReposLoading(true); setRepoError(null); try { const response = await fetch(`/api/connections/${form.connectionId}/repos`); const payload = await response.json(); if (!response.ok) throw new Error(payload.error ?? "Repos konnten nicht geladen werden."); setRepos(payload.repos ?? []); setRepoPickerOpen(true); } catch (caught) { setRepoError(caught instanceof Error ? caught.message : String(caught)); } finally { setReposLoading(false); } } function pickRepo(repo: RepoListEntry) { setForm((previous) => ({ ...previous, repoUrl: repo.htmlUrl, repoOwner: repo.owner, repoName: repo.name, name: previous.name || repo.name, summary: previous.summary || (repo.description ?? ""), })); setRepoPickerOpen(false); } async function submit(event: React.FormEvent) { event.preventDefault(); setSaving(true); setError(null); setFieldErrors({}); const body = { name: form.name, summary: form.summary, description: form.description, status: form.status, priority: Number(form.priority), progress: Number(form.progress), accent: form.accent, tags: parseTags(form.tags), favorite: form.favorite, connectionId: form.connectionId || null, repoUrl: form.repoUrl, repoOwner: form.repoOwner, repoName: form.repoName, autoSync: form.autoSync, progressFromTasks: form.progressFromTasks, // Nur beim Anlegen; beim Bearbeiten kennt das Schema das Feld nicht. ...(isEdit ? {} : { templateId: form.templateId || null }), }; try { const response = await fetch( isEdit ? `/api/projects/${project?.id}` : "/api/projects", { method: isEdit ? "PATCH" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }, ); const payload = await response.json().catch(() => ({})); if (!response.ok) { setFieldErrors(payload.fields ?? {}); throw new Error(payload.error ?? "Speichern fehlgeschlagen."); } onClose(); router.refresh(); } catch (caught) { setError(caught instanceof Error ? caught.message : String(caught)); } finally { setSaving(false); } } return (
{/* Nur beim Anlegen. Ein bestehendes Projekt nachtraeglich aus einer Vorlage zu befuellen wuerde Notizen und Aufgaben verdoppeln. */} {!isEdit && templates.length > 0 ? (
{gewaehlteVorlage ? ( ) : null}
{gewaehlteVorlage ? (

{gewaehlteVorlage.description ? `${gewaehlteVorlage.description} · ` : ""} {beschreibeVorlage(gewaehlteVorlage)}

) : (

Vorlagen entstehen aus einem bestehenden Projekt – im Projektkopf unter „Als Vorlage sichern“.

)}
) : null}
update("name", event.target.value)} placeholder="z. B. Pixelgarten" required maxLength={120} autoFocus /> {fieldErrors.name ? (

{fieldErrors.name}

) : null}
update("summary", event.target.value)} placeholder="Ein Satz, der auf die Karte passt" maxLength={280} />