mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 21:41:17 +00:00
1003 lines
38 KiB
TypeScript
1003 lines
38 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 { 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;
|