feat: Wiederkehrende Aufgaben-Vorlagen (recurring task templates)

Neue TaskTemplate-Entität mit YEARLY/MONTHLY/INTERVAL_DAYS-Wiederholung.
Fällige Aufgaben werden lazy bei GET /tasks erzeugt (Compare-and-Swap auf
nextDueDate), damit mehrere API-Replicas im Kubernetes-Cluster dieselbe
Vorlage nicht doppelt feuern - ohne zusätzliche Cron-Abhängigkeit.

Frontend: Verwaltungsseite unter /settings/task-templates zum Anlegen,
Auflisten und Deaktivieren von Vorlagen.
This commit is contained in:
Nick Szaunig committed 2026-08-25 05:36:55 +02:00
1 parent e458a7cf07
commit 4609a0f9ec
20 files changed
+1341

No files matched your search

@@ -0,0 +1,206 @@
"use client";
import { FormEvent, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createTaskTemplate } from "@/lib/task-templates/api";
import { TaskTemplateRecurrenceType } from "@/lib/task-templates/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
const RECURRENCE_LABELS: Record<TaskTemplateRecurrenceType, string> = {
YEARLY: "Jährlich",
MONTHLY: "Monatlich",
INTERVAL_DAYS: "Alle X Tage",
};
const MONTH_LABELS = [
"Januar",
"Februar",
"März",
"April",
"Mai",
"Juni",
"Juli",
"August",
"September",
"Oktober",
"November",
"Dezember",
];
export function CreateTaskTemplateDialog() {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [recurrenceType, setRecurrenceType] = useState<TaskTemplateRecurrenceType>("MONTHLY");
const [recurrenceMonth, setRecurrenceMonth] = useState("1");
const [recurrenceDayOfMonth, setRecurrenceDayOfMonth] = useState("1");
const [intervalDays, setIntervalDays] = useState("14");
const [error, setError] = useState<string | null>(null);
function reset() {
setTitle("");
setDescription("");
setRecurrenceType("MONTHLY");
setRecurrenceMonth("1");
setRecurrenceDayOfMonth("1");
setIntervalDays("14");
setError(null);
}
const mutation = useMutation({
mutationFn: createTaskTemplate,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["task-templates"] });
setOpen(false);
reset();
},
onError: () => setError("Vorlage konnte nicht angelegt werden."),
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
mutation.mutate({
title,
description: description || undefined,
recurrenceType,
recurrenceMonth: recurrenceType === "YEARLY" ? Number(recurrenceMonth) : undefined,
recurrenceDayOfMonth:
recurrenceType === "YEARLY" || recurrenceType === "MONTHLY"
? Number(recurrenceDayOfMonth)
: undefined,
intervalDays: recurrenceType === "INTERVAL_DAYS" ? Number(intervalDays) : undefined,
});
}
return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) reset();
}}
>
<Button onClick={() => setOpen(true)}>+ Neue Vorlage</Button>
<DialogContent>
<DialogHeader>
<DialogTitle>Neue wiederkehrende Aufgabe</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="template-title">Titel</Label>
<Input
id="template-title"
required
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="template-description">Beschreibung</Label>
<Textarea
id="template-description"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="template-recurrence">Wiederholung</Label>
<Select
value={recurrenceType}
onValueChange={(value) =>
setRecurrenceType((value ?? recurrenceType) as TaskTemplateRecurrenceType)
}
>
<SelectTrigger id="template-recurrence" className="w-full">
<SelectValue>{RECURRENCE_LABELS[recurrenceType]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(RECURRENCE_LABELS) as TaskTemplateRecurrenceType[]).map((option) => (
<SelectItem key={option} value={option}>
{RECURRENCE_LABELS[option]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{recurrenceType === "YEARLY" ? (
<div className="flex flex-col gap-2">
<Label htmlFor="template-month">Monat</Label>
<Select
value={recurrenceMonth}
onValueChange={(value) => setRecurrenceMonth(value ?? recurrenceMonth)}
>
<SelectTrigger id="template-month" className="w-full">
<SelectValue>{MONTH_LABELS[Number(recurrenceMonth) - 1]}</SelectValue>
</SelectTrigger>
<SelectContent>
{MONTH_LABELS.map((label, index) => (
<SelectItem key={label} value={String(index + 1)}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
{recurrenceType === "YEARLY" || recurrenceType === "MONTHLY" ? (
<div className="flex flex-col gap-2">
<Label htmlFor="template-day">Tag im Monat</Label>
<Input
id="template-day"
type="number"
min={1}
max={31}
required
value={recurrenceDayOfMonth}
onChange={(event) => setRecurrenceDayOfMonth(event.target.value)}
/>
</div>
) : null}
{recurrenceType === "INTERVAL_DAYS" ? (
<div className="flex flex-col gap-2">
<Label htmlFor="template-interval">Alle wie viele Tage</Label>
<Input
id="template-interval"
type="number"
min={1}
required
value={intervalDays}
onChange={(event) => setIntervalDays(event.target.value)}
/>
</div>
) : null}
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
) : null}
<DialogFooter>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Wird angelegt…" : "Anlegen"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,120 @@
"use client";
import { Repeat } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { deactivateTaskTemplate, fetchTaskTemplates } from "@/lib/task-templates/api";
import { TaskTemplate, TaskTemplateRecurrenceType } from "@/lib/task-templates/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/page-header";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { CreateTaskTemplateDialog } from "./create-task-template-dialog";
const RECURRENCE_LABELS: Record<TaskTemplateRecurrenceType, string> = {
YEARLY: "Jährlich",
MONTHLY: "Monatlich",
INTERVAL_DAYS: "Alle X Tage",
};
function describeRecurrence(template: TaskTemplate): string {
if (template.recurrenceType === "YEARLY") {
return `${RECURRENCE_LABELS.YEARLY} (Tag ${template.recurrenceDayOfMonth ?? "?"}.${template.recurrenceMonth ?? "?"}.)`;
}
if (template.recurrenceType === "MONTHLY") {
return `${RECURRENCE_LABELS.MONTHLY} (Tag ${template.recurrenceDayOfMonth ?? "?"})`;
}
return `Alle ${template.intervalDays ?? "?"} Tage`;
}
function formatDate(value: string): string {
return new Date(value).toLocaleDateString("de-DE");
}
export default function TaskTemplatesPage() {
const queryClient = useQueryClient();
const { data: templates, isLoading, isError } = useQuery({
queryKey: ["task-templates"],
queryFn: fetchTaskTemplates,
});
const deactivateMutation = useMutation({
mutationFn: deactivateTaskTemplate,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["task-templates"] });
toast.success("Vorlage deaktiviert");
},
onError: () => toast.error("Vorlage konnte nicht deaktiviert werden"),
});
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Repeat}
title="Wiederkehrende Aufgaben"
description="Vorlagen für regelmäßig automatisch angelegte Aufgaben verwalten"
gradient="var(--gradient-sky)"
actions={<CreateTaskTemplateDialog />}
/>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError ? (
<p role="alert" className="text-sm text-destructive">
Vorlagen konnten nicht geladen werden.
</p>
) : null}
{templates && templates.length === 0 ? (
<p className="text-sm text-muted-foreground">Noch keine Vorlagen angelegt.</p>
) : null}
{templates && templates.length > 0 ? (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Titel</TableHead>
<TableHead>Wiederholung</TableHead>
<TableHead>Nächste Fälligkeit</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Aktion</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{templates.map((template) => (
<TableRow key={template.id}>
<TableCell className="font-medium">{template.title}</TableCell>
<TableCell>{describeRecurrence(template)}</TableCell>
<TableCell>{formatDate(template.nextDueDate)}</TableCell>
<TableCell>
<Badge variant={template.active ? "default" : "outline"}>
{template.active ? "Aktiv" : "Inaktiv"}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button
type="button"
variant="ghost"
size="sm"
disabled={!template.active || deactivateMutation.isPending}
onClick={() => deactivateMutation.mutate(template.id)}
>
Deaktivieren
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : null}
</main>
);
}
@@ -16,6 +16,7 @@ import {
type LucideIcon,
MapPin,
Package,
Repeat,
Search,
ShoppingBag,
Upload,
@@ -85,6 +86,7 @@ const ADMIN_GROUP: NavGroup = {
{ label: "Audit-Log", href: "/settings/audit-log", icon: History },
{ label: "Webhooks", href: "/settings/webhooks", icon: Webhook },
{ label: "Automatisierungen", href: "/settings/automation-rules", icon: Zap },
{ label: "Wiederkehrende Aufgaben", href: "/settings/task-templates", icon: Repeat },
{ label: "Import", href: "/settings/import", icon: Upload },
],
};
@@ -0,0 +1,24 @@
import { apiFetch } from "@/lib/auth/api-client";
import { CreateTaskTemplateInput, TaskTemplate, UpdateTaskTemplateInput } from "./types";
export function fetchTaskTemplates(): Promise<TaskTemplate[]> {
return apiFetch<TaskTemplate[]>("/task-templates");
}
export function createTaskTemplate(input: CreateTaskTemplateInput): Promise<TaskTemplate> {
return apiFetch<TaskTemplate>("/task-templates", {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateTaskTemplate(id: string, input: UpdateTaskTemplateInput): Promise<TaskTemplate> {
return apiFetch<TaskTemplate>(`/task-templates/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function deactivateTaskTemplate(id: string): Promise<TaskTemplate> {
return apiFetch<TaskTemplate>(`/task-templates/${id}/deactivate`, { method: "POST" });
}
@@ -0,0 +1,43 @@
import { TaskPriority } from "@/lib/tasks/types";
export type TaskTemplateRecurrenceType = "YEARLY" | "MONTHLY" | "INTERVAL_DAYS";
export interface TaskTemplate {
id: string;
title: string;
description: string | null;
priority: TaskPriority;
assignedUserId: string | null;
createdByUserId: string | null;
recurrenceType: TaskTemplateRecurrenceType;
recurrenceMonth: number | null;
recurrenceDayOfMonth: number | null;
intervalDays: number | null;
nextDueDate: string;
active: boolean;
createdAt: string;
updatedAt: string;
}
export interface CreateTaskTemplateInput {
title: string;
description?: string;
priority?: TaskPriority;
assignedUserId?: string;
recurrenceType: TaskTemplateRecurrenceType;
recurrenceMonth?: number;
recurrenceDayOfMonth?: number;
intervalDays?: number;
}
export interface UpdateTaskTemplateInput {
title?: string;
description?: string;
priority?: TaskPriority;
assignedUserId?: string;
recurrenceType?: TaskTemplateRecurrenceType;
recurrenceMonth?: number;
recurrenceDayOfMonth?: number;
intervalDays?: number;
active?: boolean;
}