From 57c50cfb7f99798937f31de1a67226360589aa27 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sun, 26 Jul 2026 15:02:38 +0200 Subject: [PATCH] feat(projects): Projektvorlagen Ein Projekt, das sich bewaehrt hat, laesst sich im Projektkopf als Vorlage sichern: Status, Prioritaet, Farbe, Tags, Notizen und offene Aufgaben. Beim Anlegen steht sie oben im Dialog zur Auswahl. Benutzereigen statt fest eingebaut. Welche Tags und welche Startaufgaben sinnvoll sind, weiss nur, wer damit arbeitet - eine mitgelieferte Liste waere geraten. Zwei Dinge gehen bewusst nicht mit. Erledigte Aufgaben nicht: eine Vorlage beschreibt, was zu tun ist, nicht was einmal getan wurde. Und Repositories nicht: eine Vorlage, die jedes neue Projekt an dasselbe Repository haengt, waere eine Falle. Die Stammdaten uebernimmt der Dialog sichtbar in die Felder, damit man sie vor dem Speichern noch aendern kann; bereits Getipptes bleibt stehen. Notizen und Aufgaben legt erst der Server an - im Dialog liessen sie sich nicht darstellen, und wer die Vorlage wieder abwaehlt, soll sie nicht mitgeschleppt bekommen. templateId gilt nur beim Anlegen. Ein bestehendes Projekt nachtraeglich aus einer Vorlage zu befuellen wuerde Notizen und Aufgaben verdoppeln. Co-Authored-By: Claude Opus 5 (1M context) --- .../migration.sql | 29 +++ src/app/api/projects/route.ts | 67 ++++++- src/app/api/templates/[id]/route.ts | 60 ++++++ src/app/api/templates/from-project/route.ts | 87 +++++++++ src/app/api/templates/route.ts | 72 ++++++++ src/components/ProjectDialog.tsx | 143 +++++++++++++- src/components/ProjectHeader.tsx | 21 +++ src/components/SaveTemplateDialog.tsx | 174 ++++++++++++++++++ src/lib/validation.ts | 57 +++++- 9 files changed, 701 insertions(+), 9 deletions(-) create mode 100644 prisma/migrations/20260726260000_project_templates/migration.sql create mode 100644 src/app/api/templates/[id]/route.ts create mode 100644 src/app/api/templates/from-project/route.ts create mode 100644 src/app/api/templates/route.ts create mode 100644 src/components/SaveTemplateDialog.tsx diff --git a/prisma/migrations/20260726260000_project_templates/migration.sql b/prisma/migrations/20260726260000_project_templates/migration.sql new file mode 100644 index 0000000..0ba597f --- /dev/null +++ b/prisma/migrations/20260726260000_project_templates/migration.sql @@ -0,0 +1,29 @@ +-- CreateTable +CREATE TABLE "ProjectTemplate" ( + "id" TEXT NOT NULL, + "ownerId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "summary" TEXT, + "projectDescription" TEXT, + "status" "ProjectStatus" NOT NULL DEFAULT 'IDEA', + "priority" INTEGER NOT NULL DEFAULT 2, + "accent" TEXT NOT NULL DEFAULT 'violet', + "tags" TEXT[] DEFAULT ARRAY[]::TEXT[], + "progressFromTasks" BOOLEAN NOT NULL DEFAULT false, + "notes" JSONB NOT NULL DEFAULT '[]', + "tasks" JSONB NOT NULL DEFAULT '[]', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ProjectTemplate_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "ProjectTemplate_ownerId_idx" ON "ProjectTemplate"("ownerId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectTemplate_ownerId_name_key" ON "ProjectTemplate"("ownerId", "name"); + +-- AddForeignKey +ALTER TABLE "ProjectTemplate" ADD CONSTRAINT "ProjectTemplate_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts index de585f2..b2d2436 100644 --- a/src/app/api/projects/route.ts +++ b/src/app/api/projects/route.ts @@ -74,6 +74,20 @@ export async function POST(request: Request) { if (!connection) return jsonError("Git-Verbindung nicht gefunden.", 404); } + // Aus der Vorlage kommen nur Notizen und Aufgaben. Die Stammdaten hat die + // Oberflaeche schon in die Felder uebernommen, damit man sie vor dem + // Speichern noch sieht und aendern kann. + const vorlage = data.templateId + ? await prisma.projectTemplate.findFirst({ + where: { id: data.templateId, ownerId: auth.user.id }, + select: { name: true, notes: true, tasks: true }, + }) + : null; + if (data.templateId && !vorlage) return jsonError("Vorlage nicht gefunden.", 404); + + const notes = templateNotes(vorlage?.notes); + const tasks = templateTasks(vorlage?.tasks); + const angelegt = await prisma.project.create({ data: { ownerId: auth.user.id, @@ -87,8 +101,11 @@ export async function POST(request: Request) { accent: data.accent, tags: data.tags, favorite: data.favorite, + progressFromTasks: data.progressFromTasks, // Hinten an die Statusspalte des Bretts. position: await nextProjectPosition(auth.user.id, data.status), + notes: { create: notes }, + tasks: { create: tasks }, }, select: { id: true }, }); @@ -111,12 +128,56 @@ export async function POST(request: Request) { projectId: project.id, userId: auth.user.id, kind: "PROJECT_CREATED", - summary: - data.status === "IDEA" + summary: vorlage + ? `Aus Vorlage „${vorlage.name}“ angelegt` + : data.status === "IDEA" ? "Als Idee angelegt" : `Angelegt mit Status „${statusOf(data.status).label}“`, - meta: { status: data.status }, + meta: { + status: data.status, + ...(vorlage ? { template: vorlage.name, notes: notes.length, tasks: tasks.length } : {}), + }, }); return NextResponse.json({ project }, { status: 201 }); } + +/** + * Die Vorlage kommt als JSON aus der Datenbank und ist damit fuer TypeScript + * unbekannt. Hier wird sie auf das Noetige zurechtgeschnitten – lieber einen + * Eintrag auslassen als am Anlegen scheitern. + */ +function templateNotes(value: unknown) { + if (!Array.isArray(value)) return []; + return value + .filter((entry): entry is Record => Boolean(entry) && typeof entry === "object") + .filter((entry) => typeof entry.content === "string" && entry.content.trim().length > 0) + .slice(0, 50) + .map((entry) => ({ + title: typeof entry.title === "string" ? entry.title : null, + content: entry.content as string, + pinned: entry.pinned === true, + })); +} + +function templateTasks(value: unknown) { + if (!Array.isArray(value)) return []; + return value + .filter((entry): entry is Record => Boolean(entry) && typeof entry === "object") + .filter((entry) => typeof entry.title === "string" && entry.title.trim().length > 0) + .slice(0, 100) + .map((entry, index) => ({ + title: entry.title as string, + description: typeof entry.description === "string" ? entry.description : null, + status: (["TODO", "DOING", "BLOCKED"] as const).includes( + entry.status as "TODO" | "DOING" | "BLOCKED", + ) + ? (entry.status as "TODO" | "DOING" | "BLOCKED") + : ("TODO" as const), + // Reihenfolge wie in der Vorlage. + position: index, + labels: Array.isArray(entry.labels) + ? entry.labels.filter((label): label is string => typeof label === "string").slice(0, 8) + : [], + })); +} diff --git a/src/app/api/templates/[id]/route.ts b/src/app/api/templates/[id]/route.ts new file mode 100644 index 0000000..dfaa9e3 --- /dev/null +++ b/src/app/api/templates/[id]/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from "next/server"; +import { requireUserApi } from "@/lib/auth/guard"; +import { prisma } from "@/lib/db"; +import { jsonError, readJsonBody } from "@/lib/security/request"; +import { formatZodError, templateUpdateSchema } from "@/lib/validation"; + +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ id: string }> }; + +export async function PATCH(request: Request, { params }: Params) { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + const { id } = await params; + const existing = await prisma.projectTemplate.findFirst({ + where: { id, ownerId: auth.user.id }, + select: { id: true }, + }); + if (!existing) return jsonError("Vorlage nicht gefunden", 404); + + const body = await readJsonBody(request); + if (!body.ok) return body.response; + + const parsed = templateUpdateSchema.safeParse(body.data); + if (!parsed.success) { + return NextResponse.json( + { error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) }, + { status: 422 }, + ); + } + + const data = parsed.data; + + if (data.name) { + const doppelt = await prisma.projectTemplate.findFirst({ + where: { ownerId: auth.user.id, name: data.name, id: { not: id } }, + select: { id: true }, + }); + if (doppelt) return jsonError(`Eine Vorlage „${data.name}“ gibt es bereits.`, 409); + } + + const template = await prisma.projectTemplate.update({ where: { id }, data }); + return NextResponse.json({ template }); +} + +export async function DELETE(_request: Request, { params }: Params) { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + const { id } = await params; + + // deleteMany mit Besitzerbedingung: trifft fremde Vorlagen gar nicht erst. + const result = await prisma.projectTemplate.deleteMany({ + where: { id, ownerId: auth.user.id }, + }); + if (result.count === 0) return jsonError("Vorlage nicht gefunden", 404); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/templates/from-project/route.ts b/src/app/api/templates/from-project/route.ts new file mode 100644 index 0000000..ee7fb29 --- /dev/null +++ b/src/app/api/templates/from-project/route.ts @@ -0,0 +1,87 @@ +import { NextResponse } from "next/server"; +import { requireUserApi } from "@/lib/auth/guard"; +import { prisma } from "@/lib/db"; +import { jsonError, readJsonBody } from "@/lib/security/request"; +import { formatZodError, templateFromProjectSchema } from "@/lib/validation"; + +export const dynamic = "force-dynamic"; + +/** + * Aus einem vorhandenen Projekt eine Vorlage machen. + * + * Das ist der Weg, auf dem Vorlagen ueblicherweise entstehen: man richtet ein + * Projekt so ein, wie es passt, und haelt das dann fest. Eine Vorlage von + * Grund auf zu tippen kann man auch – ueber POST /api/templates. + * + * Nicht mitgenommen wird alles Projektbezogene: Name, Slug, Fortschritt, + * Favorit und vor allem die Repositories. Eine Vorlage, die jedes neue + * Projekt an dasselbe Repository haengt, waere eine Falle. + */ +export async function POST(request: Request) { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + const body = await readJsonBody(request); + if (!body.ok) return body.response; + + const parsed = templateFromProjectSchema.safeParse(body.data); + if (!parsed.success) { + return NextResponse.json( + { error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) }, + { status: 422 }, + ); + } + + const { projectId, name, description, withNotes, withTasks } = parsed.data; + + const project = await prisma.project.findFirst({ + where: { id: projectId, ownerId: auth.user.id }, + include: { + notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] }, + tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] }, + }, + }); + if (!project) return jsonError("Projekt nicht gefunden", 404); + + const doppelt = await prisma.projectTemplate.findFirst({ + where: { ownerId: auth.user.id, name }, + select: { id: true }, + }); + if (doppelt) return jsonError(`Eine Vorlage „${name}“ gibt es bereits.`, 409); + + const template = await prisma.projectTemplate.create({ + data: { + ownerId: auth.user.id, + name, + description: description ?? null, + summary: project.summary, + projectDescription: project.description, + status: project.status, + priority: project.priority, + accent: project.accent, + tags: project.tags, + progressFromTasks: project.progressFromTasks, + notes: withNotes + ? project.notes.map((note) => ({ + title: note.title, + content: note.content, + pinned: note.pinned, + })) + : [], + // Erledigte Aufgaben kommen nicht mit: eine Vorlage beschreibt, was zu + // tun ist, nicht was einmal getan wurde. + tasks: withTasks + ? project.tasks + .filter((task) => task.status !== "DONE") + .map((task) => ({ + title: task.title, + description: task.description, + status: task.status, + labels: task.labels, + })) + : [], + }, + }); + + return NextResponse.json({ template }, { status: 201 }); +} diff --git a/src/app/api/templates/route.ts b/src/app/api/templates/route.ts new file mode 100644 index 0000000..d1f8863 --- /dev/null +++ b/src/app/api/templates/route.ts @@ -0,0 +1,72 @@ +import { NextResponse } from "next/server"; +import { requireUserApi } from "@/lib/auth/guard"; +import { prisma } from "@/lib/db"; +import { jsonError, readJsonBody } from "@/lib/security/request"; +import { formatZodError, templateCreateSchema } from "@/lib/validation"; + +export const dynamic = "force-dynamic"; + +/** Hoechstzahl je Konto. Wer mehr braucht, hat keine Vorlagen mehr, sondern Projekte. */ +const MAX_TEMPLATES = 30; + +export async function GET() { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + const templates = await prisma.projectTemplate.findMany({ + where: { ownerId: auth.user.id }, + orderBy: { name: "asc" }, + }); + + return NextResponse.json({ templates }); +} + +export async function POST(request: Request) { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + const body = await readJsonBody(request); + if (!body.ok) return body.response; + + const parsed = templateCreateSchema.safeParse(body.data); + if (!parsed.success) { + return NextResponse.json( + { error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) }, + { status: 422 }, + ); + } + + const vorhanden = await prisma.projectTemplate.count({ where: { ownerId: auth.user.id } }); + if (vorhanden >= MAX_TEMPLATES) { + return jsonError(`Mehr als ${MAX_TEMPLATES} Vorlagen sind nicht vorgesehen.`, 409); + } + + const data = parsed.data; + + // Der Name ist je Konto eindeutig; ein Duplikat soll eine verstaendliche + // Meldung geben statt eines Datenbankfehlers. + const doppelt = await prisma.projectTemplate.findFirst({ + where: { ownerId: auth.user.id, name: data.name }, + select: { id: true }, + }); + if (doppelt) return jsonError(`Eine Vorlage „${data.name}“ gibt es bereits.`, 409); + + const template = await prisma.projectTemplate.create({ + data: { + ownerId: auth.user.id, + name: data.name, + description: data.description ?? null, + summary: data.summary ?? null, + projectDescription: data.projectDescription ?? null, + status: data.status, + priority: data.priority, + accent: data.accent, + tags: data.tags, + progressFromTasks: data.progressFromTasks, + notes: data.notes, + tasks: data.tasks, + }, + }); + + return NextResponse.json({ template }, { status: 201 }); +} diff --git a/src/components/ProjectDialog.tsx b/src/components/ProjectDialog.tsx index c963ffa..4c154df 100644 --- a/src/components/ProjectDialog.tsx +++ b/src/components/ProjectDialog.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; -import { Loader2, RefreshCw, Search } from "lucide-react"; +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"; @@ -17,6 +17,33 @@ interface ProjectDialogProps { 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; @@ -33,6 +60,8 @@ interface FormState { repoName: string; autoSync: boolean; progressFromTasks: boolean; + /** Nur beim Anlegen: aus welcher Vorlage Notizen und Aufgaben kommen. */ + templateId: string; } const EMPTY: FormState = { @@ -51,6 +80,7 @@ const EMPTY: FormState = { repoName: "", autoSync: true, progressFromTasks: false, + templateId: "", }; export function ProjectDialog({ open, onClose, connections, project }: ProjectDialogProps) { @@ -60,14 +90,72 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi 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); - const isEdit = Boolean(project); - useEffect(() => { if (!open) return; setError(null); @@ -96,6 +184,7 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi repoName: project.repositories[0]?.repoName ?? "", autoSync: project.repositories[0]?.autoSync ?? true, progressFromTasks: project.progressFromTasks, + templateId: "", } : EMPTY, ); @@ -165,6 +254,8 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi 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 { @@ -203,6 +294,52 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi } >
+ {/* 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} +
+ + + ); +} diff --git a/src/lib/validation.ts b/src/lib/validation.ts index c25c517..4129d7d 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -3,6 +3,7 @@ import { z } from "zod"; const STATUS = ["IDEA", "OPEN", "PLANNING", "IN_DEVELOPMENT", "DONE", "ARCHIVED"] as const; const PROVIDERS = ["GITHUB", "GITLAB", "GITEA"] as const; const ACCENTS = ["violet", "cyan", "emerald", "amber", "rose", "blue"] as const; +const TASK_STATUS = ["TODO", "DOING", "BLOCKED", "DONE"] as const; const emptyToNull = (value: unknown) => typeof value === "string" && value.trim() === "" ? null : value; @@ -31,9 +32,17 @@ export const projectCreateSchema = z.object({ autoSync: z.boolean().default(true), /** Fortschritt aus dem Verhaeltnis erledigter Aufgaben berechnen. */ progressFromTasks: z.boolean().default(false), + /** + * Aus welcher Vorlage. Die Stammdaten hat die Oberflaeche bereits in die + * Felder oben uebernommen – hier zaehlen nur noch Notizen und Aufgaben, + * die sich im Dialog nicht darstellen lassen. + */ + templateId: z.preprocess(emptyToNull, z.string().trim().nullable().optional()), }); -export const projectUpdateSchema = projectCreateSchema.partial(); +// templateId gilt nur beim Anlegen. Ein bestehendes Projekt nachtraeglich +// "aus einer Vorlage" zu befuellen wuerde Notizen und Aufgaben verdoppeln. +export const projectUpdateSchema = projectCreateSchema.partial().omit({ templateId: true }); /** * Neue Reihenfolge im Brett. Uebergeben wird je betroffener Spalte die @@ -70,6 +79,50 @@ export const projectBulkSchema = z path: ["favorite"], }); +// ── Projektvorlagen ────────────────────────────────────────── + +const templateNoteSchema = z.object({ + title: z.preprocess(emptyToNull, z.string().trim().max(160).nullable().optional()), + content: z.string().trim().min(1).max(20000), + pinned: z.boolean().optional().default(false), +}); + +const templateTaskSchema = z.object({ + title: z.string().trim().min(1).max(200), + description: z.preprocess(emptyToNull, z.string().trim().max(5000).nullable().optional()), + status: z.enum(TASK_STATUS).optional().default("TODO"), + labels: z.array(z.string().trim().min(1).max(30)).max(8).optional().default([]), +}); + +export const templateCreateSchema = z.object({ + name: z.string().trim().min(1, "Name darf nicht leer sein").max(80), + description: z.preprocess(emptyToNull, z.string().trim().max(280).nullable().optional()), + summary: z.preprocess(emptyToNull, z.string().trim().max(280).nullable().optional()), + projectDescription: z.preprocess( + emptyToNull, + z.string().trim().max(20000).nullable().optional(), + ), + status: z.enum(STATUS).default("IDEA"), + priority: z.coerce.number().int().min(1).max(4).default(2), + accent: z.enum(ACCENTS).default("violet"), + tags: z.array(z.string().trim().min(1).max(30)).max(12).default([]), + progressFromTasks: z.boolean().default(false), + notes: z.array(templateNoteSchema).max(50).default([]), + tasks: z.array(templateTaskSchema).max(100).default([]), +}); + +export const templateUpdateSchema = templateCreateSchema.partial(); + +/** Aus einem vorhandenen Projekt eine Vorlage machen. */ +export const templateFromProjectSchema = z.object({ + projectId: z.string().min(1), + name: z.string().trim().min(1).max(80), + description: z.preprocess(emptyToNull, z.string().trim().max(280).nullable().optional()), + /** Notizen und Aufgaben mitnehmen – oder nur die Stammdaten. */ + withNotes: z.boolean().default(true), + withTasks: z.boolean().default(true), +}); + // ── Repositories ───────────────────────────────────────────── export const repositoryCreateSchema = z.object({ @@ -88,8 +141,6 @@ export const repositoryUpdateSchema = repositoryCreateSchema.partial().extend({ // ── Aufgaben ───────────────────────────────────────────────── -const TASK_STATUS = ["TODO", "DOING", "BLOCKED", "DONE"] as const; - const dueDateField = z.preprocess( emptyToNull, z