mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 20:51:18 +00:00
fixing bugs
This commit is contained in:
parent
285eeb1536
commit
dad50d1c97
11 changed files with 372 additions and 95 deletions
|
|
@ -139,7 +139,7 @@ model Course {
|
||||||
isStandingOrder Boolean @default(false)
|
isStandingOrder Boolean @default(false)
|
||||||
frequency String? // daily, weekly, biweekly, monthly, custom
|
frequency String? // daily, weekly, biweekly, monthly, custom
|
||||||
weekdays String[] // For custom frequency
|
weekdays String[] // For custom frequency
|
||||||
weekdayTimings Json? // { "monday": { "timeFrom": "08:00", "timeTo": "09:00" }, ... }
|
weekdayTimings Json? // { "monday": [{ "timeFrom": "08:00", "timeTo": "09:00" }, ...], ... } — mehrere Zeitfenster pro Tag. Legacy: { "monday": { "timeFrom", "timeTo" } }
|
||||||
endDate DateTime? // Required when isStandingOrder = true
|
endDate DateTime? // Required when isStandingOrder = true
|
||||||
parentCourseId String? // Null on template; set on every child instance
|
parentCourseId String? // Null on template; set on every child instance
|
||||||
parentCourse Course? @relation("StandingOrderInstances", fields: [parentCourseId], references: [id], onDelete: Cascade)
|
parentCourse Course? @relation("StandingOrderInstances", fields: [parentCourseId], references: [id], onDelete: Cascade)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import MultiStepProzess from "./MultiStepProzess";
|
import MultiStepProzess from "./MultiStepProzess";
|
||||||
import Logo from "@/src/public/logo.png";
|
import Logo from "@/src/public/logo-gelb.png";
|
||||||
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from "@/src/components/ui/card";
|
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from "@/src/components/ui/card";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -125,11 +125,7 @@ export async function createCourse(data: CourseFormData, status: "draft" | "publ
|
||||||
defaultTimeFrom: validatedData.timeFrom || "",
|
defaultTimeFrom: validatedData.timeFrom || "",
|
||||||
defaultTimeTo: validatedData.timeTo || "",
|
defaultTimeTo: validatedData.timeTo || "",
|
||||||
weekdays: validatedData.weekdays || [],
|
weekdays: validatedData.weekdays || [],
|
||||||
weekdayTimings:
|
weekdayTimings: validatedData.weekdayTimings ?? {},
|
||||||
(validatedData.weekdayTimings as Record<
|
|
||||||
string,
|
|
||||||
{ timeFrom: string; timeTo: string }
|
|
||||||
>) ?? {},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (occurrences.length > 0) {
|
if (occurrences.length > 0) {
|
||||||
|
|
@ -551,11 +547,7 @@ export async function updateCourse(
|
||||||
defaultTimeFrom: validatedData.timeFrom || "",
|
defaultTimeFrom: validatedData.timeFrom || "",
|
||||||
defaultTimeTo: validatedData.timeTo || "",
|
defaultTimeTo: validatedData.timeTo || "",
|
||||||
weekdays: validatedData.weekdays || [],
|
weekdays: validatedData.weekdays || [],
|
||||||
weekdayTimings:
|
weekdayTimings: validatedData.weekdayTimings ?? {},
|
||||||
(validatedData.weekdayTimings as Record<
|
|
||||||
string,
|
|
||||||
{ timeFrom: string; timeTo: string }
|
|
||||||
>) ?? {},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Das Template selbst wird (wie bei createCourse) nicht als sichtbarer
|
// Das Template selbst wird (wie bei createCourse) nicht als sichtbarer
|
||||||
|
|
|
||||||
181
src/modules/courses/hooks/useWeekdayTimings.ts
Normal file
181
src/modules/courses/hooks/useWeekdayTimings.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
"use client";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
export type WeekdaySlot = { timeFrom: string; timeTo: string };
|
||||||
|
export type WeekdayTimings = Record<string, WeekdaySlot[]>;
|
||||||
|
|
||||||
|
/** Rohform, wie sie aus der DB kommen kann (neues Array- oder altes Einzelobjekt-Format). */
|
||||||
|
type RawWeekdayTimings =
|
||||||
|
| Record<string, WeekdaySlot | WeekdaySlot[] | null | undefined>
|
||||||
|
| null
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
export const emptySlot = (): WeekdaySlot => ({ timeFrom: "", timeTo: "" });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bringt gespeicherte weekdayTimings in das Array-Format. Akzeptiert sowohl das
|
||||||
|
* neue Format ({ monday: [{...}] }) als auch das alte Einzelobjekt-Format
|
||||||
|
* ({ monday: { timeFrom, timeTo } }) aus bestehenden DB-Datensätzen.
|
||||||
|
*/
|
||||||
|
export const normalizeWeekdayTimings = (raw: RawWeekdayTimings): WeekdayTimings => {
|
||||||
|
if (!raw) return {};
|
||||||
|
const result: WeekdayTimings = {};
|
||||||
|
for (const [day, value] of Object.entries(raw)) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const slots = value.filter((s): s is WeekdaySlot => !!s && typeof s === "object");
|
||||||
|
if (slots.length > 0) result[day] = slots;
|
||||||
|
} else if (value && typeof value === "object") {
|
||||||
|
result[day] = [{ timeFrom: value.timeFrom ?? "", timeTo: value.timeTo ?? "" }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ermittelt ein Standard-Zeitfenster, falls alle Tage genau ein identisches haben. */
|
||||||
|
const deriveDefaultSlot = (timings: WeekdayTimings): WeekdaySlot => {
|
||||||
|
const daySlots = Object.values(timings);
|
||||||
|
if (daySlots.length === 0) return emptySlot();
|
||||||
|
const first = daySlots[0][0];
|
||||||
|
if (!first) return emptySlot();
|
||||||
|
const allSame = daySlots.every(
|
||||||
|
(slots) =>
|
||||||
|
slots.length === 1 && slots[0].timeFrom === first.timeFrom && slots[0].timeTo === first.timeTo
|
||||||
|
);
|
||||||
|
return allSame ? { ...first } : emptySlot();
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UseWeekdayTimingsOptions {
|
||||||
|
/** Vorbelegung (Edit-Modus) — beliebiges gespeichertes Format. */
|
||||||
|
initialWeekdays?: string[];
|
||||||
|
initialTimings?: RawWeekdayTimings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kapselt die Wochentag-Auswahl und deren Zeitfenster für die "custom"-Häufigkeit
|
||||||
|
* eines Dauerauftrags. Jeder Tag kann mehrere Zeitfenster haben; ein
|
||||||
|
* Standard-Zeitfenster kann auf alle Tage übertragen werden.
|
||||||
|
*/
|
||||||
|
export function useWeekdayTimings({
|
||||||
|
initialWeekdays = [],
|
||||||
|
initialTimings,
|
||||||
|
}: UseWeekdayTimingsOptions = {}) {
|
||||||
|
const [selectedWeekdays, setSelectedWeekdays] = useState<string[]>(initialWeekdays);
|
||||||
|
const [weekdayTimings, setWeekdayTimings] = useState<WeekdayTimings>(() =>
|
||||||
|
normalizeWeekdayTimings(initialTimings)
|
||||||
|
);
|
||||||
|
const [defaultSlot, setDefaultSlot] = useState<WeekdaySlot>(() =>
|
||||||
|
deriveDefaultSlot(normalizeWeekdayTimings(initialTimings))
|
||||||
|
);
|
||||||
|
|
||||||
|
const isDefaultSlotFilled = !!defaultSlot.timeFrom && !!defaultSlot.timeTo;
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setSelectedWeekdays([]);
|
||||||
|
setWeekdayTimings({});
|
||||||
|
setDefaultSlot(emptySlot());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hält weekdayTimings mit der Tagesauswahl synchron: jeder ausgewählte Tag
|
||||||
|
// bekommt mindestens einen (ggf. leeren) Slot, abgewählte Tage fallen raus.
|
||||||
|
const syncWeekdays = (days: string[]) => {
|
||||||
|
setSelectedWeekdays(days);
|
||||||
|
setWeekdayTimings((prev) => {
|
||||||
|
const next: WeekdayTimings = {};
|
||||||
|
days.forEach((d) => {
|
||||||
|
next[d] = prev[d]?.length ? prev[d] : [{ ...defaultSlot }];
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Standard-Zeitfenster auf alle ausgewählten Tage übertragen (ersetzt deren Slots).
|
||||||
|
const applyDefaultToAll = () => {
|
||||||
|
if (!isDefaultSlotFilled || selectedWeekdays.length === 0) return;
|
||||||
|
setWeekdayTimings(() => {
|
||||||
|
const next: WeekdayTimings = {};
|
||||||
|
selectedWeekdays.forEach((d) => {
|
||||||
|
next[d] = [{ ...defaultSlot }];
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDefaultSlot = (patch: Partial<WeekdaySlot>) => {
|
||||||
|
const updated = { ...defaultSlot, ...patch };
|
||||||
|
setDefaultSlot(updated);
|
||||||
|
// Solange noch kein Tag eine eigene Uhrzeit gesetzt hat, die Änderung direkt
|
||||||
|
// auf alle ausgewählten Tage spiegeln (initiale Bequemlichkeit).
|
||||||
|
const noDayCustomized = selectedWeekdays.every((d) =>
|
||||||
|
(weekdayTimings[d] ?? []).every((s) => !s.timeFrom && !s.timeTo)
|
||||||
|
);
|
||||||
|
if (noDayCustomized && selectedWeekdays.length > 0) {
|
||||||
|
setWeekdayTimings(() => {
|
||||||
|
const next: WeekdayTimings = {};
|
||||||
|
selectedWeekdays.forEach((d) => {
|
||||||
|
next[d] = [{ ...updated }];
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSlot = (day: string, index: number, patch: Partial<WeekdaySlot>) => {
|
||||||
|
setWeekdayTimings((prev) => {
|
||||||
|
const slots = (prev[day] ?? [emptySlot()]).map((s, i) =>
|
||||||
|
i === index ? { ...s, ...patch } : s
|
||||||
|
);
|
||||||
|
return { ...prev, [day]: slots };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addSlot = (day: string) => {
|
||||||
|
setWeekdayTimings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[day]: [...(prev[day] ?? []), emptySlot()],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeSlot = (day: string, index: number) => {
|
||||||
|
setWeekdayTimings((prev) => {
|
||||||
|
const slots = (prev[day] ?? []).filter((_, i) => i !== index);
|
||||||
|
return { ...prev, [day]: slots.length ? slots : [emptySlot()] };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Nur befüllte Slots; Tage ohne gültige Uhrzeit fallen weg. `undefined`, wenn leer. */
|
||||||
|
const cleanedTimings = useMemo((): WeekdayTimings | undefined => {
|
||||||
|
const cleaned: WeekdayTimings = {};
|
||||||
|
for (const [day, slots] of Object.entries(weekdayTimings)) {
|
||||||
|
const filled = slots.filter((s) => s.timeFrom || s.timeTo);
|
||||||
|
if (filled.length > 0) cleaned[day] = filled;
|
||||||
|
}
|
||||||
|
return Object.keys(cleaned).length > 0 ? cleaned : undefined;
|
||||||
|
}, [weekdayTimings]);
|
||||||
|
|
||||||
|
/** True, wenn ein ausgewählter Tag kein oder ein ungültiges Zeitfenster hat. */
|
||||||
|
const hasInvalidTiming = useMemo(
|
||||||
|
() =>
|
||||||
|
selectedWeekdays.some((day) => {
|
||||||
|
const slots = weekdayTimings[day] ?? [];
|
||||||
|
if (slots.length === 0) return true;
|
||||||
|
return slots.some((s) => !s.timeFrom || !s.timeTo || s.timeFrom >= s.timeTo);
|
||||||
|
}),
|
||||||
|
[selectedWeekdays, weekdayTimings]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedWeekdays,
|
||||||
|
weekdayTimings,
|
||||||
|
defaultSlot,
|
||||||
|
isDefaultSlotFilled,
|
||||||
|
cleanedTimings,
|
||||||
|
hasInvalidTiming,
|
||||||
|
reset,
|
||||||
|
syncWeekdays,
|
||||||
|
applyDefaultToAll,
|
||||||
|
updateDefaultSlot,
|
||||||
|
updateSlot,
|
||||||
|
addSlot,
|
||||||
|
removeSlot,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -5,12 +5,26 @@ const frequencyEnum = z.enum(VALID_FREQUENCIES as [string, ...string[]], {
|
||||||
message: "Ungültige Häufigkeit",
|
message: "Ungültige Häufigkeit",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const weekdaySlotSchema = z.object({ timeFrom: z.string(), timeTo: z.string() });
|
||||||
|
|
||||||
|
// Neues Format: mehrere Zeitfenster pro Tag. Das alte Einzelobjekt-Format
|
||||||
|
// ({ monday: { timeFrom, timeTo } }) wird für Abwärtskompatibilität weiterhin
|
||||||
|
// akzeptiert und in ein Array normalisiert.
|
||||||
const weekdayTimingsSchema = z
|
const weekdayTimingsSchema = z
|
||||||
.record(z.string(), z.object({ timeFrom: z.string(), timeTo: z.string() }))
|
.record(z.string(), z.union([z.array(weekdaySlotSchema), weekdaySlotSchema]))
|
||||||
|
.transform((timings) => {
|
||||||
|
const normalized: Record<string, { timeFrom: string; timeTo: string }[]> = {};
|
||||||
|
for (const [day, value] of Object.entries(timings)) {
|
||||||
|
const slots = Array.isArray(value) ? value : [value];
|
||||||
|
const filled = slots.filter((s) => s.timeFrom || s.timeTo);
|
||||||
|
if (filled.length > 0) normalized[day] = filled;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(timings) =>
|
(timings) =>
|
||||||
Object.values(timings).every(
|
Object.values(timings).every((slots) =>
|
||||||
({ timeFrom, timeTo }) => !timeFrom || !timeTo || timeFrom < timeTo
|
slots.every(({ timeFrom, timeTo }) => !timeFrom || !timeTo || timeFrom < timeTo)
|
||||||
),
|
),
|
||||||
{ message: "Endzeit muss nach der Anfangszeit liegen (Wochentag-Uhrzeiten)" }
|
{ message: "Endzeit muss nach der Anfangszeit liegen (Wochentag-Uhrzeiten)" }
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,11 @@ export type Course = {
|
||||||
isStandingOrder: boolean;
|
isStandingOrder: boolean;
|
||||||
frequency: string | null;
|
frequency: string | null;
|
||||||
weekdays: string[];
|
weekdays: string[];
|
||||||
weekdayTimings: Record<string, { timeFrom: string; timeTo: string }> | null;
|
weekdayTimings:
|
||||||
|
| Record<string, { timeFrom: string; timeTo: string }[]>
|
||||||
|
// Altes Format, kann noch in bestehenden DB-Datensätzen liegen
|
||||||
|
| Record<string, { timeFrom: string; timeTo: string }>
|
||||||
|
| null;
|
||||||
endDate: Date | null;
|
endDate: Date | null;
|
||||||
parentCourseId: string | null;
|
parentCourseId: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { getMyRooms } from "@/src/modules/rooms/actions/room-actions";
|
||||||
import { getMyTrainers } from "@/src/modules/employee/actions/employee-actions";
|
import { getMyTrainers } from "@/src/modules/employee/actions/employee-actions";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Course } from "../../types/course.types";
|
import { Course } from "../../types/course.types";
|
||||||
import { Trash2, ImagePlus, X, Loader2, AlertTriangle } from "lucide-react";
|
import { Trash2, ImagePlus, X, Loader2, AlertTriangle, Plus, CopyCheck } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import DeleteDialog from "@/src/components/layout/DeleteDialog";
|
import DeleteDialog from "@/src/components/layout/DeleteDialog";
|
||||||
import {
|
import {
|
||||||
|
|
@ -28,6 +28,7 @@ import {
|
||||||
import { Progress } from "@/src/components/ui/progress";
|
import { Progress } from "@/src/components/ui/progress";
|
||||||
import { downsizeImage } from "@/src/lib/image/downsize";
|
import { downsizeImage } from "@/src/lib/image/downsize";
|
||||||
import { useDeleteCourse } from "../../hooks/useDeleteCourse";
|
import { useDeleteCourse } from "../../hooks/useDeleteCourse";
|
||||||
|
import { useWeekdayTimings, type WeekdaySlot } from "../../hooks/useWeekdayTimings";
|
||||||
import {
|
import {
|
||||||
generateOccurrences,
|
generateOccurrences,
|
||||||
TooManyInstancesError,
|
TooManyInstancesError,
|
||||||
|
|
@ -38,8 +39,6 @@ import {
|
||||||
type StandingOrderScope,
|
type StandingOrderScope,
|
||||||
} from "../components/StandingOrderScopeDialog";
|
} from "../components/StandingOrderScopeDialog";
|
||||||
|
|
||||||
type WeekdayTiming = { timeFrom: string; timeTo: string };
|
|
||||||
|
|
||||||
const LEVELS = [
|
const LEVELS = [
|
||||||
{ value: "any", label: "Jedes Niveau" },
|
{ value: "any", label: "Jedes Niveau" },
|
||||||
{ value: "beginner", label: "Anfänger" },
|
{ value: "beginner", label: "Anfänger" },
|
||||||
|
|
@ -124,15 +123,14 @@ const CourseCreateView = ({
|
||||||
const [selectedFrequency, setSelectedFrequency] = useState(
|
const [selectedFrequency, setSelectedFrequency] = useState(
|
||||||
isEdit ? initialData!.frequency || "" : ""
|
isEdit ? initialData!.frequency || "" : ""
|
||||||
);
|
);
|
||||||
const [selectedWeekdays, setSelectedWeekdays] = useState<string[]>(
|
const weekdays = useWeekdayTimings({
|
||||||
isEdit ? initialData!.weekdays || [] : []
|
initialWeekdays: isEdit ? initialData!.weekdays || [] : [],
|
||||||
);
|
initialTimings:
|
||||||
const [weekdayTimings, setWeekdayTimings] = useState<Record<string, WeekdayTiming>>(() => {
|
isEdit && initialData!.weekdayTimings
|
||||||
if (isEdit && initialData!.weekdayTimings) {
|
? (initialData!.weekdayTimings as Record<string, WeekdaySlot | WeekdaySlot[]>)
|
||||||
return initialData!.weekdayTimings as Record<string, WeekdayTiming>;
|
: undefined,
|
||||||
}
|
|
||||||
return {};
|
|
||||||
});
|
});
|
||||||
|
const { selectedWeekdays, weekdayTimings, defaultSlot, isDefaultSlotFilled } = weekdays;
|
||||||
const [endDate, setEndDate] = useState(() => {
|
const [endDate, setEndDate] = useState(() => {
|
||||||
if (isEdit && initialData!.endDate) {
|
if (isEdit && initialData!.endDate) {
|
||||||
const d = new Date(initialData!.endDate);
|
const d = new Date(initialData!.endDate);
|
||||||
|
|
@ -300,13 +298,13 @@ const CourseCreateView = ({
|
||||||
if (selectedFrequency === "custom" && selectedWeekdays.length === 0) {
|
if (selectedFrequency === "custom" && selectedWeekdays.length === 0) {
|
||||||
newErrors.weekdays = "Mindestens ein Tag muss ausgewählt werden";
|
newErrors.weekdays = "Mindestens ein Tag muss ausgewählt werden";
|
||||||
}
|
}
|
||||||
if (selectedFrequency === "custom" && selectedWeekdays.length > 0) {
|
if (
|
||||||
const hasInvalidTiming = selectedWeekdays.some((day) => {
|
selectedFrequency === "custom" &&
|
||||||
const t = weekdayTimings[day];
|
selectedWeekdays.length > 0 &&
|
||||||
return !t?.timeFrom || !t?.timeTo || t.timeFrom >= t.timeTo;
|
weekdays.hasInvalidTiming
|
||||||
});
|
) {
|
||||||
if (hasInvalidTiming)
|
newErrors.weekdayTimings =
|
||||||
newErrors.weekdayTimings = "Bitte gültige Uhrzeiten für alle Wochentage eingeben";
|
"Bitte gültige Uhrzeiten für alle Zeitfenster eingeben (Ende nach Beginn)";
|
||||||
}
|
}
|
||||||
if (!newErrors.endDate && selectedFrequency && courseDate && endDate) {
|
if (!newErrors.endDate && selectedFrequency && courseDate && endDate) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -355,7 +353,7 @@ const CourseCreateView = ({
|
||||||
isStandingOrder,
|
isStandingOrder,
|
||||||
frequency: selectedFrequency || undefined,
|
frequency: selectedFrequency || undefined,
|
||||||
weekdays: selectedWeekdays.length > 0 ? selectedWeekdays : undefined,
|
weekdays: selectedWeekdays.length > 0 ? selectedWeekdays : undefined,
|
||||||
weekdayTimings: Object.keys(weekdayTimings).length > 0 ? weekdayTimings : undefined,
|
weekdayTimings: weekdays.cleanedTimings,
|
||||||
endDate: endDate || undefined,
|
endDate: endDate || undefined,
|
||||||
businessId: selectedBusinessId || undefined,
|
businessId: selectedBusinessId || undefined,
|
||||||
});
|
});
|
||||||
|
|
@ -699,9 +697,8 @@ const CourseCreateView = ({
|
||||||
setIsStandingOrder(checked === true);
|
setIsStandingOrder(checked === true);
|
||||||
if (!checked) {
|
if (!checked) {
|
||||||
setSelectedFrequency("");
|
setSelectedFrequency("");
|
||||||
setSelectedWeekdays([]);
|
|
||||||
setWeekdayTimings({});
|
|
||||||
setEndDate("");
|
setEndDate("");
|
||||||
|
weekdays.reset();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -718,8 +715,7 @@ const CourseCreateView = ({
|
||||||
onSelect={(value) => {
|
onSelect={(value) => {
|
||||||
setSelectedFrequency(value);
|
setSelectedFrequency(value);
|
||||||
if (value !== "custom") {
|
if (value !== "custom") {
|
||||||
setSelectedWeekdays([]);
|
weekdays.reset();
|
||||||
setWeekdayTimings({});
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
options={FREQUENCIES}
|
options={FREQUENCIES}
|
||||||
|
|
@ -743,58 +739,108 @@ const CourseCreateView = ({
|
||||||
<MultiSelectDropdown
|
<MultiSelectDropdown
|
||||||
label="An welchen Tagen soll der Kurs stattfinden?"
|
label="An welchen Tagen soll der Kurs stattfinden?"
|
||||||
selected={selectedWeekdays}
|
selected={selectedWeekdays}
|
||||||
onSelect={(days) => {
|
onSelect={weekdays.syncWeekdays}
|
||||||
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}
|
options={WEEKDAYS}
|
||||||
error={errors.weekdays}
|
error={errors.weekdays}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{selectedWeekdays.length > 0 && (
|
{selectedWeekdays.length > 0 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
<p className="text-sm font-medium">Uhrzeiten pro Tag</p>
|
{/* Standard-Zeitfenster mit "für alle übernehmen" */}
|
||||||
{selectedWeekdays.map((day) => {
|
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||||
const dayLabel = WEEKDAYS.find((w) => w.value === day)?.label ?? day;
|
<p className="text-sm font-medium mb-2">Standard-Zeitfenster</p>
|
||||||
return (
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div key={day} className="flex items-center gap-3">
|
<InputComponent
|
||||||
<span className="w-24 text-sm shrink-0">{dayLabel}</span>
|
type="time"
|
||||||
<InputComponent
|
id="weekday-default-time-from"
|
||||||
type="time"
|
value={defaultSlot.timeFrom}
|
||||||
id={`weekday-time-from-${day}`}
|
onChange={(e) => weekdays.updateDefaultSlot({ timeFrom: e.target.value })}
|
||||||
value={weekdayTimings[day]?.timeFrom ?? ""}
|
/>
|
||||||
onChange={(e) =>
|
<span className="text-sm">–</span>
|
||||||
setWeekdayTimings((prev) => ({
|
<InputComponent
|
||||||
...prev,
|
type="time"
|
||||||
[day]: { ...prev[day], timeFrom: e.target.value },
|
id="weekday-default-time-to"
|
||||||
}))
|
value={defaultSlot.timeTo}
|
||||||
}
|
onChange={(e) => weekdays.updateDefaultSlot({ timeTo: e.target.value })}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm">–</span>
|
<button
|
||||||
<InputComponent
|
type="button"
|
||||||
type="time"
|
onClick={weekdays.applyDefaultToAll}
|
||||||
id={`weekday-time-to-${day}`}
|
disabled={!isDefaultSlotFilled}
|
||||||
value={weekdayTimings[day]?.timeTo ?? ""}
|
className="inline-flex items-center gap-1.5 rounded-md border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-700 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
onChange={(e) =>
|
>
|
||||||
setWeekdayTimings((prev) => ({
|
<CopyCheck size={14} />
|
||||||
...prev,
|
Für alle Tage übernehmen
|
||||||
[day]: { ...prev[day], timeTo: e.target.value },
|
</button>
|
||||||
}))
|
</div>
|
||||||
}
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
/>
|
Wird automatisch für alle Tage gesetzt, solange du keine Uhrzeit einzeln
|
||||||
</div>
|
anpasst.
|
||||||
);
|
</p>
|
||||||
})}
|
</div>
|
||||||
{errors.weekdayTimings && (
|
|
||||||
<p className="text-red-500 text-sm">{errors.weekdayTimings}</p>
|
<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;
|
||||||
|
const slots: WeekdaySlot[] = weekdayTimings[day]?.length
|
||||||
|
? weekdayTimings[day]
|
||||||
|
: [{ timeFrom: "", timeTo: "" }];
|
||||||
|
return (
|
||||||
|
<div key={day} className="flex gap-3">
|
||||||
|
<span className="w-24 text-sm shrink-0 pt-2.5">{dayLabel}</span>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{slots.map((slot, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-3">
|
||||||
|
<InputComponent
|
||||||
|
type="time"
|
||||||
|
id={`weekday-time-from-${day}-${index}`}
|
||||||
|
value={slot.timeFrom}
|
||||||
|
onChange={(e) =>
|
||||||
|
weekdays.updateSlot(day, index, {
|
||||||
|
timeFrom: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">–</span>
|
||||||
|
<InputComponent
|
||||||
|
type="time"
|
||||||
|
id={`weekday-time-to-${day}-${index}`}
|
||||||
|
value={slot.timeTo}
|
||||||
|
onChange={(e) =>
|
||||||
|
weekdays.updateSlot(day, index, {
|
||||||
|
timeTo: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{slots.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => weekdays.removeSlot(day, index)}
|
||||||
|
aria-label="Zeitfenster entfernen"
|
||||||
|
className="text-gray-400 hover:text-red-500"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => weekdays.addSlot(day)}
|
||||||
|
className="inline-flex w-fit items-center gap-1 text-sm text-blue hover:underline"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
Weiteres Zeitfenster
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{errors.weekdayTimings && (
|
||||||
|
<p className="text-red-500 text-sm">{errors.weekdayTimings}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,18 @@
|
||||||
export type FrequencyType = "daily" | "weekly" | "biweekly" | "monthly" | "custom";
|
export type FrequencyType = "daily" | "weekly" | "biweekly" | "monthly" | "custom";
|
||||||
|
|
||||||
export type WeekdayTiming = { timeFrom: string; timeTo: string };
|
export type WeekdaySlot = { timeFrom: string; timeTo: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uhrzeiten pro Wochentag. Jeder Tag kann mehrere Zeitfenster haben
|
||||||
|
* (z.B. Montag 10:00–11:00 UND 18:00–19:00).
|
||||||
|
*/
|
||||||
|
export type WeekdayTimings = Record<string, WeekdaySlot[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Altes Format, das noch in der DB liegen kann: genau ein Zeitfenster pro Tag.
|
||||||
|
* Wird von {@link normalizeWeekdayTimings} in das neue Array-Format überführt.
|
||||||
|
*/
|
||||||
|
export type LegacyWeekdayTiming = { timeFrom: string; timeTo: string };
|
||||||
|
|
||||||
export type CourseOccurrence = { date: Date; timeFrom: string; timeTo: string };
|
export type CourseOccurrence = { date: Date; timeFrom: string; timeTo: string };
|
||||||
|
|
||||||
|
|
@ -32,6 +44,27 @@ const WEEKDAY_MAP: Record<string, number> = {
|
||||||
const toWeekdayNumber = (weekday: string): number | undefined =>
|
const toWeekdayNumber = (weekday: string): number | undefined =>
|
||||||
WEEKDAY_MAP[weekday.trim().toLowerCase()];
|
WEEKDAY_MAP[weekday.trim().toLowerCase()];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bringt beliebige gespeicherte weekdayTimings in das Array-Format.
|
||||||
|
* Akzeptiert das neue Format (`{ monday: [{...}, {...}] }`) und das alte
|
||||||
|
* Einzelobjekt-Format (`{ monday: { timeFrom, timeTo } }`).
|
||||||
|
*/
|
||||||
|
export function normalizeWeekdayTimings(
|
||||||
|
raw: Record<string, WeekdaySlot[] | LegacyWeekdayTiming | null | undefined> | null | undefined
|
||||||
|
): WeekdayTimings {
|
||||||
|
if (!raw) return {};
|
||||||
|
const result: WeekdayTimings = {};
|
||||||
|
for (const [day, value] of Object.entries(raw)) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const slots = value.filter((s): s is WeekdaySlot => !!s && typeof s === "object");
|
||||||
|
if (slots.length > 0) result[day] = slots;
|
||||||
|
} else if (value && typeof value === "object") {
|
||||||
|
result[day] = [{ timeFrom: value.timeFrom ?? "", timeTo: value.timeTo ?? "" }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export const MAX_INSTANCES = 365;
|
export const MAX_INSTANCES = 365;
|
||||||
|
|
||||||
export class TooManyInstancesError extends Error {
|
export class TooManyInstancesError extends Error {
|
||||||
|
|
@ -50,7 +83,7 @@ export function generateOccurrences(params: {
|
||||||
defaultTimeFrom: string;
|
defaultTimeFrom: string;
|
||||||
defaultTimeTo: string;
|
defaultTimeTo: string;
|
||||||
weekdays?: string[];
|
weekdays?: string[];
|
||||||
weekdayTimings?: Record<string, WeekdayTiming>;
|
weekdayTimings?: Record<string, WeekdaySlot[] | LegacyWeekdayTiming | null | undefined>;
|
||||||
}): CourseOccurrence[] {
|
}): CourseOccurrence[] {
|
||||||
const {
|
const {
|
||||||
startDate,
|
startDate,
|
||||||
|
|
@ -102,10 +135,11 @@ export function generateOccurrences(params: {
|
||||||
const targetDays = new Set(
|
const targetDays = new Set(
|
||||||
weekdays.map(toWeekdayNumber).filter((d): d is number => d !== undefined)
|
weekdays.map(toWeekdayNumber).filter((d): d is number => d !== undefined)
|
||||||
);
|
);
|
||||||
const timingsByDay = Object.entries(weekdayTimings).reduce<Record<number, WeekdayTiming>>(
|
const normalized = normalizeWeekdayTimings(weekdayTimings);
|
||||||
(acc, [weekday, timing]) => {
|
const slotsByDay = Object.entries(normalized).reduce<Record<number, WeekdaySlot[]>>(
|
||||||
|
(acc, [weekday, slots]) => {
|
||||||
const day = toWeekdayNumber(weekday);
|
const day = toWeekdayNumber(weekday);
|
||||||
if (day !== undefined) acc[day] = timing;
|
if (day !== undefined) acc[day] = slots;
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{}
|
{}
|
||||||
|
|
@ -113,8 +147,14 @@ export function generateOccurrences(params: {
|
||||||
const cursor = new Date(startDate);
|
const cursor = new Date(startDate);
|
||||||
while (cursor <= endDate) {
|
while (cursor <= endDate) {
|
||||||
if (targetDays.has(cursor.getDay())) {
|
if (targetDays.has(cursor.getDay())) {
|
||||||
const timing = timingsByDay[cursor.getDay()];
|
const slots = slotsByDay[cursor.getDay()];
|
||||||
addOccurrence(cursor, timing?.timeFrom ?? defaultTimeFrom, timing?.timeTo ?? defaultTimeTo);
|
if (slots && slots.length > 0) {
|
||||||
|
for (const slot of slots) {
|
||||||
|
addOccurrence(cursor, slot.timeFrom || defaultTimeFrom, slot.timeTo || defaultTimeTo);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
addOccurrence(cursor, defaultTimeFrom, defaultTimeTo);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
cursor.setDate(cursor.getDate() + 1);
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ const DashboardSidebar = ({ userData, displayName }: DashboardSidebarProps) => {
|
||||||
return (
|
return (
|
||||||
<Sidebar className="border-r-0 bg-white/55 backdrop-blur-xl supports-backdrop-filter:bg-white/45 shadow-[1px_0_0_0_oklch(0.85_0_0/30%)]">
|
<Sidebar className="border-r-0 bg-white/55 backdrop-blur-xl supports-backdrop-filter:bg-white/45 shadow-[1px_0_0_0_oklch(0.85_0_0/30%)]">
|
||||||
<SidebarHeader className="text-sidebar-accent-foreground">
|
<SidebarHeader className="text-sidebar-accent-foreground">
|
||||||
<Link href="/" className="flex justify-center items-center gap-2 px-2 pt-2">
|
<Link href="/" className="flex items-center gap-2 px-2 pt-2">
|
||||||
<Image src={Logo} height={45} alt="Kumasy Logo" />
|
<Image src={Logo} height={45} alt="Kumasy Logo" />
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ const settingsMenuTitles: SettingsMenuItem[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Building2,
|
icon: Building2,
|
||||||
name: "Business Einsetllungen",
|
name: "Business Einstellungen",
|
||||||
href: "/settings/business",
|
href: "/settings/business",
|
||||||
description: "Verwalte deine Business Daten",
|
description: "Verwalte deine Business Daten",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
BIN
src/public/logo-gelb.png
Normal file
BIN
src/public/logo-gelb.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Loading…
Add table
Add a link
Reference in a new issue