mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 20:51:18 +00:00
Merge pull request #128 from Since-3/feature/standing-order-courses
Feature/standing order courses
This commit is contained in:
commit
da330cc206
13 changed files with 678 additions and 111 deletions
|
|
@ -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");
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ type Course = {
|
|||
isStandingOrder: boolean;
|
||||
frequency: string | null;
|
||||
weekdays: string[];
|
||||
parentCourseId: string | null;
|
||||
status: string;
|
||||
createdBy: string;
|
||||
createdAt: Date;
|
||||
|
|
|
|||
117
src/modules/courses/utils/generate-instances.ts
Normal file
117
src/modules/courses/utils/generate-instances.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue