Kuma/src/modules/courses/ui/views/course-create-view.tsx
2026-09-11 12:23:43 +02:00

1003 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 { 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,
ImagePlus,
X,
Loader2,
AlertTriangle,
Plus,
Info,
CalendarDays,
Settings2,
FileText,
RefreshCw,
Check,
} from "lucide-react";
import Image from "next/image";
import DeleteDialog from "@/src/components/layout/DeleteDialog";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
} from "@/src/components/ui/dialog";
import { Progress } from "@/src/components/ui/progress";
import { downsizeImage } from "@/src/lib/image/downsize";
import { useDeleteCourse } from "../../hooks/useDeleteCourse";
import { useWeekdayTimings, type WeekdaySlot } from "../../hooks/useWeekdayTimings";
import {
generateOccurrences,
TooManyInstancesError,
type FrequencyType,
} from "../../utils/generate-instances";
import {
StandingOrderScopeDialog,
type StandingOrderScope,
} from "../components/StandingOrderScopeDialog";
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 isEdit = mode === "edit" && !!initialData;
const [courseName, setCourseName] = useState(isEdit ? initialData!.name || "" : "");
const [courseDate, setCourseDate] = useState(() => {
if (isEdit && initialData!.date) {
const d = new Date(initialData!.date);
return [
d.getFullYear(),
String(d.getMonth() + 1).padStart(2, "0"),
String(d.getDate()).padStart(2, "0"),
].join("-");
}
return "";
});
const [timeFrom, setTimeFrom] = useState(isEdit ? initialData!.timeFrom || "" : "");
const [timeTo, setTimeTo] = useState(isEdit ? initialData!.timeTo || "" : "");
const [selectedSports, setSelectedSports] = useState<string[]>(
isEdit ? initialData!.sport || [] : []
);
const [sports, setSports] = useState<Array<{ value: string; label: string }>>(() => {
if (isEdit) {
const existingValues = new Set(customSports.map((s) => s.value));
const extra = (initialData!.sport || [])
.filter((s) => !existingValues.has(s))
.map((s) => ({ value: s, label: s }));
return [...customSports, ...extra];
}
return customSports;
});
const [newlyCreatedSports, setNewlyCreatedSports] = useState<string[]>([]);
const [selectedLevel, setSelectedLevel] = useState(isEdit ? initialData!.level || "" : "");
const [selectedTrainers, setSelectedTrainers] = useState<string[]>(
isEdit ? initialData!.trainers || [] : []
);
const [selectedRoom, setSelectedRoom] = useState(isEdit ? initialData!.room || "" : "");
const [description, setDescription] = useState(isEdit ? initialData!.description || "" : "");
const [maxParticipants, setMaxParticipants] = useState(
isEdit ? initialData!.maxParticipants?.toString() || "" : ""
);
const [isStandingOrder, setIsStandingOrder] = useState(
isEdit ? initialData!.isStandingOrder || false : false
);
const [selectedFrequency, setSelectedFrequency] = useState(
isEdit ? initialData!.frequency || "" : ""
);
const weekdays = useWeekdayTimings({
initialWeekdays: isEdit ? initialData!.weekdays || [] : [],
initialTimings:
isEdit && initialData!.weekdayTimings
? (initialData!.weekdayTimings as Record<string, WeekdaySlot | WeekdaySlot[]>)
: undefined,
// Neu ausgewählte Tage mit der allgemeinen Kurs-Uhrzeit oben vorbefüllen.
defaultSlot: { timeFrom, timeTo },
});
const { selectedWeekdays, weekdayTimings } = weekdays;
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 [standingOrderConfirmOpen, setStandingOrderConfirmOpen] = useState(false);
const [standingOrderConfirmInfo, setStandingOrderConfirmInfo] = useState<{
existingBookingsCount: number;
message: string;
} | null>(null);
const [price, setPrice] = useState(isEdit ? initialData!.price?.toString() || "" : "");
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(
isEdit ? (initialData!.businessId ?? "") : ""
);
const [businesses, setBusinesses] = useState<Array<{ value: string; label: string }>>([]);
const [coverImage, setCoverImage] = useState<string | null>(
isEdit ? (initialData!.coverImage ?? null) : null
);
const [isUploadingImage, setIsUploadingImage] = useState(false);
const [imageUploadPhase, setImageUploadPhase] = useState<"optimizing" | "uploading" | null>(null);
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"),
});
// 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();
}, []);
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsUploadingImage(true);
setImageUploadPhase("optimizing");
try {
const resizedFile = await downsizeImage(file);
setImageUploadPhase("uploading");
const formData = new FormData();
formData.append("file", resizedFile);
const res = await fetch("/api/upload/course-image", { method: "POST", body: formData });
const json = await res.json();
if (!res.ok) throw new Error(json.error);
setCoverImage(json.publicUrl);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Fehler beim Hochladen");
} finally {
setImageUploadPhase(null);
setIsUploadingImage(false);
}
};
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 (!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 (!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 &&
weekdays.hasInvalidTiming
) {
newErrors.weekdayTimings =
"Bitte gültige Uhrzeiten für alle Zeitfenster eingeben (Ende nach Beginn)";
}
if (!newErrors.endDate && selectedFrequency && courseDate && endDate) {
try {
generateOccurrences({
startDate: new Date(courseDate),
endDate: new Date(endDate),
frequency: selectedFrequency as FrequencyType,
defaultTimeFrom: timeFrom,
defaultTimeTo: timeTo,
weekdays: selectedWeekdays,
weekdayTimings,
});
} catch (err) {
newErrors.endDate =
err instanceof TooManyInstancesError
? err.message
: "Ungültiger Zeitraum für den Dauerauftrag";
}
}
}
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 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: weekdays.cleanedTimings,
endDate: endDate || undefined,
businessId: selectedBusinessId || undefined,
});
const doSubmit = async (
status: "draft" | "published",
scope: StandingOrderScope = "this",
confirmedStandingOrderConversion = false
) => {
setIsSubmitting(true);
try {
const courseData = buildCourseData();
if (mode === "edit" && !courseId) throw new Error("Missing courseId in edit mode");
const result =
mode === "edit"
? await updateCourse(
courseId!,
courseData,
status,
scope,
confirmedStandingOrderConversion
)
: await createCourse(courseData, status);
if (result.success) {
toast.success(result.message);
router.push("/courses");
} else if ("requiresStandingOrderConfirmation" in result && courseId) {
setStandingOrderConfirmInfo({
existingBookingsCount: result.existingBookingsCount ?? 0,
message: result.error || "",
});
setPendingSubmitStatus(status);
setStandingOrderConfirmOpen(true);
} 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);
}
};
const handleStandingOrderConfirm = async () => {
setStandingOrderConfirmOpen(false);
if (pendingSubmitStatus) {
await doSubmit(pendingSubmitStatus, "this", true);
setPendingSubmitStatus(null);
}
setStandingOrderConfirmInfo(null);
};
const handleSubmit = async (status: "draft" | "published") => {
if (isUploadingImage) {
toast.info("Bitte warten Sie, bis das Kursbild hochgeladen wurde.");
return;
}
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>
{/* Page header */}
<div className="w-full flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-bold">
{mode === "edit" ? "Kurs bearbeiten" : "Kurs anlegen"}
</h1>
<p className="text-gray-500 mt-1">
{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, isStandingOrderRelated)}
>
<Trash2 className="mb-1" /> Löschen
</Button>
)}
</div>
<div className="max-w-3xl flex flex-col gap-5">
{/* Section 1: Grunddaten */}
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm p-5 sm:p-6">
<div className="flex items-center gap-2 mb-5">
<div className="flex items-center justify-center size-8 rounded-lg bg-[#c9a84c]/10 text-[#c9a84c]">
<Info className="size-4" />
</div>
<h2 className="text-base font-semibold text-gray-900">Grunddaten</h2>
</div>
<div className="flex flex-col gap-4">
{businesses.length > 1 && (
<GenericDropdown
label="Standort"
selected={selectedBusinessId}
onSelect={setSelectedBusinessId}
options={businesses}
placeholder="Standort auswählen"
/>
)}
<div>
<InputComponent
isLabel
label="Kursname"
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>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<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>
</div>
</div>
{/* Section 2: Zeitplan */}
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm p-5 sm:p-6">
<div className="flex items-center gap-2 mb-5">
<div className="flex items-center justify-center size-8 rounded-lg bg-blue-50 text-blue-500">
<CalendarDays className="size-4" />
</div>
<h2 className="text-base font-semibold text-gray-900">Zeitplan</h2>
</div>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<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="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="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>
{/* Dauerauftrag toggle */}
<button
type="button"
onClick={() => {
const next = !isStandingOrder;
setIsStandingOrder(next);
if (!next) {
setSelectedFrequency("");
setEndDate("");
weekdays.reset();
}
}}
className={`flex items-center gap-3 w-full rounded-xl border p-4 text-left transition-all cursor-pointer ${
isStandingOrder
? "border-[#c9a84c]/50 bg-[#c9a84c]/5"
: "border-gray-200 bg-white/40 hover:border-gray-300 hover:bg-white/60"
}`}
>
<div
className={`flex items-center justify-center size-8 rounded-full border-2 transition-all shrink-0 ${
isStandingOrder ? "border-[#c9a84c] bg-[#c9a84c]" : "border-gray-300 bg-white"
}`}
>
{isStandingOrder && <Check className="size-4 text-white" />}
</div>
<div>
<p className="text-sm font-semibold text-gray-900">Als Dauerauftrag anlegen</p>
<p className="text-xs text-gray-500 mt-0.5">
Kurs wiederholt sich automatisch nach einem Zeitplan
</p>
</div>
<RefreshCw
className={`ml-auto size-4 shrink-0 transition-colors ${
isStandingOrder ? "text-[#c9a84c]" : "text-gray-300"
}`}
/>
</button>
{isStandingOrder && (
<div className="flex flex-col gap-4 pt-1">
<GenericDropdown
label="Wie oft soll dieser Kurs stattfinden?"
selected={selectedFrequency}
onSelect={(value) => {
setSelectedFrequency(value);
if (value !== "custom") {
weekdays.reset();
}
}}
options={FREQUENCIES}
error={errors.frequency}
/>
<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={weekdays.syncWeekdays}
options={WEEKDAYS}
error={errors.weekdays}
/>
{selectedWeekdays.length > 0 && (
<div className="space-y-3">
<p className="text-sm font-medium text-gray-700">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 text-gray-600 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 text-gray-400">–</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>
</div>
{/* Section 3: Logistik */}
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm p-5 sm:p-6">
<div className="flex items-center gap-2 mb-5">
<div className="flex items-center justify-center size-8 rounded-lg bg-emerald-50 text-emerald-500">
<Settings2 className="size-4" />
</div>
<h2 className="text-base font-semibold text-gray-900">Logistik</h2>
</div>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<GenericDropdown
label="Raum"
selected={selectedRoom}
onSelect={setSelectedRoom}
options={rooms}
error={errors.room}
disabled={isLoadingRooms}
placeholder={isLoadingRooms ? "Räume werden geladen..." : "Raum auswählen"}
/>
{!isLoadingTrainers && trainers.length > 0 && (
<MultiSelectDropdown
label="Trainer"
selected={selectedTrainers}
onSelect={setSelectedTrainers}
options={trainers}
error={errors.trainers}
/>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<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;
const cleanValue = value.replace(/[^\d,\.]/g, "");
const normalizedValue = cleanValue.replace(",", ".");
setPriceDisplay(value);
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>
<div>
<InputComponent
isLabel
label="Max. Teilnehmer"
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>
</div>
</div>
</div>
{/* Section 4: Inhalt */}
<div className="border border-white/60 bg-white/60 backdrop-blur-xl rounded-2xl shadow-sm p-5 sm:p-6">
<div className="flex items-center gap-2 mb-5">
<div className="flex items-center justify-center size-8 rounded-lg bg-purple-50 text-purple-500">
<FileText className="size-4" />
</div>
<h2 className="text-base font-semibold text-gray-900">Inhalt</h2>
</div>
<div className="flex flex-col gap-5">
<div>
<p className="text-sm font-semibold text-[#c9a84c] mb-2">Kursbild (optional)</p>
{coverImage ? (
<div className="relative w-full h-48 rounded-xl overflow-hidden border border-gray-200">
<Image src={coverImage} alt="Kursbild" fill className="object-cover" />
<button
type="button"
onClick={() => setCoverImage(null)}
className="absolute top-2 right-2 bg-white rounded-full p-1 shadow hover:bg-gray-100"
>
<X size={16} />
</button>
</div>
) : (
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-gray-200 rounded-xl cursor-pointer hover:border-[#c9a84c]/50 hover:bg-[#c9a84c]/5 transition-colors px-6">
{isUploadingImage ? (
<div className="flex flex-col items-center gap-2 w-full">
<Loader2 size={20} className="text-gray-400 animate-spin" />
<span className="text-sm text-gray-500">
{imageUploadPhase === "optimizing"
? "Bild wird optimiert…"
: "Wird hochgeladen…"}
</span>
<Progress
value={imageUploadPhase === "optimizing" ? 40 : 85}
className="w-full max-w-48"
/>
</div>
) : (
<>
<ImagePlus size={24} className="text-gray-300 mb-1.5" />
<span className="text-sm text-gray-500 font-medium">Bild auswählen</span>
<span className="text-xs text-gray-400 mt-0.5">max. 5 MB</span>
</>
)}
<input
type="file"
accept="image/*"
className="hidden"
disabled={isUploadingImage}
onChange={handleImageUpload}
/>
</label>
)}
</div>
<RichTextEditor
label="Was man mitbringen sollte"
value={description}
onChange={setDescription}
placeholder="Beschreibe, was die Teilnehmer mitbringen sollten..."
error={errors.description}
/>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-between pb-4">
<div className="flex items-center gap-2">
<Button
onClick={() => handleSubmit("published")}
disabled={isSubmitting || isUploadingImage}
className="bg-[#c9a84c] hover:bg-[#b8963e] text-white border-0"
>
{isSubmitting ? (
<>
<Loader2 className="size-4 animate-spin mr-1" />
Wird gespeichert...
</>
) : mode === "edit" ? (
"Änderungen speichern"
) : (
"Veröffentlichen"
)}
</Button>
<Button
variant="outline"
onClick={() => router.push("/courses")}
disabled={isSubmitting || isUploadingImage}
>
Abbrechen
</Button>
</div>
<Button
variant="ghost"
onClick={() => handleSubmit("draft")}
disabled={isSubmitting || isUploadingImage}
className="text-gray-500 hover:text-gray-700"
>
{mode === "edit" ? "Als Entwurf speichern" : "Entwurf speichern"}
</Button>
</div>
</div>
<DeleteDialog
open={deleteDialogOpen}
onOpenChange={(open) => {
setDeleteDialogOpen(open);
if (!open) setCourseToDelete(null);
}}
itemName={courseToDelete?.name}
topicName="Kurs"
onConfirm={handleDeleteConfirm}
isLoading={isDeleting}
/>
<StandingOrderScopeDialog
open={deleteScopeDialogOpen}
onOpenChange={setDeleteScopeDialogOpen}
onConfirm={handleDeleteScopeConfirm}
action="delete"
/>
<StandingOrderScopeDialog
open={editScopeDialogOpen}
onOpenChange={setEditScopeDialogOpen}
onConfirm={handleEditScopeConfirm}
action="edit"
/>
<Dialog
open={standingOrderConfirmOpen}
onOpenChange={(open) => {
setStandingOrderConfirmOpen(open);
if (!open) {
setStandingOrderConfirmInfo(null);
setPendingSubmitStatus(null);
}
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex gap-4 items-center text-xl">
<AlertTriangle /> Kurs hat bereits Buchungen
</DialogTitle>
<DialogDescription className="mt-2 text-gray-600">
{standingOrderConfirmInfo?.existingBookingsCount ?? 0}{" "}
{standingOrderConfirmInfo?.existingBookingsCount === 1 ? "Buchung" : "Buchungen"}{" "}
existieren für diesen Kurs. {standingOrderConfirmInfo?.message}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose className="mt-6 flex gap-2" asChild>
<Button
variant="outline"
onClick={() => {
setStandingOrderConfirmInfo(null);
setPendingSubmitStatus(null);
}}
>
Abbrechen
</Button>
</DialogClose>
<Button
variant="destructive"
className="mt-6"
onClick={handleStandingOrderConfirm}
disabled={isSubmitting}
>
{isSubmitting ? "Wird umgewandelt..." : "Trotzdem umwandeln"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
export default CourseCreateView;