Kuma/src/modules/courses/ui/views/course-create-view.tsx
2026-05-05 10:45:05 +02:00

569 lines
20 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import InputComponent from "@/src/components/layout/InputComponent";
import GenericDropdown from "@/src/components/layout/GenericDropdown";
import MultiSelectDropdown from "@/src/components/layout/MultiSelectDropdown";
import RichTextEditor from "@/src/components/layout/RichTextEditor";
import { Button } from "@/src/components/ui/button";
import { Checkbox } from "@/src/components/ui/checkbox";
import { Label } from "@/src/components/ui/label";
import { createCourse, updateCourse, getMyBusinesses } from "../../actions/course-actions";
import { getMyRooms } from "@/src/modules/rooms/actions/room-actions";
import { getMyTrainers } from "@/src/modules/employee/actions/employee-actions";
import { toast } from "sonner";
import { Course } from "../../types/course.types";
import { Trash2 } from "lucide-react";
import DeleteDialog from "@/src/components/layout/DeleteDialog";
import { useDeleteCourse } from "../../hooks/useDeleteCourse";
const LEVELS = [
{ value: "any", label: "Jedes Niveau" },
{ value: "beginner", label: "Anfänger" },
{ value: "advanced", label: "Fortgeschrittene" },
{ value: "pro", label: "Profi" },
];
const FREQUENCIES = [
{ value: "daily", label: "Täglich" },
{ value: "weekly", label: "Wöchentlich" },
{ value: "biweekly", label: "Alle 2 Wochen" },
{ value: "monthly", label: "Monatlich" },
{ value: "custom", label: "Individuell" },
];
const WEEKDAYS = [
{ value: "monday", label: "Montag" },
{ value: "tuesday", label: "Dienstag" },
{ value: "wednesday", label: "Mittwoch" },
{ value: "thursday", label: "Donnerstag" },
{ value: "friday", label: "Freitag" },
{ value: "saturday", label: "Samstag" },
{ value: "sunday", label: "Sonntag" },
];
interface CourseCreateViewProps {
mode?: "create" | "edit";
courseId?: string;
initialData?: Course;
customSports?: Array<{ value: string; label: string }>;
}
const CourseCreateView = ({
mode = "create",
courseId,
initialData,
customSports = [],
}: CourseCreateViewProps = {}) => {
const router = useRouter();
const [courseName, setCourseName] = useState("");
const [courseDate, setCourseDate] = useState("");
const [timeFrom, setTimeFrom] = useState("");
const [timeTo, setTimeTo] = useState("");
const [selectedSports, setSelectedSports] = useState<string[]>([]);
const [sports, setSports] = useState<Array<{ value: string; label: string }>>(customSports);
const [newlyCreatedSports, setNewlyCreatedSports] = useState<string[]>([]);
const [selectedLevel, setSelectedLevel] = useState("");
const [selectedTrainers, setSelectedTrainers] = useState<string[]>([]);
const [selectedRoom, setSelectedRoom] = useState("");
const [description, setDescription] = useState("");
const [maxParticipants, setMaxParticipants] = useState("");
const [isStandingOrder, setIsStandingOrder] = useState(false);
const [selectedFrequency, setSelectedFrequency] = useState("");
const [selectedWeekdays, setSelectedWeekdays] = useState<string[]>([]);
const [price, setPrice] = useState("");
const [priceDisplay, setPriceDisplay] = useState("");
const [isPriceFocused, setIsPriceFocused] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [rooms, setRooms] = useState<Array<{ value: string; label: string }>>([]);
const [isLoadingRooms, setIsLoadingRooms] = useState(true);
const [trainers, setTrainers] = useState<Array<{ value: string; label: string }>>([]);
const [isLoadingTrainers, setIsLoadingTrainers] = useState(true);
const [selectedBusinessId, setSelectedBusinessId] = useState("");
const [businesses, setBusinesses] = useState<Array<{ value: string; label: string }>>([]);
const {
deleteDialogOpen,
setDeleteDialogOpen,
courseToDelete,
setCourseToDelete,
isDeleting,
handleDeleteClick,
handleDeleteConfirm,
} = useDeleteCourse({
onSuccess: () => router.push("/courses"),
});
// Load businesses from database
useEffect(() => {
const loadBusinesses = async () => {
const result = await getMyBusinesses();
if (result.success) {
const opts = result.businesses.map((b) => ({ value: b.id, label: b.name }));
setBusinesses(opts);
if (opts.length === 1) setSelectedBusinessId(opts[0].value);
} else {
toast.error("Fehler beim Laden der Standorte. Bitte laden Sie die Seite neu.");
}
};
loadBusinesses();
}, []);
// Load rooms from database
useEffect(() => {
const loadRooms = async () => {
const result = await getMyRooms();
setIsLoadingRooms(true);
if (result.success) {
const roomOptions = result.rooms.map((room) => ({
value: room.id,
label: room.name,
}));
setRooms(roomOptions);
} else {
toast.error("Fehler beim Laden der Räume. Bitte laden Sie die Seite neu.");
}
setIsLoadingRooms(false);
};
loadRooms();
}, []);
// Load trainers from database
useEffect(() => {
const loadTrainers = async () => {
setIsLoadingTrainers(true);
const result = await getMyTrainers();
if (result.success) {
setTrainers(result.trainers);
}
setIsLoadingTrainers(false);
};
loadTrainers();
}, []);
// Load initial data when in edit mode
useEffect(() => {
if (mode === "edit" && initialData) {
setCourseName(initialData.name || "");
const sportValues = initialData.sport || [];
setSelectedSports(sportValues);
// Merge initialData sports with customSports (no duplicates)
const existingValues = new Set(customSports.map((s) => s.value));
const newOptions = sportValues
.filter((s) => !existingValues.has(s))
.map((s) => ({ value: s, label: s }));
setSports([...customSports, ...newOptions]);
setSelectedLevel(initialData.level || "");
// Format date for input field (YYYY-MM-DD)
if (initialData.date) {
const date = new Date(initialData.date);
const formattedDate = [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, "0"),
String(date.getDate()).padStart(2, "0"),
].join("-");
setCourseDate(formattedDate);
}
setTimeFrom(initialData.timeFrom || "");
setTimeTo(initialData.timeTo || "");
setSelectedTrainers(initialData.trainers || []);
setSelectedRoom(initialData.room || "");
setDescription(initialData.description || "");
setMaxParticipants(initialData.maxParticipants?.toString() || "");
setPrice(initialData.price?.toString() || "");
setIsStandingOrder(initialData.isStandingOrder || false);
setSelectedFrequency(initialData.frequency || "");
setSelectedWeekdays(initialData.weekdays || []);
setSelectedBusinessId(initialData.businessId ?? "");
}
}, [mode, initialData, customSports]);
const validateForm = () => {
const newErrors: Record<string, string> = {};
if (!courseName.trim()) newErrors.name = "Kursname ist erforderlich";
if (selectedSports.length === 0) newErrors.sport = "Mindestens eine Sportart ist erforderlich";
if (!courseDate) newErrors.date = "Datum ist erforderlich";
if (!timeFrom) newErrors.timeFrom = "Anfangszeit ist erforderlich";
if (!timeTo) newErrors.timeTo = "Endzeit ist erforderlich";
// Validate that timeFrom is before timeTo
if (timeFrom && timeTo && timeFrom >= timeTo) {
newErrors.timeTo = "Endzeit muss nach der Anfangszeit liegen";
}
if (trainers.length > 0 && selectedTrainers.length === 0)
newErrors.trainers = "Mindestens ein Trainer muss ausgewählt werden";
if (!selectedRoom) newErrors.room = "Raum ist erforderlich";
const maxPart = parseInt(maxParticipants);
if (!maxParticipants || isNaN(maxPart) || maxPart < 1) {
newErrors.maxParticipants = "Mindestens 1 Teilnehmer erforderlich";
} else if (maxPart > 100) {
newErrors.maxParticipants = "Maximal 100 Teilnehmer erlaubt";
}
const priceValue = parseFloat(price);
if (!price.trim()) {
newErrors.price = "Preis ist erforderlich";
} else if (isNaN(priceValue) || priceValue < 0) {
newErrors.price = "Preis muss eine gültige positive Zahl sein";
} else if (priceValue > 9999.99) {
newErrors.price = "Preis darf maximal 9.999,99 € betragen";
}
if (isStandingOrder) {
if (!selectedFrequency)
newErrors.frequency = "Häufigkeit ist erforderlich bei Daueraufträgen";
if (selectedFrequency === "custom" && selectedWeekdays.length === 0) {
newErrors.weekdays = "Mindestens ein Tag muss ausgewählt werden";
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const validateDraft = () => {
const newErrors: Record<string, string> = {};
if (!courseName.trim()) newErrors.name = "Kursname ist erforderlich";
setErrors(newErrors);
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;
}
setIsSubmitting(true);
try {
const courseData = {
name: courseName,
sport: selectedSports,
level: selectedLevel,
date: courseDate,
timeFrom: timeFrom,
timeTo: timeTo,
trainers: selectedTrainers,
room: selectedRoom,
description,
maxParticipants: maxParticipants ? parseInt(maxParticipants) : undefined,
price: price ? parseFloat(price) : undefined,
isStandingOrder,
frequency: selectedFrequency || undefined,
weekdays: selectedWeekdays.length > 0 ? selectedWeekdays : undefined,
businessId: selectedBusinessId || undefined,
};
if (mode === "edit" && !courseId) {
throw new Error("Missing courseId in edit mode");
}
const result =
mode === "edit"
? await updateCourse(courseId!, courseData, status)
: await createCourse(courseData, status);
if (result.success) {
toast.success(result.message);
router.push("/courses");
} else {
toast.error(result.error || "Ein Fehler ist aufgetreten");
if (result.fieldErrors) {
const fieldErrors: Record<string, string> = {};
Object.entries(result.fieldErrors).forEach(([key, value]) => {
if (Array.isArray(value) && value.length > 0) {
fieldErrors[key] = value[0];
}
});
setErrors(fieldErrors);
}
}
} catch (error) {
toast.error("Ein unerwarteter Fehler ist aufgetreten");
console.error(error);
} finally {
setIsSubmitting(false);
}
};
return (
<div>
<div className="w-full flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">
{mode === "edit" ? "Kurs bearbeiten" : "Kurs anlegen"}
</h1>
<p className="text-xl mt-2">
{mode === "edit"
? "Ändern Sie die Kursdetails und speichern Sie Ihre Änderungen"
: "Alle Kurse können nach dem Speichern angepasst oder gelöscht werden"}
</p>
</div>
{mode === "edit" && (
<Button variant="destructive" onClick={() => handleDeleteClick(courseId!, courseName)}>
<Trash2 className="mb-1" /> Löschen
</Button>
)}
</div>
<div className="mt-6 flex flex-col gap-4 max-w-xl">
{businesses.length > 1 && (
<GenericDropdown
label="Standort"
selected={selectedBusinessId}
onSelect={setSelectedBusinessId}
options={businesses}
placeholder="Standort auswählen"
/>
)}
<div>
<InputComponent
isLabel
label="Kursnamen"
type="text"
id="course-create-name"
value={courseName}
onChange={(e) => setCourseName(e.target.value)}
/>
{errors.name && <p className="text-red-500 text-sm mt-1">{errors.name}</p>}
</div>
<MultiSelectDropdown
label="Sportart"
labelButton="Neue Sportart anlegen"
selected={selectedSports}
onSelect={setSelectedSports}
options={sports}
allowCreate={true}
onCreateOption={(newSport) => {
setSports([...sports, newSport]);
setNewlyCreatedSports([...newlyCreatedSports, newSport.value]);
}}
onDeleteOption={(valueToDelete) => {
setSports(sports.filter((s) => s.value !== valueToDelete));
setNewlyCreatedSports(newlyCreatedSports.filter((v) => v !== valueToDelete));
setSelectedSports(selectedSports.filter((v) => v !== valueToDelete));
}}
deletableValues={newlyCreatedSports}
error={errors.sport}
/>
<GenericDropdown
label="Niveau"
selected={selectedLevel}
onSelect={setSelectedLevel}
options={LEVELS}
/>
<div>
<InputComponent
isLabel
label="Datum"
type="date"
id="course-create-date"
value={courseDate}
onChange={(e) => setCourseDate(e.target.value)}
/>
{errors.date && <p className="text-red-500 text-sm mt-1">{errors.date}</p>}
</div>
<div>
<InputComponent
isLabel
label="Uhrzeit von"
type="time"
id="course-create-time-from"
value={timeFrom}
onChange={(e) => setTimeFrom(e.target.value)}
/>
{errors.timeFrom && <p className="text-red-500 text-sm mt-1">{errors.timeFrom}</p>}
</div>
<div>
<InputComponent
isLabel
label="Uhrzeit bis"
type="time"
id="course-create-time-to"
value={timeTo}
onChange={(e) => setTimeTo(e.target.value)}
/>
{errors.timeTo && <p className="text-red-500 text-sm mt-1">{errors.timeTo}</p>}
</div>
<div>
<InputComponent
isLabel
label="Preis (€)"
type="text"
id="course-create-price"
value={
isPriceFocused
? priceDisplay
: price
? parseFloat(price).toLocaleString("de-DE", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
: ""
}
onChange={(e) => {
const value = e.target.value;
// Remove all non-numeric characters except comma and dot
const cleanValue = value.replace(/[^\d,\.]/g, "");
// Replace comma with dot for internal storage
const normalizedValue = cleanValue.replace(",", ".");
setPriceDisplay(value);
// Allow empty string, numbers with optional decimal point and max 2 decimal places
if (normalizedValue === "" || /^\d*\.?\d{0,2}$/.test(normalizedValue)) {
setPrice(normalizedValue);
}
}}
onFocus={() => {
setIsPriceFocused(true);
setPriceDisplay(price.replace(".", ","));
}}
onBlur={() => {
setIsPriceFocused(false);
}}
placeholder="0,00"
/>
{errors.price && <p className="text-red-500 text-sm mt-1">{errors.price}</p>}
</div>
{!isLoadingTrainers && trainers.length > 0 && (
<div>
<MultiSelectDropdown
label="Trainer"
selected={selectedTrainers}
onSelect={setSelectedTrainers}
options={trainers}
error={errors.trainers}
/>
</div>
)}
<GenericDropdown
label="Raum"
selected={selectedRoom}
onSelect={setSelectedRoom}
options={rooms}
error={errors.room}
disabled={isLoadingRooms}
placeholder={isLoadingRooms ? "Räume werden geladen..." : "Raum auswählen"}
/>
<div>
<InputComponent
isLabel
label="Maximale Teilnehmerzahl"
type="number"
id="course-create-max-participants"
value={maxParticipants}
onChange={(e) => setMaxParticipants(e.target.value)}
/>
{errors.maxParticipants && (
<p className="text-red-500 text-sm mt-1">{errors.maxParticipants}</p>
)}
</div>
<RichTextEditor
label="Was man mitbringen sollte"
value={description}
onChange={setDescription}
placeholder="Beschreibe, was die Teilnehmer mitbringen sollten..."
error={errors.description}
/>
<div className="flex items-center gap-2">
<Checkbox
id="course-create-standing-order"
className="shadow-none h-5 w-5 bg-white border-blue cursor-pointer"
checked={isStandingOrder}
onCheckedChange={(checked) => {
setIsStandingOrder(checked === true);
if (!checked) {
setSelectedFrequency("");
setSelectedWeekdays([]);
}
}}
/>
<Label htmlFor="course-create-standing-order" className="cursor-pointer text-lg mt-2">
Als Dauerauftrag anlegen
</Label>
</div>
{isStandingOrder && (
<>
<GenericDropdown
label="Wie oft soll dieser Kurs stattfinden?"
selected={selectedFrequency}
onSelect={(value) => {
setSelectedFrequency(value);
if (value !== "custom") {
setSelectedWeekdays([]);
}
}}
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 className="flex gap-2">
<Button onClick={() => handleSubmit("published")} disabled={isSubmitting}>
{isSubmitting
? "Wird gespeichert..."
: mode === "edit"
? "Änderungen speichern"
: "Veröffentlichen"}
</Button>
<Button variant="outline" onClick={() => router.push("/courses")} disabled={isSubmitting}>
Abbrechen
</Button>
</div>
<button
className="mt-6 mb-4 w-fit cursor-pointer hover:underline disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => handleSubmit("draft")}
disabled={isSubmitting}
>
{mode === "edit" ? "Als Entwurf speichern" : "Entwurf speichern"}
</button>
</div>
<DeleteDialog
open={deleteDialogOpen}
onOpenChange={(open) => {
setDeleteDialogOpen(open);
if (!open) setCourseToDelete(null);
}}
itemName={courseToDelete?.name}
topicName="Kurs"
onConfirm={handleDeleteConfirm}
isLoading={isDeleting}
/>
</div>
);
};
export default CourseCreateView;