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:
1 parent
e458a7cf07
commit
4609a0f9ec
20 files changed
+1341
No files matched your search
+206
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user