Merge pull request #128 from Since-3/feature/standing-order-courses

Feature/standing order courses
This commit is contained in:
Rajpreet0 2026-06-27 23:05:11 +02:00 • committed by GitHub
commit da330cc206
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 678 additions and 111 deletions

View file

@ -0,0 +1,17 @@
-- Migration: add_standing_order_instances
-- Adds endDate, weekdayTimings, and parentCourseId (self-relation) to Course
ALTER TABLE "Course"
ADD COLUMN "weekdayTimings" JSONB,
ADD COLUMN "endDate" TIMESTAMP(3),
ADD COLUMN "parentCourseId" TEXT;
ALTER TABLE "Course"
ADD CONSTRAINT "Course_parentCourseId_fkey"
FOREIGN KEY ("parentCourseId")
REFERENCES "Course"("id")
ON DELETE CASCADE
ON UPDATE CASCADE;
CREATE INDEX "Course_parentCourseId_idx" ON "Course"("parentCourseId");
CREATE INDEX "Course_parentCourseId_date_idx" ON "Course"("parentCourseId", "date");

View file

@ -125,6 +125,11 @@ model Course {
isStandingOrder Boolean @default(false)
frequency String? // daily, weekly, biweekly, monthly, custom
weekdays String[] // For custom frequency
weekdayTimings Json? // { "monday": { "timeFrom": "08:00", "timeTo": "09:00" }, ... }
endDate DateTime? // Required when isStandingOrder = true
parentCourseId String? // Null on template; set on every child instance
parentCourse Course? @relation("StandingOrderInstances", fields: [parentCourseId], references: [id], onDelete: Cascade)
children Course[] @relation("StandingOrderInstances")
status String @default("draft") // draft, published
createdBy String // Manager ID who created the course
businessId String?
@ -138,6 +143,8 @@ model Course {
@@index([date])
@@index([status])
@@index([businessId])
@@index([parentCourseId])
@@index([parentCourseId, date])
}
model Room {

View file

@ -14,6 +14,9 @@ import { courseSchema, publishedCourseSchema, type CourseFormData } from "../sch
import { revalidatePath } from "next/cache";
import { Prisma } from "@prisma/client";
import { generateUniqueSlug } from "@/src/lib/slug";
import { generateOccurrences, type FrequencyType } from "../utils/generate-instances";
type StandingOrderScope = "this" | "this_and_following" | "all";
/**
* Erstellt einen neuen Kurs in der Datenbank
@ -67,31 +70,104 @@ export async function createCourse(data: CourseFormData, status: "draft" | "publ
if (!creatorId)
return { success: false, error: "Mitarbeiter-Account ist keinem Manager zugeordnet" };
// Schritt 6: Kurs in der Datenbank erstellen
const course = await prisma.course.create({
data: {
name: validatedData.name || "",
sport: validatedData.sport || [],
level: validatedData.level || "any",
date: new Date(validatedData.date || new Date()),
timeFrom: validatedData.timeFrom || "",
timeTo: validatedData.timeTo || "",
trainers: validatedData.trainers || [],
room: validatedData.room || "",
description: validatedData.description || "",
coverImage: validatedData.coverImage ?? null,
maxParticipants: validatedData.maxParticipants || 0,
price: validatedData.price ?? 0,
isStandingOrder: validatedData.isStandingOrder,
frequency: validatedData.frequency || null,
weekdays: validatedData.weekdays || [],
status,
createdBy: creatorId,
businessId: validatedData.businessId ?? null,
},
});
// Schritt 6: Kurs erstellen — bei Dauerauftrag Template + Instanzen, sonst einzelner Kurs
const commonFields = {
name: validatedData.name || "",
sport: validatedData.sport || [],
level: validatedData.level || "any",
timeFrom: validatedData.timeFrom || "",
timeTo: validatedData.timeTo || "",
trainers: validatedData.trainers || [],
room: validatedData.room || "",
description: validatedData.description || "",
coverImage: validatedData.coverImage ?? null,
maxParticipants: validatedData.maxParticipants || 0,
price: validatedData.price ?? 0,
frequency: validatedData.frequency || null,
weekdays: validatedData.weekdays || [],
weekdayTimings: validatedData.weekdayTimings
? (validatedData.weekdayTimings as Prisma.InputJsonValue)
: Prisma.JsonNull,
status,
createdBy: creatorId,
businessId: validatedData.businessId ?? null,
};
// Schritt 6: Next.js Cache für /courses Seite invalidieren, damit neue Daten angezeigt werden
let course: { id: string };
const isCompleteStandingOrder =
validatedData.isStandingOrder && validatedData.frequency && validatedData.endDate;
if (isCompleteStandingOrder) {
// Vollständiger Dauerauftrag: Template + Instanzen in einer Transaction
const startDate = new Date(validatedData.date || new Date());
const endDate = new Date(validatedData.endDate!);
course = await prisma.$transaction(async (tx) => {
const template = await tx.course.create({
data: {
...commonFields,
date: startDate,
endDate,
isStandingOrder: true,
parentCourseId: null,
},
});
const occurrences = generateOccurrences({
startDate,
endDate,
frequency: validatedData.frequency as FrequencyType,
defaultTimeFrom: validatedData.timeFrom || "",
defaultTimeTo: validatedData.timeTo || "",
weekdays: validatedData.weekdays || [],
weekdayTimings:
(validatedData.weekdayTimings as Record<
string,
{ timeFrom: string; timeTo: string }
>) ?? {},
});
if (occurrences.length > 0) {
await tx.course.createMany({
data: occurrences.map((o) => ({
...commonFields,
date: o.date,
timeFrom: o.timeFrom,
timeTo: o.timeTo,
endDate: null,
isStandingOrder: false,
parentCourseId: template.id,
})),
});
}
return template;
});
} else if (validatedData.isStandingOrder) {
// Unvollständiger Dauerauftrag-Entwurf: als Template ohne Instanzen speichern
course = await prisma.course.create({
data: {
...commonFields,
date: new Date(validatedData.date || new Date()),
endDate: validatedData.endDate ? new Date(validatedData.endDate) : null,
isStandingOrder: true,
parentCourseId: null,
},
});
} else {
course = await prisma.course.create({
data: {
...commonFields,
date: new Date(validatedData.date || new Date()),
endDate: null,
isStandingOrder: false,
parentCourseId: null,
},
});
}
// Next.js Cache für /courses Seite invalidieren
revalidatePath("/courses");
return {
@ -187,6 +263,7 @@ export async function getMyCourses(options?: { dateFrom?: Date; dateTo?: Date })
// Step 4: Build the where clause with optional date filtering
const whereClause: Prisma.CourseWhereInput = {
createdBy: managerId,
isStandingOrder: false, // Templates ausblenden — nur Instanzen anzeigen
};
// Add date range filter if provided
@ -310,7 +387,8 @@ export async function getCourseById(courseId: string) {
export async function updateCourse(
courseId: string,
data: CourseFormData,
status: "draft" | "published"
status: "draft" | "published",
scope: StandingOrderScope = "this"
) {
try {
// Schritt 1: Aktuellen Benutzer abrufen
@ -374,31 +452,63 @@ export async function updateCourse(
const validatedData = validation.data;
// Schritt 8: Kurs in der Datenbank aktualisieren
await prisma.course.update({
where: { id: courseId },
data: {
name: validatedData.name || "",
sport: validatedData.sport || [],
level: validatedData.level || "any",
date: new Date(validatedData.date || new Date()),
timeFrom: validatedData.timeFrom || "",
timeTo: validatedData.timeTo || "",
trainers: validatedData.trainers || [],
room: validatedData.room || "",
description: validatedData.description || "",
coverImage: validatedData.coverImage ?? null,
maxParticipants: validatedData.maxParticipants || 0,
price: validatedData.price ?? 0,
isStandingOrder: validatedData.isStandingOrder,
frequency: validatedData.frequency || null,
weekdays: validatedData.weekdays || [],
status,
...(validatedData.businessId !== undefined
? { businessId: validatedData.businessId || null }
: {}),
},
});
// Felder, die auf Instanzen angewendet werden (ohne date/timeFrom/timeTo)
const sharedUpdateFields = {
name: validatedData.name || "",
sport: validatedData.sport || [],
level: validatedData.level || "any",
trainers: validatedData.trainers || [],
room: validatedData.room || "",
description: validatedData.description || "",
coverImage: validatedData.coverImage ?? null,
maxParticipants: validatedData.maxParticipants || 0,
price: validatedData.price ?? 0,
status,
...(validatedData.businessId !== undefined
? { businessId: validatedData.businessId || null }
: {}),
};
const isTemplate = !existingCourse.parentCourseId;
const templateId = isTemplate ? existingCourse.id : existingCourse.parentCourseId!;
// Schritt 8: Kurs aktualisieren je nach Scope
if (scope === "this") {
await prisma.course.update({
where: { id: courseId },
data: {
...sharedUpdateFields,
date: new Date(validatedData.date || existingCourse.date),
timeFrom: validatedData.timeFrom || "",
timeTo: validatedData.timeTo || "",
},
});
} else if (scope === "this_and_following") {
await prisma.course.updateMany({
where: { parentCourseId: templateId, date: { gte: existingCourse.date } },
data: sharedUpdateFields,
});
} else if (scope === "all") {
if (isTemplate) {
await prisma.course.update({
where: { id: courseId },
data: {
...sharedUpdateFields,
isStandingOrder: validatedData.isStandingOrder,
frequency: validatedData.frequency || null,
weekdays: validatedData.weekdays || [],
weekdayTimings: validatedData.weekdayTimings
? (validatedData.weekdayTimings as Prisma.InputJsonValue)
: Prisma.JsonNull,
endDate: validatedData.endDate ? new Date(validatedData.endDate) : null,
},
});
}
await prisma.course.updateMany({
where: { parentCourseId: templateId },
data: sharedUpdateFields,
});
}
// Schritt 9: Next.js Cache für /courses Seite invalidieren
revalidatePath("/courses");
@ -431,7 +541,7 @@ export async function updateCourse(
* @param courseId - Die eindeutige ID des zu löschenden Kurses
* @returns Ein Objekt mit success-Flag und Nachricht oder Fehler
*/
export async function deleteCourse(courseId: string) {
export async function deleteCourse(courseId: string, scope: StandingOrderScope = "this") {
try {
// Schritt 1: Aktuellen Benutzer abrufen
const userData = await getUserData();
@ -473,12 +583,22 @@ export async function deleteCourse(courseId: string) {
};
}
// Schritt 7: Kurs aus der Datenbank löschen
await prisma.course.delete({
where: { id: courseId },
});
// Schritt 7: Kurs löschen je nach Scope
const isTemplate = !course.parentCourseId;
const templateId = isTemplate ? course.id : course.parentCourseId!;
// Schritt 8: Next.js Cache invalidieren, damit gelöschter Kurs nicht mehr angezeigt wird
if (scope === "this") {
await prisma.course.delete({ where: { id: courseId } });
} else if (scope === "this_and_following") {
await prisma.course.deleteMany({
where: { parentCourseId: templateId, date: { gte: course.date } },
});
} else if (scope === "all") {
// Cascade löscht automatisch alle Kinder
await prisma.course.delete({ where: { id: templateId } });
}
// Schritt 8: Next.js Cache invalidieren
revalidatePath("/courses");
return {

View file

@ -2,6 +2,7 @@
import { useState } from "react";
import { deleteCourse } from "../actions/course-actions";
import { toast } from "sonner";
import type { StandingOrderScope } from "../ui/components/StandingOrderScopeDialog";
interface UseDeleteCourseOptions {
onSuccess?: (deletedId: string) => void;
@ -9,11 +10,24 @@ interface UseDeleteCourseOptions {
export function useDeleteCourse({ onSuccess }: UseDeleteCourseOptions = {}) {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [scopeDialogOpen, setScopeDialogOpen] = useState(false);
const [courseToDelete, setCourseToDelete] = useState<{ id: string; name: string } | null>(null);
const [deleteScope, setDeleteScope] = useState<StandingOrderScope>("this");
const [isDeleting, setIsDeleting] = useState(false);
const handleDeleteClick = (id: string, name: string) => {
const handleDeleteClick = (id: string, name: string, isStandingOrderRelated = false) => {
setDeleteScope("this");
setCourseToDelete({ id, name });
if (isStandingOrderRelated) {
setScopeDialogOpen(true);
} else {
setDeleteDialogOpen(true);
}
};
const handleScopeConfirm = (scope: StandingOrderScope) => {
setDeleteScope(scope);
setScopeDialogOpen(false);
setDeleteDialogOpen(true);
};
@ -21,7 +35,7 @@ export function useDeleteCourse({ onSuccess }: UseDeleteCourseOptions = {}) {
if (!courseToDelete) return;
setIsDeleting(true);
const result = await deleteCourse(courseToDelete.id);
const result = await deleteCourse(courseToDelete.id, deleteScope);
setIsDeleting(false);
setDeleteDialogOpen(false);
@ -29,9 +43,11 @@ export function useDeleteCourse({ onSuccess }: UseDeleteCourseOptions = {}) {
toast.success(result.message);
const deletedId = courseToDelete.id;
setCourseToDelete(null);
setDeleteScope("this");
onSuccess?.(deletedId);
} else {
setCourseToDelete(null);
setDeleteScope("this");
toast.error(result.error || "Fehler beim Löschen des Kurses");
}
};
@ -39,10 +55,13 @@ export function useDeleteCourse({ onSuccess }: UseDeleteCourseOptions = {}) {
return {
deleteDialogOpen,
setDeleteDialogOpen,
scopeDialogOpen,
setScopeDialogOpen,
courseToDelete,
setCourseToDelete,
isDeleting,
handleDeleteClick,
handleScopeConfirm,
handleDeleteConfirm,
};
}

View file

@ -1,4 +1,22 @@
import { z } from "zod";
import { VALID_FREQUENCIES } from "../utils/generate-instances";
const frequencyEnum = z.enum(VALID_FREQUENCIES as [string, ...string[]], {
message: "Ungültige Häufigkeit",
});
const weekdayTimingsSchema = z
.record(z.string(), z.object({ timeFrom: z.string(), timeTo: z.string() }))
.refine(
(timings) =>
Object.values(timings).every(
({ timeFrom, timeTo }) => !timeFrom || !timeTo || timeFrom < timeTo
),
{ message: "Endzeit muss nach der Anfangszeit liegen (Wochentag-Uhrzeiten)" }
)
.optional();
const isoDateOptional = z.union([z.iso.date(), z.literal("")]).optional();
// Basis-Schema mit allen Feldern optional — für Entwürfe
export const courseSchema = z
@ -6,7 +24,7 @@ export const courseSchema = z
name: z.string().min(1, "Kursname ist erforderlich").optional().or(z.literal("")),
sport: z.array(z.string()).optional(),
level: z.string().optional(),
date: z.string().optional().or(z.literal("")),
date: isoDateOptional,
timeFrom: z.string().optional().or(z.literal("")),
timeTo: z.string().optional().or(z.literal("")),
trainers: z.array(z.string()).optional(),
@ -16,21 +34,18 @@ export const courseSchema = z
maxParticipants: z.number().max(100).optional(),
price: z.number().max(9999.99).optional(),
isStandingOrder: z.boolean(),
frequency: z.string().optional(),
frequency: frequencyEnum.optional(),
weekdays: z.array(z.string()).optional(),
weekdayTimings: weekdayTimingsSchema,
endDate: isoDateOptional,
businessId: z.string().optional(),
})
.refine(
(data) => {
if (data.timeFrom && data.timeTo && data.timeFrom >= data.timeTo) {
return false;
}
if (data.timeFrom && data.timeTo && data.timeFrom >= data.timeTo) return false;
return true;
},
{
message: "Endzeit muss nach der Anfangszeit liegen",
path: ["timeTo"],
}
{ message: "Endzeit muss nach der Anfangszeit liegen", path: ["timeTo"] }
);
// Strenges Schema für veröffentlichte Kurse — alle Pflichtfelder erforderlich
@ -39,7 +54,7 @@ export const publishedCourseSchema = z
name: z.string().min(1, "Kursname ist erforderlich"),
sport: z.array(z.string()).min(1, "Mindestens eine Sportart ist erforderlich"),
level: z.string().optional(),
date: z.string().min(1, "Datum ist erforderlich"),
date: z.iso.date().min(1, "Datum ist erforderlich"),
timeFrom: z.string().min(1, "Anfangszeit ist erforderlich"),
timeTo: z.string().min(1, "Endzeit ist erforderlich"),
trainers: z
@ -58,8 +73,10 @@ export const publishedCourseSchema = z
.min(0, "Preis muss eine gültige positive Zahl sein")
.max(9999.99, "Preis darf maximal 9.999,99 € betragen"),
isStandingOrder: z.boolean(),
frequency: z.string().optional(),
frequency: frequencyEnum.optional(),
weekdays: z.array(z.string()).optional(),
weekdayTimings: weekdayTimingsSchema,
endDate: isoDateOptional,
businessId: z.string().optional(),
})
.refine(
@ -69,6 +86,22 @@ export const publishedCourseSchema = z
},
{ message: "Häufigkeit ist erforderlich bei Daueraufträgen", path: ["frequency"] }
)
.refine(
(data) => {
if (data.isStandingOrder && !data.endDate) return false;
return true;
},
{ message: "Enddatum ist erforderlich bei Daueraufträgen", path: ["endDate"] }
)
.refine(
(data) => {
if (data.isStandingOrder && data.date && data.endDate) {
return new Date(data.endDate) > new Date(data.date);
}
return true;
},
{ message: "Enddatum muss nach dem Startdatum liegen", path: ["endDate"] }
)
.refine(
(data) => {
if (data.frequency === "custom" && (!data.weekdays || data.weekdays.length === 0))

View file

@ -14,6 +14,9 @@ export type Course = {
isStandingOrder: boolean;
frequency: string | null;
weekdays: string[];
weekdayTimings: Record<string, { timeFrom: string; timeTo: string }> | null;
endDate: Date | null;
parentCourseId: string | null;
status: string;
createdBy: string;
businessId: string | null;

View file

@ -0,0 +1,81 @@
"use client";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/src/components/ui/dialog";
import { Button } from "@/src/components/ui/button";
export type StandingOrderScope = "this" | "this_and_following" | "all";
interface StandingOrderScopeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (scope: StandingOrderScope) => void;
action: "edit" | "delete";
}
const LABELS = {
edit: {
title: "Dauerauftrag bearbeiten",
description: "Welche Termine sollen geändert werden?",
this: "Nur diesen Termin",
this_and_following: "Diesen und alle folgenden Termine",
all: "Alle Termine",
},
delete: {
title: "Dauerauftrag löschen",
description: "Welche Termine sollen gelöscht werden?",
this: "Nur diesen Termin",
this_and_following: "Diesen und alle folgenden Termine",
all: "Alle Termine",
},
} as const;
export function StandingOrderScopeDialog({
open,
onOpenChange,
onConfirm,
action,
}: StandingOrderScopeDialogProps) {
const labels = LABELS[action];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{labels.title}</DialogTitle>
<DialogDescription>{labels.description}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3 mt-2">
<Button variant="outline" onClick={() => onConfirm("this")} className="justify-start">
{labels.this}
</Button>
<Button
variant="outline"
onClick={() => onConfirm("this_and_following")}
className="justify-start"
>
{labels.this_and_following}
</Button>
<Button
variant={action === "delete" ? "destructive" : "default"}
onClick={() => onConfirm("all")}
className="justify-start"
>
{labels.all}
</Button>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Abbrechen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -17,6 +17,12 @@ import { Trash2, ImagePlus, X } from "lucide-react";
import Image from "next/image";
import DeleteDialog from "@/src/components/layout/DeleteDialog";
import { useDeleteCourse } from "../../hooks/useDeleteCourse";
import {
StandingOrderScopeDialog,
type StandingOrderScope,
} from "../components/StandingOrderScopeDialog";
type WeekdayTiming = { timeFrom: string; timeTo: string };
const LEVELS = [
{ value: "any", label: "Jedes Niveau" },
@ -105,6 +111,27 @@ const CourseCreateView = ({
const [selectedWeekdays, setSelectedWeekdays] = useState<string[]>(
isEdit ? initialData!.weekdays || [] : []
);
const [weekdayTimings, setWeekdayTimings] = useState<Record<string, WeekdayTiming>>(() => {
if (isEdit && initialData!.weekdayTimings) {
return initialData!.weekdayTimings as Record<string, WeekdayTiming>;
}
return {};
});
const [endDate, setEndDate] = useState(() => {
if (isEdit && initialData!.endDate) {
const d = new Date(initialData!.endDate);
return [
d.getFullYear(),
String(d.getMonth() + 1).padStart(2, "0"),
String(d.getDate()).padStart(2, "0"),
].join("-");
}
return "";
});
const [editScopeDialogOpen, setEditScopeDialogOpen] = useState(false);
const [pendingSubmitStatus, setPendingSubmitStatus] = useState<"draft" | "published" | null>(
null
);
const [price, setPrice] = useState(isEdit ? initialData!.price?.toString() || "" : "");
const [priceDisplay, setPriceDisplay] = useState("");
const [isPriceFocused, setIsPriceFocused] = useState(false);
@ -123,13 +150,19 @@ const CourseCreateView = ({
);
const [isUploadingImage, setIsUploadingImage] = useState(false);
const isStandingOrderRelated =
isEdit && !!(initialData?.parentCourseId || initialData?.isStandingOrder);
const {
deleteDialogOpen,
setDeleteDialogOpen,
scopeDialogOpen: deleteScopeDialogOpen,
setScopeDialogOpen: setDeleteScopeDialogOpen,
courseToDelete,
setCourseToDelete,
isDeleting,
handleDeleteClick,
handleScopeConfirm: handleDeleteScopeConfirm,
handleDeleteConfirm,
} = useDeleteCourse({
onSuccess: () => router.push("/courses"),
@ -235,9 +268,20 @@ const CourseCreateView = ({
if (isStandingOrder) {
if (!selectedFrequency)
newErrors.frequency = "Häufigkeit ist erforderlich bei Daueraufträgen";
if (!endDate) newErrors.endDate = "Enddatum ist erforderlich bei Daueraufträgen";
if (endDate && courseDate && endDate <= courseDate)
newErrors.endDate = "Enddatum muss nach dem Startdatum liegen";
if (selectedFrequency === "custom" && selectedWeekdays.length === 0) {
newErrors.weekdays = "Mindestens ein Tag muss ausgewählt werden";
}
if (selectedFrequency === "custom" && selectedWeekdays.length > 0) {
const hasInvalidTiming = selectedWeekdays.some((day) => {
const t = weekdayTimings[day];
return !t?.timeFrom || !t?.timeTo || t.timeFrom >= t.timeTo;
});
if (hasInvalidTiming)
newErrors.weekdayTimings = "Bitte gültige Uhrzeiten für alle Wochentage eingeben";
}
}
setErrors(newErrors);
@ -251,45 +295,37 @@ const CourseCreateView = ({
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (status: "draft" | "published") => {
if (status === "published" && !validateForm()) {
toast.error("Bitte füllen Sie alle erforderlichen Felder aus");
return;
}
if (status === "draft" && !validateDraft()) {
toast.error("Bitte geben Sie mindestens einen Kursnamen ein");
return;
}
const buildCourseData = () => ({
name: courseName,
sport: selectedSports,
level: selectedLevel,
date: courseDate,
timeFrom: timeFrom,
timeTo: timeTo,
trainers: selectedTrainers.length > 0 ? selectedTrainers : undefined,
room: selectedRoom,
description,
coverImage: coverImage ?? undefined,
maxParticipants: maxParticipants ? parseInt(maxParticipants) : undefined,
price: price ? parseFloat(price) : undefined,
isStandingOrder,
frequency: selectedFrequency || undefined,
weekdays: selectedWeekdays.length > 0 ? selectedWeekdays : undefined,
weekdayTimings: Object.keys(weekdayTimings).length > 0 ? weekdayTimings : undefined,
endDate: endDate || undefined,
businessId: selectedBusinessId || undefined,
});
const doSubmit = async (status: "draft" | "published", scope: StandingOrderScope = "this") => {
setIsSubmitting(true);
try {
const courseData = {
name: courseName,
sport: selectedSports,
level: selectedLevel,
date: courseDate,
timeFrom: timeFrom,
timeTo: timeTo,
trainers: selectedTrainers.length > 0 ? selectedTrainers : undefined,
room: selectedRoom,
description,
coverImage: coverImage ?? undefined,
maxParticipants: maxParticipants ? parseInt(maxParticipants) : undefined,
price: price ? parseFloat(price) : undefined,
isStandingOrder,
frequency: selectedFrequency || undefined,
weekdays: selectedWeekdays.length > 0 ? selectedWeekdays : undefined,
businessId: selectedBusinessId || undefined,
};
const courseData = buildCourseData();
if (mode === "edit" && !courseId) {
throw new Error("Missing courseId in edit mode");
}
if (mode === "edit" && !courseId) throw new Error("Missing courseId in edit mode");
const result =
mode === "edit"
? await updateCourse(courseId!, courseData, status)
? await updateCourse(courseId!, courseData, status, scope)
: await createCourse(courseData, status);
if (result.success) {
@ -315,6 +351,33 @@ const CourseCreateView = ({
}
};
const handleSubmit = async (status: "draft" | "published") => {
if (status === "published" && !validateForm()) {
toast.error("Bitte füllen Sie alle erforderlichen Felder aus");
return;
}
if (status === "draft" && !validateDraft()) {
toast.error("Bitte geben Sie mindestens einen Kursnamen ein");
return;
}
if (mode === "edit" && isStandingOrderRelated) {
setPendingSubmitStatus(status);
setEditScopeDialogOpen(true);
return;
}
await doSubmit(status);
};
const handleEditScopeConfirm = async (scope: StandingOrderScope) => {
setEditScopeDialogOpen(false);
if (pendingSubmitStatus) {
await doSubmit(pendingSubmitStatus, scope);
setPendingSubmitStatus(null);
}
};
return (
<div>
<div className="w-full flex items-center justify-between">
@ -329,7 +392,10 @@ const CourseCreateView = ({
</p>
</div>
{mode === "edit" && (
<Button variant="destructive" onClick={() => handleDeleteClick(courseId!, courseName)}>
<Button
variant="destructive"
onClick={() => handleDeleteClick(courseId!, courseName, isStandingOrderRelated)}
>
<Trash2 className="mb-1" /> Löschen
</Button>
)}
@ -549,6 +615,8 @@ const CourseCreateView = ({
if (!checked) {
setSelectedFrequency("");
setSelectedWeekdays([]);
setWeekdayTimings({});
setEndDate("");
}
}}
/>
@ -566,20 +634,85 @@ const CourseCreateView = ({
setSelectedFrequency(value);
if (value !== "custom") {
setSelectedWeekdays([]);
setWeekdayTimings({});
}
}}
options={FREQUENCIES}
error={errors.frequency}
/>
{selectedFrequency === "custom" && (
<MultiSelectDropdown
label="An welchen Tagen soll der Kurs stattfinden?"
selected={selectedWeekdays}
onSelect={setSelectedWeekdays}
options={WEEKDAYS}
error={errors.weekdays}
<div>
<InputComponent
isLabel
label="Enddatum des Dauerauftrags"
type="date"
id="course-create-end-date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
{errors.endDate && <p className="text-red-500 text-sm mt-1">{errors.endDate}</p>}
</div>
{selectedFrequency === "custom" && (
<>
<MultiSelectDropdown
label="An welchen Tagen soll der Kurs stattfinden?"
selected={selectedWeekdays}
onSelect={(days) => {
setSelectedWeekdays(days);
// Nicht mehr ausgewählte Tage aus weekdayTimings entfernen
setWeekdayTimings((prev) => {
const next: Record<string, WeekdayTiming> = {};
days.forEach((d) => {
next[d] = prev[d] ?? { timeFrom: "", timeTo: "" };
});
return next;
});
}}
options={WEEKDAYS}
error={errors.weekdays}
/>
{selectedWeekdays.length > 0 && (
<div className="space-y-3">
<p className="text-sm font-medium">Uhrzeiten pro Tag</p>
{selectedWeekdays.map((day) => {
const dayLabel = WEEKDAYS.find((w) => w.value === day)?.label ?? day;
return (
<div key={day} className="flex items-center gap-3">
<span className="w-24 text-sm shrink-0">{dayLabel}</span>
<InputComponent
type="time"
id={`weekday-time-from-${day}`}
value={weekdayTimings[day]?.timeFrom ?? ""}
onChange={(e) =>
setWeekdayTimings((prev) => ({
...prev,
[day]: { ...prev[day], timeFrom: e.target.value },
}))
}
/>
<span className="text-sm">–</span>
<InputComponent
type="time"
id={`weekday-time-to-${day}`}
value={weekdayTimings[day]?.timeTo ?? ""}
onChange={(e) =>
setWeekdayTimings((prev) => ({
...prev,
[day]: { ...prev[day], timeTo: e.target.value },
}))
}
/>
</div>
);
})}
{errors.weekdayTimings && (
<p className="text-red-500 text-sm">{errors.weekdayTimings}</p>
)}
</div>
)}
</>
)}
</>
)}
@ -615,6 +748,20 @@ const CourseCreateView = ({
onConfirm={handleDeleteConfirm}
isLoading={isDeleting}
/>
<StandingOrderScopeDialog
open={deleteScopeDialogOpen}
onOpenChange={setDeleteScopeDialogOpen}
onConfirm={handleDeleteScopeConfirm}
action="delete"
/>
<StandingOrderScopeDialog
open={editScopeDialogOpen}
onOpenChange={setEditScopeDialogOpen}
onConfirm={handleEditScopeConfirm}
action="edit"
/>
</div>
);
};

View file

@ -25,7 +25,7 @@ const CourseEditView = ({ courseId, customSports }: CourseEditViewProps) => {
const result = await getCourseById(courseId);
if (result.success && result.course) {
if (alive) setCourseData(result.course);
if (alive) setCourseData(result.course as unknown as Course);
} else {
toast.error(result.error || "Fehler beim Laden des Kurses");
router.push("/courses");

View file

@ -11,6 +11,7 @@ import CourseListItem from "../components/CourseListItem";
import { Filter } from "lucide-react";
import DeleteDialog from "@/src/components/layout/DeleteDialog";
import CourseFilter from "../components/CourseFilter";
import { StandingOrderScopeDialog } from "../components/StandingOrderScopeDialog";
type Course = {
id: string;
@ -28,6 +29,7 @@ type Course = {
isStandingOrder: boolean;
frequency: string | null;
weekdays: string[];
parentCourseId: string | null;
status: string;
createdBy: string;
createdAt: Date;
@ -81,10 +83,13 @@ const CourseListView = ({
const {
deleteDialogOpen,
setDeleteDialogOpen,
scopeDialogOpen,
setScopeDialogOpen,
courseToDelete,
setCourseToDelete,
isDeleting,
handleDeleteClick,
handleScopeConfirm,
handleDeleteConfirm,
} = useDeleteCourse({
onSuccess: (deletedId) => {
@ -419,7 +424,14 @@ const CourseListView = ({
showDeleteIcon={deleteMode && canDelete}
coverImage={course.coverImage ?? undefined}
onDelete={
canDelete ? () => handleDeleteClick(course.id, course.name) : undefined
canDelete
? () =>
handleDeleteClick(
course.id,
course.name,
!!(course.isStandingOrder || course.parentCourseId)
)
: undefined
}
onEdit={canEdit ? () => router.push(`/courses/edit/${course.id}`) : undefined}
/>
@ -442,6 +454,13 @@ const CourseListView = ({
onConfirm={handleDeleteConfirm}
isLoading={isDeleting}
/>
<StandingOrderScopeDialog
open={scopeDialogOpen}
onOpenChange={setScopeDialogOpen}
onConfirm={handleScopeConfirm}
action="delete"
/>
</div>
);
};

View file

@ -21,6 +21,7 @@ type Course = {
isStandingOrder: boolean;
frequency: string | null;
weekdays: string[];
parentCourseId: string | null;
status: string;
createdBy: string;
createdAt: Date;

View file

@ -0,0 +1,117 @@
export type FrequencyType = "daily" | "weekly" | "biweekly" | "monthly" | "custom";
export type WeekdayTiming = { timeFrom: string; timeTo: string };
export type CourseOccurrence = { date: Date; timeFrom: string; timeTo: string };
export const VALID_FREQUENCIES: FrequencyType[] = [
"daily",
"weekly",
"biweekly",
"monthly",
"custom",
];
const WEEKDAY_MAP: Record<string, number> = {
sunday: 0,
sonntag: 0,
monday: 1,
montag: 1,
tuesday: 2,
dienstag: 2,
wednesday: 3,
mittwoch: 3,
thursday: 4,
donnerstag: 4,
friday: 5,
freitag: 5,
saturday: 6,
samstag: 6,
};
const toWeekdayNumber = (weekday: string): number | undefined =>
WEEKDAY_MAP[weekday.trim().toLowerCase()];
export const MAX_INSTANCES = 365;
export function generateOccurrences(params: {
startDate: Date;
endDate: Date;
frequency: FrequencyType;
defaultTimeFrom: string;
defaultTimeTo: string;
weekdays?: string[];
weekdayTimings?: Record<string, WeekdayTiming>;
}): CourseOccurrence[] {
const {
startDate,
endDate,
frequency,
defaultTimeFrom,
defaultTimeTo,
weekdays = [],
weekdayTimings = {},
} = params;
const occurrences: CourseOccurrence[] = [];
const addOccurrence = (d: Date, timeFrom: string, timeTo: string) => {
if (occurrences.length >= MAX_INSTANCES) {
throw new Error(
`Dauerauftrag überschreitet das Maximum von ${MAX_INSTANCES} Instanzen. Bitte ein kürzeres Enddatum wählen.`
);
}
occurrences.push({ date: new Date(d), timeFrom, timeTo });
};
if (frequency === "daily") {
const cursor = new Date(startDate);
while (cursor <= endDate) {
addOccurrence(cursor, defaultTimeFrom, defaultTimeTo);
cursor.setDate(cursor.getDate() + 1);
}
} else if (frequency === "weekly") {
const cursor = new Date(startDate);
while (cursor <= endDate) {
addOccurrence(cursor, defaultTimeFrom, defaultTimeTo);
cursor.setDate(cursor.getDate() + 7);
}
} else if (frequency === "biweekly") {
const cursor = new Date(startDate);
while (cursor <= endDate) {
addOccurrence(cursor, defaultTimeFrom, defaultTimeTo);
cursor.setDate(cursor.getDate() + 14);
}
} else if (frequency === "monthly") {
const cursor = new Date(startDate);
const desiredDay = cursor.getDate();
while (cursor <= endDate) {
addOccurrence(cursor, defaultTimeFrom, defaultTimeTo);
cursor.setDate(1);
cursor.setMonth(cursor.getMonth() + 1);
const lastDayOfMonth = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 0).getDate();
cursor.setDate(Math.min(desiredDay, lastDayOfMonth));
}
} else if (frequency === "custom") {
const targetDays = new Set(
weekdays.map(toWeekdayNumber).filter((d): d is number => d !== undefined)
);
const timingsByDay = Object.entries(weekdayTimings).reduce<Record<number, WeekdayTiming>>(
(acc, [weekday, timing]) => {
const day = toWeekdayNumber(weekday);
if (day !== undefined) acc[day] = timing;
return acc;
},
{}
);
const cursor = new Date(startDate);
while (cursor <= endDate) {
if (targetDays.has(cursor.getDay())) {
const timing = timingsByDay[cursor.getDay()];
addOccurrence(cursor, timing?.timeFrom ?? defaultTimeFrom, timing?.timeTo ?? defaultTimeTo);
}
cursor.setDate(cursor.getDate() + 1);
}
}
return occurrences;
}

View file

@ -128,6 +128,7 @@ describe("publishedCourseSchema", () => {
...validPublishedCourse,
isStandingOrder: true,
frequency: "weekly",
endDate: "2026-12-31",
});
expect(result.success).toBe(true);
});
@ -137,6 +138,7 @@ describe("publishedCourseSchema", () => {
...validPublishedCourse,
isStandingOrder: true,
frequency: "custom",
endDate: "2026-12-31",
weekdays: [],
});
expect(result.success).toBe(false);
@ -148,6 +150,7 @@ describe("publishedCourseSchema", () => {
...validPublishedCourse,
isStandingOrder: true,
frequency: "custom",
endDate: "2026-12-31",
weekdays: ["Montag", "Mittwoch"],
});
expect(result.success).toBe(true);