completed Implementation of User Event page

This commit is contained in:
PxlLoewe
2025-03-01 00:31:03 +01:00
parent 488c50e2e0
commit daf3238bee
6 changed files with 146 additions and 113 deletions

View File

@@ -22,9 +22,16 @@ export const KursItem = ({
<div className="card-body"> <div className="card-body">
<h2 className="card-title">{event.name}</h2> <h2 className="card-title">{event.name}</h2>
<div className="absolute top-0 right-0 m-4"> <div className="absolute top-0 right-0 m-4">
{event.type === "COURSE" && (
<span className="badge badge-info badge-outline"> <span className="badge badge-info badge-outline">
Zusatzqualifikation Zusatzqualifikation
</span> </span>
)}
{event.type === "OBLIGATED_COURSE" && (
<span className="badge badge-secondary badge-outline">
Verpflichtend
</span>
)}
</div> </div>
<div className="grid grid-cols-6 gap-4"> <div className="grid grid-cols-6 gap-4">
<div className="col-span-4"> <div className="col-span-4">
@@ -65,59 +72,7 @@ export const KursItem = ({
event={event} event={event}
title={event.name} title={event.name}
dates={event.appointments} dates={event.appointments}
modalId={`${event.name}_modal.${event.id}`} participant={event.participants[0]}
/>
</div>
</div>
</div>
</div>
);
};
export const ObligatedEvent = ({
event,
selectedAppointments,
user,
}: {
event: Event;
user: User;
selectedAppointments: EventAppointment[];
}) => {
{
/* STATISCH, DA FÜR ALLE NEUEN MITGLIEDER MANDATORY, WIRD AUSGEBLENDET WENN ABSOLVIERT */
}
return (
<div className="col-span-full">
<div className="card card-bordered border-secondary bg-base-200 shadow-xl mb-4">
<div className="card-body">
<h2 className="card-title">Einsteigerkurs für Piloten</h2>
<div className="absolute top-0 right-0 m-4">
<span className="badge badge-secondary badge-outline">
Verpflichtend
</span>
</div>
<div className="grid grid-cols-6 gap-4">
<div className="col-span-4">
<p className="text-left text-balance">
In diesem Kurs lernen Piloten die Grundlagen der Luftrettung,
Einsatzverfahren, den Umgang mit dem BOS-Funk und einige
medizinische Basics. Der Kurs bietet eine ideale Vorbereitung
für alle Standard Operations bei Virtual Air Rescue.
</p>
</div>
<div className="col-span-2">Badge</div>
</div>
<div className="card-actions flex justify-between items-center mt-5">
<p className="text-gray-600 text-left flex items-center gap-2">
<DrawingPinFilledIcon /> <b>Teilnahmevoraussetzungen:</b> Keine
</p>
<ModalBtn
selectedAppointments={selectedAppointments}
user={user}
event={event}
title={event.name}
dates={(event as any).appointments}
participant={(event as any).participants[0]}
modalId={`${event.name}_modal.${event.id}`} modalId={`${event.name}_modal.${event.id}`}
/> />
</div> </div>

View File

@@ -7,9 +7,21 @@ import {
} from "@radix-ui/react-icons"; } from "@radix-ui/react-icons";
import { Event, EventAppointment, Participant, User } from "@repo/db"; import { Event, EventAppointment, Participant, User } from "@repo/db";
import { cn } from "../../../../helper/cn"; import { cn } from "../../../../helper/cn";
import { addParticipant, inscribeToMoodleCourse } from "../actions"; import { inscribeToMoodleCourse, upsertParticipant } from "../actions";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { Cross } from "lucide-react"; import { Clock10Icon, Cross } from "lucide-react";
import { useForm } from "react-hook-form";
import {
EventAppointmentOptionalDefaults,
EventAppointmentSchema,
ParticipantOptionalDefaults,
ParticipantOptionalDefaultsSchema,
ParticipantSchema,
} from "@repo/db/zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { Select } from "../../../_components/ui/Select";
import toast from "react-hot-toast";
import { useRouter } from "next/navigation";
interface ModalBtnProps { interface ModalBtnProps {
title: string; title: string;
@@ -45,6 +57,7 @@ const ModalBtn = ({
modal?.removeEventListener("close", handleClose); modal?.removeEventListener("close", handleClose);
}; };
}, [modalId]); }, [modalId]);
const router = useRouter();
const canSelectDate = const canSelectDate =
event.hasPresenceEvents && event.hasPresenceEvents &&
@@ -62,7 +75,15 @@ const ModalBtn = ({
document.body.classList.remove("modal-open"); document.body.classList.remove("modal-open");
modal?.close(); modal?.close();
}; };
const selectAppointmentForm = useForm<ParticipantOptionalDefaults>({
resolver: zodResolver(ParticipantOptionalDefaultsSchema),
defaultValues: {
eventId: event.id,
userId: user.id,
...participant,
},
});
const selectedAppointment = selectedAppointments[0];
return ( return (
<> <>
<button <button
@@ -83,14 +104,16 @@ const ModalBtn = ({
<div className="flex items-center gap-2 justify-center"> <div className="flex items-center gap-2 justify-center">
<CalendarIcon /> <CalendarIcon />
{!!dates.length && ( {!!dates.length && (
<select className="select w-full max-w-xs" defaultValue={0}> <Select
<option disabled>Bitte wähle einen Termin aus</option> form={selectAppointmentForm as any}
{dates.map((date, index) => ( options={dates.map((date) => ({
<option key={index}> label: new Date(date.appointmentDate).toLocaleString(),
{date.appointmentDate.toLocaleString()} value: date.id,
</option> }))}
))} name="eventAppointmentId"
</select> label={""}
className="min-w-[200px]"
/>
)} )}
{!dates.length && ( {!dates.length && (
<p className="text-center text-info"> <p className="text-center text-info">
@@ -99,6 +122,31 @@ const ModalBtn = ({
)} )}
</div> </div>
)} )}
{selectedAppointment && !participant?.appointmentCancelled && (
<div className="flex items-center gap-2 justify-center">
<p>Dein Ausgewähler Termin</p>
<p>
{new Date(
selectedAppointment.appointmentDate,
).toLocaleString()}
</p>
<button
onClick={async () => {
await upsertParticipant({
eventId: event.id,
userId: user.id,
appointmentCancelled: true,
});
toast.success("Termin abgesagt");
router.refresh();
}}
className="btn btn-error btn-outline btn-sm"
>
absagen
</button>
</div>
)}
{!!dates.length && ( {!!dates.length && (
<p className="mt-3 text-center"> <p className="mt-3 text-center">
Bitte finde dich an diesem Termin in unserem Discord ein. Bitte finde dich an diesem Termin in unserem Discord ein.
@@ -111,7 +159,7 @@ const ModalBtn = ({
participant={participant} participant={participant}
user={user} user={user}
moodleCourseId={event.finisherMoodleCourseId} moodleCourseId={event.finisherMoodleCourseId}
completed={participant?.finisherMoodleCurseCompleted} completed={participant?.finisherMoodleCurseCompleted || false}
event={event} event={event}
/> />
)} )}
@@ -119,8 +167,27 @@ const ModalBtn = ({
<button className="btn" onClick={closeModal}> <button className="btn" onClick={closeModal}>
Abbrechen Abbrechen
</button> </button>
{!!(event.hasPresenceEvents && dates.length) && ( {!!canSelectDate && (
<button className="btn btn-info btn-outline btn-wide"> <button
className={cn(
"btn btn-info btn-outline btn-wide",
event.type === "OBLIGATED_COURSE" && "btn-secondary",
)}
onClick={async () => {
console.log("submit", selectAppointmentForm.formState.errors);
const data = selectAppointmentForm.getValues();
if (!data.eventAppointmentId) return;
console.log("submit", data);
await upsertParticipant({
...data,
statusLog: data.statusLog?.filter((log) => log !== null),
appointmentCancelled: false,
});
router.refresh();
closeModal();
}}
>
<EnterIcon /> Anmelden <EnterIcon /> Anmelden
</button> </button>
)} )}
@@ -150,6 +217,13 @@ const MoodleCourseIndicator = ({
event: Event; event: Event;
}) => { }) => {
const courseUrl = `${process.env.NEXT_PUBLIC_MOODLE_URL}/course/view.php?id=${moodleCourseId}`; const courseUrl = `${process.env.NEXT_PUBLIC_MOODLE_URL}/course/view.php?id=${moodleCourseId}`;
if (!participant || (event.hasPresenceEvents && !participant?.attended))
return (
<p className="py-4 flex items-center gap-2 justify-center">
<Clock10Icon className="text-error" />
Abschlusstest erst nach Teilnahme verfügbar
</p>
);
if (completed) if (completed)
return ( return (
<p className="py-4 flex items-center gap-2 justify-center"> <p className="py-4 flex items-center gap-2 justify-center">
@@ -157,20 +231,17 @@ const MoodleCourseIndicator = ({
Moodle Kurs abgeschlossen Moodle Kurs abgeschlossen
</p> </p>
); );
if (!participant || (event.hasPresenceEvents && !participant?.attended))
return (
<p className="py-4 flex items-center gap-2 justify-center">
<Cross className="text-error" />
Teilnahme an Event erforderlich
</p>
);
return ( return (
<p className="py-4 flex items-center gap-2 justify-center"> <p className="py-4 flex items-center gap-2 justify-center">
Moodle-Kurs Benötigt Moodle-Kurs Benötigt
<button <button
className="btn btn-xs btn-info ml-2" className="btn btn-xs btn-info ml-2"
onClick={async () => { onClick={async () => {
await addParticipant(event.id, user.id); await upsertParticipant({
eventId: event.id,
userId: user.id,
finisherMoodleCurseCompleted: false,
});
if (user.moodleId) { if (user.moodleId) {
await inscribeToMoodleCourse(moodleCourseId, user.moodleId); await inscribeToMoodleCourse(moodleCourseId, user.moodleId);

View File

@@ -1,6 +1,6 @@
"use server"; "use server";
import { enrollUserInCourse } from "../../../helper/moodle"; import { enrollUserInCourse } from "../../../helper/moodle";
import { prisma } from "@repo/db"; import { Prisma, prisma } from "@repo/db";
export const inscribeToMoodleCourse = async ( export const inscribeToMoodleCourse = async (
moodleCourseId: string | number, moodleCourseId: string | number,
@@ -9,18 +9,24 @@ export const inscribeToMoodleCourse = async (
await enrollUserInCourse(moodleCourseId, userMoodleId); await enrollUserInCourse(moodleCourseId, userMoodleId);
}; };
export const addParticipant = async (eventId: number, userId: string) => { export const upsertParticipant = async (
data: Prisma.ParticipantUncheckedCreateInput,
) => {
const participant = await prisma.participant.findFirst({ const participant = await prisma.participant.findFirst({
where: { where: {
userId: userId, userId: data.userId,
eventId: data.eventId,
}, },
}); });
if (!participant) { if (!participant) {
await prisma.participant.create({ return await prisma.participant.create({
data: { data,
userId: userId,
eventId,
},
}); });
} }
return await prisma.participant.update({
where: {
id: participant.id,
},
data,
});
}; };

View File

@@ -1,6 +1,6 @@
import { getServerSession } from "../../api/auth/[...nextauth]/auth"; import { getServerSession } from "../../api/auth/[...nextauth]/auth";
import { PrismaClient } from "@repo/db"; import { PrismaClient } from "@repo/db";
import { ObligatedEvent, KursItem } from "./_components/item"; import { KursItem } from "./_components/item";
import { RocketIcon } from "@radix-ui/react-icons"; import { RocketIcon } from "@radix-ui/react-icons";
export default async () => { export default async () => {
@@ -49,16 +49,6 @@ export default async () => {
</div> </div>
{events.map((event) => { {events.map((event) => {
if (event.type === "OBLIGATED_COURSE")
return (
<ObligatedEvent
selectedAppointments={userAppointments}
user={user}
event={event}
key={event.id}
/>
);
if (event.type === "COURSE")
return ( return (
<KursItem <KursItem
selectedAppointments={userAppointments} selectedAppointments={userAppointments}

View File

@@ -11,14 +11,15 @@ import SelectTemplate, {
} from "react-select"; } from "react-select";
import { cn } from "../../../helper/cn"; import { cn } from "../../../helper/cn";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { CSSProperties } from "react";
interface SelectProps<T extends FieldValues> { interface SelectProps<T extends FieldValues>
extends Omit<SelectTemplateProps, "form"> {
label?: any;
name: Path<T>; name: Path<T>;
form: UseFormReturn<T>; form: UseFormReturn<T>;
formOptions?: RegisterOptions<T>; formOptions?: RegisterOptions<T>;
label?: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any
placeholder?: string;
className?: string;
} }
const customStyles: StylesConfig<any, false> = { const customStyles: StylesConfig<any, false> = {
@@ -73,7 +74,6 @@ const SelectCom = <T extends FieldValues>({
{label} {label}
</span> </span>
<SelectTemplate <SelectTemplate
{...form.register(name, formOptions)}
onChange={(newValue: any) => { onChange={(newValue: any) => {
if (Array.isArray(newValue)) { if (Array.isArray(newValue)) {
form.setValue(name, newValue.map((v: any) => v.value) as any); form.setValue(name, newValue.map((v: any) => v.value) as any);
@@ -82,7 +82,16 @@ const SelectCom = <T extends FieldValues>({
} }
form.trigger(name); form.trigger(name);
}} }}
styles={customStyles} value={
(inputProps as any)?.isMulti
? (inputProps as any).options?.filter((o: any) =>
form.watch(name)?.includes(o.value),
)
: (inputProps as any).options?.find(
(o: any) => o.value === form.watch(name),
)
}
styles={customStyles as any}
className={cn("w-full placeholder:text-neutral-600", className)} className={cn("w-full placeholder:text-neutral-600", className)}
placeholder={placeholder} placeholder={placeholder}
{...inputProps} {...inputProps}
@@ -96,7 +105,9 @@ const SelectCom = <T extends FieldValues>({
); );
}; };
const SelectWrapper = (props: any) => <SelectCom {...props} />; const SelectWrapper = <T extends FieldValues>(props: SelectProps<T>) => (
<SelectCom {...props} />
);
export const Select = dynamic(() => Promise.resolve(SelectWrapper), { export const Select = dynamic(() => Promise.resolve(SelectWrapper), {
ssr: false, ssr: false,

View File

@@ -24,7 +24,7 @@ model Participant {
appointmentCancelled Boolean @default(false) appointmentCancelled Boolean @default(false)
finished Boolean @default(false) finished Boolean @default(false)
eventAppointmentId Int? eventAppointmentId Int?
statusLog Json[] statusLog Json[] @default([])
eventId Int eventId Int
// relations: // relations:
User User @relation(fields: [userId], references: [id]) User User @relation(fields: [userId], references: [id])