From 63e6e4dc5cc0d5c54f8c7be09997ad5702f55a1e Mon Sep 17 00:00:00 2001 From: Rajpreet0 Date: Sat, 27 Jun 2026 16:50:10 +0200 Subject: [PATCH] added standing orders in courses --- .../migration.sql | 17 ++ prisma/schema.prisma | 7 + src/modules/courses/actions/course-actions.ts | 215 ++++++++++++----- src/modules/courses/hooks/useDeleteCourse.ts | 22 +- src/modules/courses/schemas/course-schema.ts | 23 ++ src/modules/courses/types/course.types.ts | 3 + .../components/StandingOrderScopeDialog.tsx | 81 +++++++ .../courses/ui/views/course-create-view.tsx | 227 +++++++++++++++--- .../courses/ui/views/course-edit-view.tsx | 2 +- .../courses/ui/views/course-list-view.tsx | 21 +- src/modules/courses/ui/views/course-view.tsx | 1 + .../courses/utils/generate-instances.ts | 81 +++++++ 12 files changed, 600 insertions(+), 100 deletions(-) create mode 100644 prisma/migrations/20260627000000_add_standing_order_instances/migration.sql create mode 100644 src/modules/courses/ui/components/StandingOrderScopeDialog.tsx create mode 100644 src/modules/courses/utils/generate-instances.ts diff --git a/prisma/migrations/20260627000000_add_standing_order_instances/migration.sql b/prisma/migrations/20260627000000_add_standing_order_instances/migration.sql new file mode 100644 index 0000000..d131b2e --- /dev/null +++ b/prisma/migrations/20260627000000_add_standing_order_instances/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 83a0037..cf76452 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 { diff --git a/src/modules/courses/actions/course-actions.ts b/src/modules/courses/actions/course-actions.ts index 48ab1b0..89dd6dc 100644 --- a/src/modules/courses/actions/course-actions.ts +++ b/src/modules/courses/actions/course-actions.ts @@ -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,87 @@ 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 }; + + if (validatedData.isStandingOrder && validatedData.frequency && validatedData.endDate) { + const startDate = new Date(validatedData.date || new Date()); + const endDate = new Date(validatedData.endDate); + + // Template anlegen + const template = await prisma.course.create({ + data: { + ...commonFields, + date: startDate, + endDate, + isStandingOrder: true, + parentCourseId: null, + }, + }); + + // Instanzdaten berechnen + const occurrences = generateOccurrences({ + startDate, + endDate, + frequency: validatedData.frequency as FrequencyType, + defaultTimeFrom: validatedData.timeFrom || "", + defaultTimeTo: validatedData.timeTo || "", + weekdays: validatedData.weekdays || [], + weekdayTimings: + (validatedData.weekdayTimings as Record) ?? + {}, + }); + + if (occurrences.length > 0) { + await prisma.course.createMany({ + data: occurrences.map((o) => ({ + ...commonFields, + date: o.date, + timeFrom: o.timeFrom, + timeTo: o.timeTo, + endDate: null, + isStandingOrder: false, + parentCourseId: template.id, + })), + }); + } + + course = template; + } 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 +246,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 +370,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 +435,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 +524,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 +566,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 { diff --git a/src/modules/courses/hooks/useDeleteCourse.ts b/src/modules/courses/hooks/useDeleteCourse.ts index 6557fb4..8ac326f 100644 --- a/src/modules/courses/hooks/useDeleteCourse.ts +++ b/src/modules/courses/hooks/useDeleteCourse.ts @@ -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,23 @@ 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("this"); const [isDeleting, setIsDeleting] = useState(false); - const handleDeleteClick = (id: string, name: string) => { + const handleDeleteClick = (id: string, name: string, isStandingOrderRelated = false) => { setCourseToDelete({ id, name }); + if (isStandingOrderRelated) { + setScopeDialogOpen(true); + } else { + setDeleteDialogOpen(true); + } + }; + + const handleScopeConfirm = (scope: StandingOrderScope) => { + setDeleteScope(scope); + setScopeDialogOpen(false); setDeleteDialogOpen(true); }; @@ -21,7 +34,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 +42,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 +54,13 @@ export function useDeleteCourse({ onSuccess }: UseDeleteCourseOptions = {}) { return { deleteDialogOpen, setDeleteDialogOpen, + scopeDialogOpen, + setScopeDialogOpen, courseToDelete, setCourseToDelete, isDeleting, handleDeleteClick, + handleScopeConfirm, handleDeleteConfirm, }; } diff --git a/src/modules/courses/schemas/course-schema.ts b/src/modules/courses/schemas/course-schema.ts index 3bd9b4a..191ac3c 100644 --- a/src/modules/courses/schemas/course-schema.ts +++ b/src/modules/courses/schemas/course-schema.ts @@ -18,6 +18,10 @@ export const courseSchema = z isStandingOrder: z.boolean(), frequency: z.string().optional(), weekdays: z.array(z.string()).optional(), + weekdayTimings: z + .record(z.string(), z.object({ timeFrom: z.string(), timeTo: z.string() })) + .optional(), + endDate: z.string().optional(), businessId: z.string().optional(), }) .refine( @@ -60,6 +64,10 @@ export const publishedCourseSchema = z isStandingOrder: z.boolean(), frequency: z.string().optional(), weekdays: z.array(z.string()).optional(), + weekdayTimings: z + .record(z.string(), z.object({ timeFrom: z.string(), timeTo: z.string() })) + .optional(), + endDate: z.string().optional(), businessId: z.string().optional(), }) .refine( @@ -69,6 +77,21 @@ 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 && data.endDate <= data.date) + return false; + return true; + }, + { message: "Enddatum muss nach dem Startdatum liegen", path: ["endDate"] } + ) .refine( (data) => { if (data.frequency === "custom" && (!data.weekdays || data.weekdays.length === 0)) diff --git a/src/modules/courses/types/course.types.ts b/src/modules/courses/types/course.types.ts index a44ac23..e8eeb34 100644 --- a/src/modules/courses/types/course.types.ts +++ b/src/modules/courses/types/course.types.ts @@ -14,6 +14,9 @@ export type Course = { isStandingOrder: boolean; frequency: string | null; weekdays: string[]; + weekdayTimings: Record | null; + endDate: Date | null; + parentCourseId: string | null; status: string; createdBy: string; businessId: string | null; diff --git a/src/modules/courses/ui/components/StandingOrderScopeDialog.tsx b/src/modules/courses/ui/components/StandingOrderScopeDialog.tsx new file mode 100644 index 0000000..cbfde04 --- /dev/null +++ b/src/modules/courses/ui/components/StandingOrderScopeDialog.tsx @@ -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 ( + + + + {labels.title} + {labels.description} + +
+ + + +
+ + + +
+
+ ); +} diff --git a/src/modules/courses/ui/views/course-create-view.tsx b/src/modules/courses/ui/views/course-create-view.tsx index df02947..fd6e845 100644 --- a/src/modules/courses/ui/views/course-create-view.tsx +++ b/src/modules/courses/ui/views/course-create-view.tsx @@ -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( isEdit ? initialData!.weekdays || [] : [] ); + const [weekdayTimings, setWeekdayTimings] = useState>(() => { + if (isEdit && initialData!.weekdayTimings) { + return initialData!.weekdayTimings as Record; + } + 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 (
@@ -329,7 +392,10 @@ const CourseCreateView = ({

{mode === "edit" && ( - )} @@ -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" && ( - + setEndDate(e.target.value)} /> + {errors.endDate &&

{errors.endDate}

} +
+ + {selectedFrequency === "custom" && ( + <> + { + setSelectedWeekdays(days); + // Nicht mehr ausgewählte Tage aus weekdayTimings entfernen + setWeekdayTimings((prev) => { + const next: Record = {}; + days.forEach((d) => { + next[d] = prev[d] ?? { timeFrom: "", timeTo: "" }; + }); + return next; + }); + }} + options={WEEKDAYS} + error={errors.weekdays} + /> + + {selectedWeekdays.length > 0 && ( +
+

Uhrzeiten pro Tag

+ {selectedWeekdays.map((day) => { + const dayLabel = WEEKDAYS.find((w) => w.value === day)?.label ?? day; + return ( +
+ {dayLabel} + + setWeekdayTimings((prev) => ({ + ...prev, + [day]: { ...prev[day], timeFrom: e.target.value }, + })) + } + /> + – + + setWeekdayTimings((prev) => ({ + ...prev, + [day]: { ...prev[day], timeTo: e.target.value }, + })) + } + /> +
+ ); + })} + {errors.weekdayTimings && ( +

{errors.weekdayTimings}

+ )} +
+ )} + )} )} @@ -615,6 +748,20 @@ const CourseCreateView = ({ onConfirm={handleDeleteConfirm} isLoading={isDeleting} /> + + + + ); }; diff --git a/src/modules/courses/ui/views/course-edit-view.tsx b/src/modules/courses/ui/views/course-edit-view.tsx index d4897b4..a51cbde 100644 --- a/src/modules/courses/ui/views/course-edit-view.tsx +++ b/src/modules/courses/ui/views/course-edit-view.tsx @@ -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"); diff --git a/src/modules/courses/ui/views/course-list-view.tsx b/src/modules/courses/ui/views/course-list-view.tsx index 41f1672..7e42c8f 100644 --- a/src/modules/courses/ui/views/course-list-view.tsx +++ b/src/modules/courses/ui/views/course-list-view.tsx @@ -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} /> + + ); }; diff --git a/src/modules/courses/ui/views/course-view.tsx b/src/modules/courses/ui/views/course-view.tsx index 9bda8a4..5686f5a 100644 --- a/src/modules/courses/ui/views/course-view.tsx +++ b/src/modules/courses/ui/views/course-view.tsx @@ -21,6 +21,7 @@ type Course = { isStandingOrder: boolean; frequency: string | null; weekdays: string[]; + parentCourseId: string | null; status: string; createdBy: string; createdAt: Date; diff --git a/src/modules/courses/utils/generate-instances.ts b/src/modules/courses/utils/generate-instances.ts new file mode 100644 index 0000000..525b111 --- /dev/null +++ b/src/modules/courses/utils/generate-instances.ts @@ -0,0 +1,81 @@ +export type FrequencyType = "daily" | "weekly" | "biweekly" | "monthly" | "custom"; + +export type WeekdayTiming = { timeFrom: string; timeTo: string }; + +export type CourseOccurrence = { date: Date; timeFrom: string; timeTo: string }; + +const WEEKDAY_MAP: Record = { + sunday: 0, + monday: 1, + tuesday: 2, + wednesday: 3, + thursday: 4, + friday: 5, + saturday: 6, +}; + +const MAX_INSTANCES = 365; + +export function generateOccurrences(params: { + startDate: Date; + endDate: Date; + frequency: FrequencyType; + defaultTimeFrom: string; + defaultTimeTo: string; + weekdays?: string[]; + weekdayTimings?: Record; +}): CourseOccurrence[] { + const { + startDate, + endDate, + frequency, + defaultTimeFrom, + defaultTimeTo, + weekdays = [], + weekdayTimings = {}, + } = params; + const occurrences: CourseOccurrence[] = []; + + const addOccurrence = (d: Date, timeFrom: string, timeTo: string) => { + occurrences.push({ date: new Date(d), timeFrom, timeTo }); + }; + + if (frequency === "daily") { + const cursor = new Date(startDate); + while (cursor <= endDate && occurrences.length < MAX_INSTANCES) { + addOccurrence(cursor, defaultTimeFrom, defaultTimeTo); + cursor.setDate(cursor.getDate() + 1); + } + } else if (frequency === "weekly") { + const cursor = new Date(startDate); + while (cursor <= endDate && occurrences.length < MAX_INSTANCES) { + addOccurrence(cursor, defaultTimeFrom, defaultTimeTo); + cursor.setDate(cursor.getDate() + 7); + } + } else if (frequency === "biweekly") { + const cursor = new Date(startDate); + while (cursor <= endDate && occurrences.length < MAX_INSTANCES) { + addOccurrence(cursor, defaultTimeFrom, defaultTimeTo); + cursor.setDate(cursor.getDate() + 14); + } + } else if (frequency === "monthly") { + const cursor = new Date(startDate); + while (cursor <= endDate && occurrences.length < MAX_INSTANCES) { + addOccurrence(cursor, defaultTimeFrom, defaultTimeTo); + cursor.setMonth(cursor.getMonth() + 1); + } + } else if (frequency === "custom") { + const targetDays = new Set(weekdays.map((w) => WEEKDAY_MAP[w])); + const cursor = new Date(startDate); + while (cursor <= endDate && occurrences.length < MAX_INSTANCES) { + const dayName = Object.keys(WEEKDAY_MAP).find((k) => WEEKDAY_MAP[k] === cursor.getDay()); + if (targetDays.has(cursor.getDay()) && dayName) { + const timing = weekdayTimings[dayName]; + addOccurrence(cursor, timing?.timeFrom ?? defaultTimeFrom, timing?.timeTo ?? defaultTimeTo); + } + cursor.setDate(cursor.getDate() + 1); + } + } + + return occurrences; +}