Event admin redesign #153

Merged
PxlLoewe merged 3 commits from event-admin-redesign into staging 2026-01-29 20:49:06 +00:00
23 changed files with 473 additions and 876 deletions

View File

@@ -7,7 +7,6 @@ const router: Router = Router();
export const eventCompleted = (event: Event, participant?: Participant) => { export const eventCompleted = (event: Event, participant?: Participant) => {
if (!participant) return false; if (!participant) return false;
if (event.finisherMoodleCourseId && !participant.finisherMoodleCurseCompleted) return false; if (event.finisherMoodleCourseId && !participant.finisherMoodleCurseCompleted) return false;
if (event.hasPresenceEvents && !participant.attended) return false;
return true; return true;
}; };

View File

@@ -23,15 +23,6 @@ const page = async () => {
userId: user.id, userId: user.id,
}, },
}, },
Appointments: {
include: {
Participants: {
where: {
appointmentCancelled: false,
},
},
},
},
}, },
}); });
@@ -47,23 +38,13 @@ const page = async () => {
return ( return (
<div> <div>
<div className="col-span-full"> <div className="col-span-full">
<p className="text-xl font-semibold text-left flex items-center gap-2 mb-2 mt-5"> <p className="mb-2 mt-5 flex items-center gap-2 text-left text-xl font-semibold">
<RocketIcon className="w-4 h-4" /> Laufende Events & Kurse <RocketIcon className="h-4 w-4" /> Laufende Events & Kurse
</p> </p>
</div> </div>
<div className="grid grid-cols-6 gap-4"> <div className="grid grid-cols-6 gap-4">
{filteredEvents.map((event) => { {filteredEvents.map((event) => {
return ( return <EventCard user={user} event={event} key={event.id} />;
<EventCard
appointments={event.Appointments}
selectedAppointments={event.Appointments.filter((a) =>
a.Participants.find((p) => p.userId == user.id),
)}
user={user}
event={event}
key={event.id}
/>
);
})} })}
</div> </div>
</div> </div>

View File

@@ -20,18 +20,18 @@ const PathsOptions = ({
<div className="flex gap-6"> <div className="flex gap-6">
{/* Disponent Card */} {/* Disponent Card */}
<div <div
className={`cursor-pointer border rounded-lg p-6 w-80 transition-colors ${ className={`w-80 cursor-pointer rounded-lg border p-6 transition-colors ${
selected === "disponent" ? "border-info ring-2 ring-info" : "border-base-300" selected === "disponent" ? "border-info ring-info ring-2" : "border-base-300"
}`} }`}
onClick={() => setSelected("disponent")} onClick={() => setSelected("disponent")}
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-pressed={selected === "disponent"} aria-pressed={selected === "disponent"}
> >
<h1 className="font-semibold text-lg mb-2 flex gap-2 justify-center items-center"> <h1 className="mb-2 flex items-center justify-center gap-2 text-lg font-semibold">
Disponent <Workflow /> Disponent <Workflow />
</h1> </h1>
<div className="text-sm text-base-content/70"> <div className="text-base-content/70 text-sm">
Denkt sich realistische Einsatzszenarien aus, koordiniert deren Ablauf und ist die Denkt sich realistische Einsatzszenarien aus, koordiniert deren Ablauf und ist die
zentrale Schnittstelle zwischen Piloten und bodengebundenen Rettungsmitteln. Er trägt zentrale Schnittstelle zwischen Piloten und bodengebundenen Rettungsmitteln. Er trägt
die Verantwortung für einen reibungslosen Ablauf und der erfolgreichen Durchführung der die Verantwortung für einen reibungslosen Ablauf und der erfolgreichen Durchführung der
@@ -43,18 +43,18 @@ const PathsOptions = ({
</div> </div>
{/* Pilot Card */} {/* Pilot Card */}
<div <div
className={`cursor-pointer border rounded-lg p-6 w-80 transition-colors ${ className={`w-80 cursor-pointer rounded-lg border p-6 transition-colors ${
selected === "pilot" ? "border-info ring-2 ring-info" : "border-base-300" selected === "pilot" ? "border-info ring-info ring-2" : "border-base-300"
}`} }`}
onClick={() => setSelected("pilot")} onClick={() => setSelected("pilot")}
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-pressed={selected === "pilot"} aria-pressed={selected === "pilot"}
> >
<h1 className="font-semibold text-lg mb-2 flex gap-2 justify-center items-center"> <h1 className="mb-2 flex items-center justify-center gap-2 text-lg font-semibold">
Pilot <Plane /> Pilot <Plane />
</h1> </h1>
<div className="text-sm text-base-content/70"> <div className="text-base-content/70 text-sm">
Fliegt die vom Disponenten erstellten Einsätze und transportiert die Med-Crew sicher zum Fliegt die vom Disponenten erstellten Einsätze und transportiert die Med-Crew sicher zum
Einsatzort. Er übernimmt die navigatorische Vorbereitung, achtet auf Wetterentwicklungen Einsatzort. Er übernimmt die navigatorische Vorbereitung, achtet auf Wetterentwicklungen
und sorgt für die Sicherheit seiner Crew im Flug. und sorgt für die Sicherheit seiner Crew im Flug.
@@ -76,17 +76,7 @@ const EventSelect = ({ pathSelected }: { pathSelected: "disponent" | "pilot" })
const user = useSession().data?.user; const user = useSession().data?.user;
if (!user) return null; if (!user) return null;
return events?.map((event) => { return events?.map((event) => {
return ( return <EventCard user={user} event={event} key={event.id} />;
<EventCard
appointments={event.Appointments}
selectedAppointments={event.Appointments.filter((a) =>
a.Participants.find((p) => p.userId == user.id),
)}
user={user}
event={event}
key={event.id}
/>
);
}); });
}; };
@@ -107,14 +97,14 @@ export const FirstPath = () => {
return ( return (
<dialog ref={modalRef} className="modal"> <dialog ref={modalRef} className="modal">
<div className="modal-box w-11/12 max-w-5xl"> <div className="modal-box w-11/12 max-w-5xl">
<h3 className="flex items-center gap-2 text-lg font-bold mb-10"> <h3 className="mb-10 flex items-center gap-2 text-lg font-bold">
{session?.user.migratedFromV1 {session?.user.migratedFromV1
? "Hallo, hier hat sich einiges geändert!" ? "Hallo, hier hat sich einiges geändert!"
: "Wähle deinen Einstieg!"} : "Wähle deinen Einstieg!"}
</h3> </h3>
<h2 className="text-2xl font-bold mb-4 text-center">Willkommen bei Virtual Air Rescue!</h2> <h2 className="mb-4 text-center text-2xl font-bold">Willkommen bei Virtual Air Rescue!</h2>
{session?.user.migratedFromV1 ? ( {session?.user.migratedFromV1 ? (
<p className="mb-8 text-base text-base-content/80 text-center"> <p className="text-base-content/80 mb-8 text-center text-base">
Dein Account wurde erfolgreich auf das neue System migriert. Herzlich Willkommen im Dein Account wurde erfolgreich auf das neue System migriert. Herzlich Willkommen im
neuen HUB! Um die Erfahrung für alle Nutzer zu steigern haben wir uns dazu entschlossen, neuen HUB! Um die Erfahrung für alle Nutzer zu steigern haben wir uns dazu entschlossen,
dass alle Nutzer einen Test absolvieren müssen:{" "} dass alle Nutzer einen Test absolvieren müssen:{" "}
@@ -129,12 +119,12 @@ export const FirstPath = () => {
ausprobieren, wenn du möchtest. ausprobieren, wenn du möchtest.
</p> </p>
)} )}
<div className="flex flex-col items-center justify-center m-20"> <div className="m-20 flex flex-col items-center justify-center">
{page === "path" && <PathsOptions selected={selected} setSelected={setSelected} />} {page === "path" && <PathsOptions selected={selected} setSelected={setSelected} />}
{page === "event-select" && ( {page === "event-select" && (
<div className="flex flex-col gap-3 min-w-[800px]"> <div className="flex min-w-[800px] flex-col gap-3">
<div> <div>
<p className="text-left text-gray-400 text-sm">Wähle dein Einführungs-Event aus:</p> <p className="text-left text-sm text-gray-400">Wähle dein Einführungs-Event aus:</p>
</div> </div>
<EventSelect pathSelected={selected!} /> <EventSelect pathSelected={selected!} />
</div> </div>

View File

@@ -2,23 +2,17 @@ import Image from "next/image";
import { DiscordLogoIcon, InstagramLogoIcon, ReaderIcon } from "@radix-ui/react-icons"; import { DiscordLogoIcon, InstagramLogoIcon, ReaderIcon } from "@radix-ui/react-icons";
import YoutubeSvg from "./youtube_wider.svg"; import YoutubeSvg from "./youtube_wider.svg";
import FacebookSvg from "./facebook.svg"; import FacebookSvg from "./facebook.svg";
import { ChangelogModalBtn } from "@repo/shared-components";
import { getServerSession } from "api/auth/[...nextauth]/auth";
import { updateUser } from "(app)/settings/actions";
import toast from "react-hot-toast";
import { ChangelogWrapper } from "(app)/_components/ChangelogWrapper"; import { ChangelogWrapper } from "(app)/_components/ChangelogWrapper";
import { prisma } from "@repo/db"; import { prisma } from "@repo/db";
export const Footer = async () => { export const Footer = async () => {
const session = await getServerSession();
const latestChangelog = await prisma.changelog.findFirst({ const latestChangelog = await prisma.changelog.findFirst({
orderBy: { orderBy: {
createdAt: "desc", createdAt: "desc",
}, },
}); });
const autoOpen = !session?.user.changelogAck && !!latestChangelog;
return ( return (
<footer className="footer bg-base-200 mt-4 flex items-center justify-between rounded-lg p-4 shadow-md"> <footer className="footer bg-base-200 mt-4 flex items-center justify-between rounded-lg p-4 shadow-md">
{/* Left: Impressum & Datenschutz */} {/* Left: Impressum & Datenschutz */}

View File

@@ -0,0 +1,55 @@
import { prisma } from "@repo/db";
import { ParticipantForm } from "../../../_components/ParticipantForm";
import { Error } from "_components/Error";
import Link from "next/link";
import { PersonIcon } from "@radix-ui/react-icons";
import { ArrowLeft } from "lucide-react";
export default async function Page({
params,
}: {
params: Promise<{ id: string; participantId: string }>;
}) {
const { id: eventId, participantId } = await params;
console.log(eventId, participantId);
const event = await prisma.event.findUnique({
where: { id: parseInt(eventId) },
});
const participant = await prisma.participant.findUnique({
where: { id: parseInt(participantId) },
});
if (!participant) {
return <Error title="Teilnehmer nicht gefunden" statusCode={404} />;
}
const user = await prisma.user.findUnique({
where: { id: participant?.userId },
});
if (!event) return <Error title="Event nicht gefunden" statusCode={404} />;
if (!participant || !user) {
return <Error title="Teilnehmer nicht gefunden" statusCode={404} />;
}
return (
<div>
<div className="my-3">
<div className="text-left">
<Link href={`/admin/event/${event.id}`} className="link-hover l-0 text-gray-500">
<ArrowLeft className="mb-1 mr-1 inline h-4 w-4" />
Zurück zum Event
</Link>
</div>
<p className="text-left text-2xl font-semibold">
<PersonIcon className="mr-2 inline h-5 w-5" /> Event-Übersicht für{" "}
{`${user.firstname} ${user.lastname} #${user.publicId}`}
</p>
</div>
<ParticipantForm event={event} participant={participant} />
</div>
);
}

View File

@@ -1,203 +0,0 @@
import { Event, Participant, Prisma } from "@repo/db";
import { EventAppointmentOptionalDefaults, InputJsonValueType } from "@repo/db/zod";
import { ColumnDef } from "@tanstack/react-table";
import { useSession } from "next-auth/react";
import { RefObject, useRef } from "react";
import { UseFormReturn } from "react-hook-form";
import { PaginatedTable, PaginatedTableRef } from "../../../../_components/PaginatedTable";
import { Button } from "../../../../_components/ui/Button";
import { DateInput } from "../../../../_components/ui/DateInput";
import { upsertParticipant } from "../../../events/actions";
import { deleteAppoinement, upsertAppointment } from "../action";
import { handleParticipantFinished } from "../../../../../helper/events";
import toast from "react-hot-toast";
interface AppointmentModalProps {
event?: Event;
ref: RefObject<HTMLDialogElement | null>;
participantModal: RefObject<HTMLDialogElement | null>;
appointmentsTableRef: React.RefObject<PaginatedTableRef | null>;
appointmentForm: UseFormReturn<EventAppointmentOptionalDefaults>;
participantForm: UseFormReturn<Participant>;
}
export const AppointmentModal = ({
event,
ref,
participantModal,
appointmentsTableRef,
appointmentForm,
participantForm,
}: AppointmentModalProps) => {
const { data: session } = useSession();
const participantTableRef = useRef<PaginatedTableRef>(null);
return (
<dialog ref={ref} className="modal">
<div className="modal-box min-h-[500px] min-w-[900px]">
<form method="dialog">
{/* if there is a button in form, it will close the modal */}
<button
className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
onClick={() => ref.current?.close()}
>
</button>
</form>
<form
onSubmit={appointmentForm.handleSubmit(async (values) => {
if (!event) return;
await upsertAppointment(values);
ref.current?.close();
appointmentsTableRef.current?.refresh();
})}
className="flex flex-col"
>
<div className="mr-7 flex justify-between">
<h3 className="text-lg font-bold">Termin {appointmentForm.watch("id")}</h3>
<DateInput
value={new Date(appointmentForm.watch("appointmentDate") || Date.now())}
onChange={(date) => appointmentForm.setValue("appointmentDate", date)}
/>
</div>
<div>
<PaginatedTable
supressQuery={appointmentForm.watch("id") === undefined}
ref={participantTableRef}
columns={
[
{
accessorKey: "User.firstname",
header: "Vorname",
},
{
accessorKey: "User.lastname",
header: "Nachname",
},
{
accessorKey: "enscriptionDate",
header: "Einschreibedatum",
cell: ({ row }) => {
return <span>{new Date(row.original.enscriptionDate).toLocaleString()}</span>;
},
},
{
header: "Anwesend",
cell: ({ row }) => {
if (row.original.attended) {
return <span className="text-green-500">Ja</span>;
} else if (row.original.appointmentCancelled) {
return <span className="text-red-500">Nein (Termin abgesagt)</span>;
} else {
return <span>?</span>;
}
},
},
{
header: "Aktion",
cell: ({ row }) => {
return (
<div className="space-x-2">
<button
onClick={() => {
participantForm.reset(row.original);
participantModal.current?.showModal();
}}
className="btn btn-outline btn-sm"
>
anzeigen
</button>
{!row.original.attended && event?.hasPresenceEvents && (
<button
type="button"
onSubmit={() => {}}
onClick={async () => {
await upsertParticipant({
eventId: event!.id,
userId: row.original.userId,
attended: true,
appointmentCancelled: false,
});
if (!event.finisherMoodleCourseId?.length) {
toast(
"Teilnehmer hat das event abgeschlossen, workflow ausgeführt",
);
await handleParticipantFinished(row.original.id.toString());
}
participantTableRef.current?.refresh();
}}
className="btn btn-outline btn-info btn-sm"
>
Anwesend
</button>
)}
{!row.original.appointmentCancelled && event?.hasPresenceEvents && (
<button
type="button"
onSubmit={() => {}}
onClick={async () => {
await upsertParticipant({
eventId: event!.id,
userId: row.original.userId,
attended: false,
appointmentCancelled: true,
statusLog: [
...(row.original.statusLog as InputJsonValueType[]),
{
event: "Gefehlt an Event",
timestamp: new Date().toISOString(),
user: `${session?.user?.firstname} ${session?.user?.lastname} - ${session?.user?.publicId}`,
},
],
});
participantTableRef.current?.refresh();
}}
className="btn btn-outline btn-error btn-sm"
>
abwesend
</button>
)}
</div>
);
},
},
] as ColumnDef<Participant>[]
}
prismaModel={"participant"}
getFilter={() =>
({
eventAppointmentId: appointmentForm.watch("id")!,
}) as Prisma.ParticipantWhereInput
}
include={{ User: true }}
leftOfPagination={
<div className="flex gap-2">
<Button type="submit" className="btn btn-primary">
Speichern
</Button>
{appointmentForm.watch("id") && (
<Button
type="button"
onSubmit={() => {}}
onClick={async () => {
await deleteAppoinement(appointmentForm.watch("id")!);
ref.current?.close();
appointmentsTableRef.current?.refresh();
}}
className="btn btn-error btn-outline"
>
Löschen
</Button>
)}
</div>
}
/>
</div>
</form>
</div>
</dialog>
);
};

View File

@@ -1,70 +1,37 @@
"use client"; "use client";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { BADGES, Event, EVENT_TYPE, Participant, PERMISSION, Prisma, User } from "@repo/db"; import { BADGES, Event, EVENT_TYPE, Participant, PERMISSION, Prisma } from "@repo/db";
import { import { EventOptionalDefaults, EventOptionalDefaultsSchema } from "@repo/db/zod";
EventAppointmentOptionalDefaults, import { Bot, FileText, UserIcon } from "lucide-react";
EventAppointmentOptionalDefaultsSchema,
EventOptionalDefaults,
EventOptionalDefaultsSchema,
ParticipantSchema,
} from "@repo/db/zod";
import { Bot, Calendar, FileText, UserIcon } from "lucide-react";
import { useSession } from "next-auth/react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { useRef } from "react";
import "react-datepicker/dist/react-datepicker.css"; import "react-datepicker/dist/react-datepicker.css";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { PaginatedTable, PaginatedTableRef } from "../../../../_components/PaginatedTable";
import { Button } from "../../../../_components/ui/Button"; import { Button } from "../../../../_components/ui/Button";
import { Input } from "../../../../_components/ui/Input"; import { Input } from "../../../../_components/ui/Input";
import { MarkdownEditor } from "../../../../_components/ui/MDEditor"; import { MarkdownEditor } from "../../../../_components/ui/MDEditor";
import { Select } from "../../../../_components/ui/Select"; import { Select } from "../../../../_components/ui/Select";
import { Switch } from "../../../../_components/ui/Switch"; import { Switch } from "../../../../_components/ui/Switch";
import { deleteEvent, upsertEvent } from "../action"; import { deleteEvent, upsertEvent } from "../action";
import { AppointmentModal } from "./AppointmentModal";
import { ParticipantModal } from "./ParticipantModal";
import { ColumnDef } from "@tanstack/react-table";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { PaginatedTable } from "_components/PaginatedTable";
import Link from "next/link"; import Link from "next/link";
import { ColumnDef } from "@tanstack/react-table";
import { User } from "next-auth";
export const Form = ({ event }: { event?: Event }) => { export const Form = ({ event }: { event?: Event }) => {
const { data: session } = useSession();
const form = useForm<EventOptionalDefaults>({ const form = useForm<EventOptionalDefaults>({
resolver: zodResolver(EventOptionalDefaultsSchema), resolver: zodResolver(EventOptionalDefaultsSchema),
defaultValues: event defaultValues: event
? { ? {
...event, ...event,
discordRoleId: event.discordRoleId ?? undefined, discordRoleId: event.discordRoleId ?? undefined,
maxParticipants: event.maxParticipants ?? undefined,
finisherMoodleCourseId: event.finisherMoodleCourseId ?? undefined, finisherMoodleCourseId: event.finisherMoodleCourseId ?? undefined,
} }
: undefined, : undefined,
}); });
const appointmentForm = useForm<EventAppointmentOptionalDefaults>({
resolver: zodResolver(EventAppointmentOptionalDefaultsSchema),
defaultValues: {
eventId: event?.id,
presenterId: session?.user?.id,
},
});
const participantForm = useForm<Participant>({
resolver: zodResolver(ParticipantSchema),
});
const appointmentsTableRef = useRef<PaginatedTableRef>(null);
const appointmentModal = useRef<HTMLDialogElement>(null);
const participantModal = useRef<HTMLDialogElement>(null);
return ( return (
<> <>
<AppointmentModal
participantModal={participantModal}
participantForm={participantForm}
appointmentForm={appointmentForm}
ref={appointmentModal}
appointmentsTableRef={appointmentsTableRef}
event={event}
/>
<ParticipantModal participantForm={participantForm} ref={participantModal} />
<form <form
onSubmit={form.handleSubmit(async (values) => { onSubmit={form.handleSubmit(async (values) => {
await upsertEvent(values, event?.id); await upsertEvent(values, event?.id);
@@ -139,117 +106,11 @@ export const Form = ({ event }: { event?: Event }) => {
label="Discord Rolle für eingeschriebene Teilnehmer" label="Discord Rolle für eingeschriebene Teilnehmer"
className="input-sm" className="input-sm"
/> />
<Input
form={form}
label="Maximale Teilnehmer (Nur für live Events)"
className="input-sm"
{...form.register("maxParticipants", {
valueAsNumber: true,
})}
/>
<Switch form={form} name="hasPresenceEvents" label="Hat Live Event" />
<div className="divider w-full" /> <div className="divider w-full" />
<Switch form={form} name="hidden" label="Event verstecken" /> <Switch form={form} name="hidden" label="Event verstecken" />
</div> </div>
</div> </div>
{form.watch("hasPresenceEvents") ? (
<div className="card bg-base-200 col-span-6 shadow-xl">
<div className="card-body">
<PaginatedTable
ref={appointmentsTableRef}
prismaModel={"eventAppointment"}
getFilter={() =>
({
eventId: event?.id,
}) as Prisma.EventAppointmentWhereInput
}
include={{
Presenter: true,
Participants: true,
}}
leftOfSearch={
<h2 className="card-title">
<Calendar className="h-5 w-5" /> Termine
</h2>
}
rightOfSearch={
event && (
<button
className="btn btn-primary btn-outline"
onClick={() => {
appointmentModal.current?.showModal();
appointmentForm.reset({
id: undefined,
eventId: event.id,
presenterId: session?.user?.id,
});
}}
>
Hinzufügen
</button>
)
}
columns={
[
{
header: "Datum",
accessorKey: "appointmentDate",
accessorFn: (date) => new Date(date.appointmentDate).toLocaleString(),
},
{
header: "Presenter",
accessorKey: "presenter",
cell: ({ row }) => (
<div className="flex items-center">
<span className="ml-2">
{row.original.Presenter.firstname} {row.original.Presenter.lastname}
</span>
</div>
),
},
{
header: "Teilnehmer",
accessorKey: "Participants",
cell: ({ row }) => (
<div className="flex items-center">
<UserIcon className="h-5 w-5" />
<span className="ml-2">{row.original.Participants.length}</span>
</div>
),
},
{
header: "Aktionen",
cell: ({ row }) => {
return (
<div className="flex gap-2">
<button
onSubmit={() => false}
type="button"
onClick={() => {
appointmentForm.reset(row.original);
appointmentModal.current?.showModal();
}}
className="btn btn-sm btn-outline"
>
Bearbeiten
</button>
</div>
);
},
},
] as ColumnDef<
EventAppointmentOptionalDefaults & {
Presenter: User;
Participants: Participant[];
}
>[]
}
/>
</div>
</div>
) : null}
{!form.watch("hasPresenceEvents") ? (
<div className="card bg-base-200 col-span-6 shadow-xl"> <div className="card bg-base-200 col-span-6 shadow-xl">
<div className="card-body"> <div className="card-body">
{ {
@@ -259,7 +120,6 @@ export const Form = ({ event }: { event?: Event }) => {
<UserIcon className="h-5 w-5" /> Teilnehmer <UserIcon className="h-5 w-5" /> Teilnehmer
</h2> </h2>
} }
ref={appointmentsTableRef}
prismaModel={"participant"} prismaModel={"participant"}
showSearch showSearch
getFilter={(searchTerm) => getFilter={(searchTerm) =>
@@ -334,19 +194,18 @@ export const Form = ({ event }: { event?: Event }) => {
header: "Aktionen", header: "Aktionen",
cell: ({ row }) => { cell: ({ row }) => {
return ( return (
<div className="flex gap-2"> <Link
href={`/admin/event/${event?.id}/participant/${row.original.id}`}
className="flex gap-2"
>
<button <button
onSubmit={() => false} onSubmit={() => false}
type="button" type="button"
onClick={() => {
participantForm.reset(row.original);
participantModal.current?.showModal();
}}
className="btn btn-sm btn-outline" className="btn btn-sm btn-outline"
> >
Bearbeiten Ansehen
</button> </button>
</div> </Link>
); );
}, },
}, },
@@ -356,7 +215,6 @@ export const Form = ({ event }: { event?: Event }) => {
} }
</div> </div>
</div> </div>
) : null}
<div className="card bg-base-200 col-span-6 shadow-xl"> <div className="card bg-base-200 col-span-6 shadow-xl">
<div className="card-body"> <div className="card-body">
<div className="flex w-full gap-4"> <div className="flex w-full gap-4">

View File

@@ -0,0 +1,213 @@
"use client";
import { Participant, Event, ParticipantLog, Prisma } from "@repo/db";
import { Users, Activity, Bug } from "lucide-react";
import toast from "react-hot-toast";
import { InputJsonValueType, ParticipantOptionalDefaultsSchema } from "@repo/db/zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Switch } from "_components/ui/Switch";
import { useState } from "react";
import { Button } from "_components/ui/Button";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { upsertParticipant } from "(app)/events/actions";
import { deleteParticipant } from "../action";
import { redirect } from "next/navigation";
interface ParticipantFormProps {
event: Event;
participant: Participant;
}
const checkEventCompleted = (participant: Participant, event: Event): boolean => {
return event.finisherMoodleCourseId ? participant.finisherMoodleCurseCompleted : false;
};
export const ParticipantForm = ({ event, participant }: ParticipantFormProps) => {
const [isLoading, setIsLoading] = useState(false);
const queryClient = useQueryClient();
const upsertParticipantMutation = useMutation({
mutationKey: ["upsertParticipant"],
mutationFn: async (newData: Prisma.ParticipantUncheckedCreateInput) => {
const data = await upsertParticipant(newData);
await queryClient.invalidateQueries({ queryKey: ["participants", event.id] });
return data;
},
});
const deleteParticipantMutation = useMutation({
mutationKey: ["deleteParticipant"],
mutationFn: async (participantId: number) => {
await deleteParticipant(participantId);
await queryClient.invalidateQueries({ queryKey: ["participants", event.id] });
},
});
const eventCompleted = checkEventCompleted(participant, event);
const form = useForm({
resolver: zodResolver(ParticipantOptionalDefaultsSchema),
defaultValues: participant,
});
const handleEventFinished = async () => {
setIsLoading(true);
try {
await upsertParticipantMutation.mutateAsync({
eventId: event.id,
userId: participant.userId,
});
toast.success("Event als beendet markiert");
} finally {
setIsLoading(false);
}
};
const handleCheckMoodle = async () => {
setIsLoading(true);
try {
toast.success("Moodle-Check durchgeführt");
} finally {
setIsLoading(false);
}
};
return (
<form
onSubmit={form.handleSubmit(async (formData) => {
const data = await upsertParticipantMutation.mutateAsync({
...formData,
statusLog: participant.statusLog as unknown as Prisma.ParticipantCreatestatusLogInput,
eventId: event.id,
userId: participant.userId,
});
form.reset(data);
toast.success("Teilnehmer aktualisiert");
})}
className="bg-base-100 flex flex-wrap gap-6 p-6"
>
{/* Status Section */}
<div className="card bg-base-200 shadow">
<div className="card-body">
<h3 className="card-title text-sm">
<Bug className="mr-2 inline h-5 w-5" />
Debug
</h3>
<Switch form={form} name={"attended"} label="Anwesend" />
<Switch form={form} name={"appointmentCancelled"} label="Termin Abgesagt" />
<Switch
form={form}
name={"finisherMoodleCurseCompleted"}
label="Moodle Kurs abgeschlossen"
/>
<div></div>
</div>
</div>
{/* Info Card */}
<div className="card bg-base-200 min-w-[200px] flex-1 shadow-lg">
<div className="card-body">
<h2 className="card-title text-lg">
<Users className="h-5 w-5" /> Informationen
</h2>
<div className="divider" />
<div>
<p className="text-sm text-gray-600">Kurs-status</p>
<p className="font-semibold">{eventCompleted ? "Abgeschlossen" : "In Bearbeitung"}</p>
<p className="text-sm text-gray-600">Einschreibedatum</p>
<p className="font-semibold">
{participant?.enscriptionDate
? new Date(participant.enscriptionDate).toLocaleDateString()
: "-"}
</p>
</div>
<div className="divider" />
<div className="flex flex-col gap-2">
<Button
onClick={handleEventFinished}
isLoading={isLoading}
className="btn btn-sm btn-success"
>
Event beendet
</Button>
<Button
onClick={handleCheckMoodle}
isLoading={isLoading}
className="btn btn-sm btn-info"
>
Moodle-Check
</Button>
</div>
</div>
</div>
{/* Activity Log */}
<div className="card bg-base-200 min-w-[300px] flex-1 shadow-lg">
<div className="card-body">
<h2 className="card-title text-lg">
<Activity className="h-5 w-5" /> Aktivitätslog
</h2>
<div className="timeline timeline-vertical">
<table className="table-sm table w-full">
<thead>
<tr>
<th>Datum</th>
<th>Event</th>
<th>User</th>
</tr>
</thead>
<tbody>
{participant?.statusLog &&
Array.isArray(participant.statusLog) &&
(participant.statusLog as InputJsonValueType[])
.slice()
.reverse()
.map((log: InputJsonValueType, index: number) => {
const logEntry = log as unknown as ParticipantLog;
return (
<tr key={index}>
<td>
{logEntry.timestamp
? new Date(logEntry.timestamp).toLocaleDateString()
: "-"}
</td>
<td>{logEntry.event || "-"}</td>
<td>{logEntry.user || "-"}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
<div className="card bg-base-200 w-full shadow-xl">
<div className="card-body">
<div className="flex w-full gap-4">
<Button
disabled={!form.formState.isDirty}
isLoading={form.formState.isSubmitting}
type="submit"
className="btn btn-primary flex-1"
>
Speichern
</Button>
{event && (
<Button
onClick={async () => {
await deleteParticipantMutation.mutateAsync(participant.id);
redirect(`/admin/event/${event.id}`);
}}
className="btn btn-error"
>
Austragen
</Button>
)}
</div>
</div>
</div>
</form>
);
};

View File

@@ -2,6 +2,7 @@
import { prisma, Prisma, Event, Participant } from "@repo/db"; import { prisma, Prisma, Event, Participant } from "@repo/db";
//############# Event //#############
export const upsertEvent = async (event: Prisma.EventCreateInput, id?: Event["id"]) => { export const upsertEvent = async (event: Prisma.EventCreateInput, id?: Event["id"]) => {
const newEvent = id const newEvent = id
? await prisma.event.update({ ? await prisma.event.update({
@@ -11,34 +12,22 @@ export const upsertEvent = async (event: Prisma.EventCreateInput, id?: Event["id
: await prisma.event.create({ data: event }); : await prisma.event.create({ data: event });
return newEvent; return newEvent;
}; };
export const deleteEvent = async (id: Event["id"]) => { export const deleteEvent = async (id: Event["id"]) => {
await prisma.event.delete({ where: { id: id } }); await prisma.event.delete({ where: { id: id } });
}; };
export const upsertAppointment = async ( //############# Participant //#############
eventAppointment: Prisma.EventAppointmentUncheckedCreateInput,
) => { export const upsertParticipant = async (participant: Prisma.ParticipantUncheckedCreateInput) => {
const newEventAppointment = eventAppointment.id const newParticipant = participant.id
? await prisma.eventAppointment.update({ ? await prisma.participant.update({
where: { id: eventAppointment.id }, where: { id: participant.id },
data: eventAppointment, data: participant,
}) })
: await prisma.eventAppointment.create({ data: eventAppointment }); : await prisma.participant.create({ data: participant });
return newEventAppointment; return newParticipant;
}; };
export const deleteAppoinement = async (id: Event["id"]) => {
await prisma.eventAppointment.delete({ where: { id: id } });
prisma.eventAppointment.findMany({
where: {
eventId: id,
},
orderBy: {
// TODO: add order by in relation to table selected column
},
});
};
export const deleteParticipant = async (id: Participant["id"]) => { export const deleteParticipant = async (id: Participant["id"]) => {
await prisma.participant.delete({ where: { id: id } }); await prisma.participant.delete({ where: { id: id } });
}; };

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { DrawingPinFilledIcon } from "@radix-ui/react-icons"; import { DrawingPinFilledIcon } from "@radix-ui/react-icons";
import { Event, Participant, EventAppointment, User } from "@repo/db"; import { Event, Participant, User } from "@repo/db";
import ModalBtn from "./Modal"; import ModalBtn from "./Modal";
import MDEditor from "@uiw/react-md-editor"; import MDEditor from "@uiw/react-md-editor";
import { Badge } from "@repo/shared-components"; import { Badge } from "@repo/shared-components";
@@ -8,25 +8,18 @@ import { Badge } from "@repo/shared-components";
export const EventCard = ({ export const EventCard = ({
user, user,
event, event,
selectedAppointments,
appointments,
}: { }: {
user: User; user: User;
event: Event & { event: Event & {
Appointments: EventAppointment[];
Participants: Participant[]; Participants: Participant[];
}; };
selectedAppointments: EventAppointment[];
appointments: (EventAppointment & {
Participants: { userId: string }[];
})[];
}) => { }) => {
return ( return (
<div className="col-span-full"> <div className="col-span-full">
<div className="card bg-base-200 shadow-xl mb-4"> <div className="card bg-base-200 mb-4 shadow-xl">
<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 right-0 top-0 m-4">
{event.type === "COURSE" && ( {event.type === "COURSE" && (
<span className="badge badge-info badge-outline">Zusatzqualifikation</span> <span className="badge badge-info badge-outline">Zusatzqualifikation</span>
)} )}
@@ -36,7 +29,7 @@ export const EventCard = ({
</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">
<div className="text-left text-balance" data-color-mode="dark"> <div className="text-balance text-left" data-color-mode="dark">
<MDEditor.Markdown <MDEditor.Markdown
source={event.descriptionShort} source={event.descriptionShort}
style={{ style={{
@@ -45,21 +38,21 @@ export const EventCard = ({
/> />
</div> </div>
</div> </div>
<div className="flex col-span-2 justify-end"> <div className="col-span-2 flex justify-end">
{event.finishedBadges.map((b) => { {event.finishedBadges.map((b) => {
return <Badge badge={b} key={b} />; return <Badge badge={b} key={b} />;
})} })}
</div> </div>
</div> </div>
<div className="card-actions flex justify-between items-center mt-5"> <div className="card-actions mt-5 flex items-center justify-between">
<div> <div>
<p className="text-gray-600 text-left flex items-center gap-2"> <p className="flex items-center gap-2 text-left text-gray-600">
<DrawingPinFilledIcon /> <b>Teilnahmevoraussetzungen: </b> <DrawingPinFilledIcon /> <b>Teilnahmevoraussetzungen: </b>
{!event.requiredBadges.length && "Keine"} {!event.requiredBadges.length && "Keine"}
</p> </p>
{!!event.requiredBadges.length && ( {!!event.requiredBadges.length && (
<div className="flex ml-6"> <div className="ml-6 flex">
<b className="text-gray-600 text-left mr-2">Abzeichen:</b> <b className="mr-2 text-left text-gray-600">Abzeichen:</b>
<div className="flex gap-2"> <div className="flex gap-2">
{event.requiredBadges.map((badge) => ( {event.requiredBadges.map((badge) => (
<div className="badge badge-secondary badge-outline" key={badge}> <div className="badge badge-secondary badge-outline" key={badge}>
@@ -71,11 +64,9 @@ export const EventCard = ({
)} )}
</div> </div>
<ModalBtn <ModalBtn
selectedAppointments={selectedAppointments}
user={user} user={user}
event={event} event={event}
title={event.name} title={event.name}
dates={appointments}
participant={event.Participants[0]} participant={event.Participants[0]}
modalId={`${event.name}_modal.${event.id}`} modalId={`${event.name}_modal.${event.id}`}
/> />

View File

@@ -1,56 +1,30 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect } from "react";
import { CheckCircledIcon, EnterIcon, DrawingPinFilledIcon } from "@radix-ui/react-icons"; import { CheckCircledIcon, EnterIcon, DrawingPinFilledIcon } from "@radix-ui/react-icons";
import { Event, EventAppointment, Participant, User } from "@repo/db"; import { Event, Participant, User } from "@repo/db";
import { cn } from "@repo/shared-components"; import { cn } from "@repo/shared-components";
import { inscribeToMoodleCourse, upsertParticipant } from "../actions"; import { inscribeToMoodleCourse, upsertParticipant } from "../actions";
import { import {
BookCheck, BookCheck,
Calendar,
Check, Check,
CirclePlay, CirclePlay,
Clock10Icon,
ExternalLink, ExternalLink,
EyeIcon, EyeIcon,
Info, Info,
TriangleAlert, TriangleAlert,
} from "lucide-react"; } from "lucide-react";
import { useForm } from "react-hook-form";
import {
InputJsonValueType,
ParticipantOptionalDefaults,
ParticipantOptionalDefaultsSchema,
} from "@repo/db/zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { Select } from "../../../_components/ui/Select";
import { useRouter } from "next/navigation";
import { handleParticipantEnrolled } from "../../../../helper/events";
import { eventCompleted } from "@repo/shared-components"; import { eventCompleted } from "@repo/shared-components";
import MDEditor from "@uiw/react-md-editor"; import MDEditor from "@uiw/react-md-editor";
import toast from "react-hot-toast";
import { formatDate } from "date-fns";
interface ModalBtnProps { interface ModalBtnProps {
title: string; title: string;
event: Event; event: Event;
dates: (EventAppointment & {
Participants: { userId: string }[];
})[];
selectedAppointments: EventAppointment[];
participant?: Participant; participant?: Participant;
user: User; user: User;
modalId: string; modalId: string;
} }
const ModalBtn = ({ const ModalBtn = ({ title, modalId, participant, event, user }: ModalBtnProps) => {
title,
dates,
modalId,
participant,
selectedAppointments,
event,
user,
}: ModalBtnProps) => {
useEffect(() => { useEffect(() => {
const modal = document.getElementById(modalId) as HTMLDialogElement; const modal = document.getElementById(modalId) as HTMLDialogElement;
const handleOpen = () => { const handleOpen = () => {
@@ -66,12 +40,6 @@ const ModalBtn = ({
modal?.removeEventListener("close", handleClose); modal?.removeEventListener("close", handleClose);
}; };
}, [modalId]); }, [modalId]);
const router = useRouter();
const canSelectDate =
event.hasPresenceEvents &&
!participant?.attended &&
(selectedAppointments.length === 0 || participant?.appointmentCancelled);
const openModal = () => { const openModal = () => {
const modal = document.getElementById(modalId) as HTMLDialogElement; const modal = document.getElementById(modalId) as HTMLDialogElement;
@@ -82,29 +50,6 @@ const ModalBtn = ({
const modal = document.getElementById(modalId) as HTMLDialogElement; const modal = document.getElementById(modalId) as HTMLDialogElement;
modal?.close(); modal?.close();
}; };
const selectAppointmentForm = useForm<ParticipantOptionalDefaults>({
resolver: zodResolver(ParticipantOptionalDefaultsSchema),
defaultValues: {
eventId: event.id,
userId: user.id,
...participant,
},
});
const selectedAppointment = selectedAppointments[0];
const selectedDate = dates.find(
(date) =>
date.id === selectAppointmentForm.watch("eventAppointmentId") || selectedAppointment?.id,
);
const ownIndexInParticipantList = selectedDate?.Participants?.findIndex(
(p) => p.userId === user.id,
);
const ownPlaceInParticipantList =
typeof ownIndexInParticipantList === "number"
? ownIndexInParticipantList === -1
? (selectedDate?.Participants?.length ?? 0) + 1
: ownIndexInParticipantList + 1
: undefined;
const missingRequirements = const missingRequirements =
event.requiredBadges?.length > 0 && event.requiredBadges?.length > 0 &&
@@ -163,79 +108,6 @@ const ModalBtn = ({
/> />
</div> </div>
</div> </div>
{event.hasPresenceEvents && (
<div className="bg-base-300 flex min-w-[300px] flex-1 flex-col gap-2 rounded-lg p-3 shadow">
<h2 className="flex gap-2 text-lg font-bold">
<Calendar /> Termine
</h2>
<div className="flex flex-1 flex-col items-center justify-center">
{!!dates.length && !selectedDate && (
<>
<p className="text-info text-center">Melde dich zu einem Termin an</p>
<Select
form={selectAppointmentForm}
options={dates.map((date) => ({
label: `${formatDate(date.appointmentDate, "dd.MM.yyyy HH:mm")} - (${date.Participants.length}/${event.maxParticipants})`,
value: date.id,
}))}
name="eventAppointmentId"
label={""}
placeholder="Wähle einen Termin"
className="min-w-[250px]"
/>
</>
)}
{selectedAppointment && !participant?.appointmentCancelled && (
<div className="flex flex-col items-center justify-center gap-2">
<span>Dein ausgewählter Termin (Deutsche Zeit)</span>
<div>
<button
className="input input-border pointer-events-none min-w-[250px]"
style={{ anchorName: "--rdp" } as React.CSSProperties}
>
{new Date(selectedAppointment.appointmentDate).toLocaleString("de-DE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
</button>
</div>
{participant?.attended ? (
<p className="flex items-center justify-center gap-2 py-4">
<CheckCircledIcon className="text-success" />
Du hast an dem Presenztermin teilgenommen
</p>
) : (
<p className="flex items-center justify-center gap-2 py-4">
Bitte erscheine ~5 minuten vor dem Termin im Discord
</p>
)}
</div>
)}
{!dates.length && (
<p className="text-error text-center">Aktuell sind keine Termine verfügbar</p>
)}
{!!selectedDate &&
!!event.maxParticipants &&
!!ownPlaceInParticipantList &&
!!(ownPlaceInParticipantList > event.maxParticipants) && (
<p
role="alert"
className="alert alert-error alert-outline my-5 flex items-center justify-center gap-2 border py-4"
>
<TriangleAlert className="h-6 w-6 shrink-0 stroke-current" fill="none" />
Dieser Termin ist ausgebucht, wahrscheinlich wirst du nicht teilnehmen
können. (Listenplatz: {ownPlaceInParticipantList} , max.{" "}
{event.maxParticipants})
</p>
)}
</div>
</div>
)}
{event.finisherMoodleCourseId && ( {event.finisherMoodleCourseId && (
<div className="bg-base-300 flex min-w-[300px] flex-1 flex-col gap-2 rounded-lg p-3 shadow"> <div className="bg-base-300 flex min-w-[300px] flex-1 flex-col gap-2 rounded-lg p-3 shadow">
<h2 className="flex gap-2 text-lg font-bold"> <h2 className="flex gap-2 text-lg font-bold">
@@ -261,64 +133,6 @@ const ModalBtn = ({
{!event.requiredBadges.length && "Keine"} {!event.requiredBadges.length && "Keine"}
</p> </p>
</div> </div>
<div className="modal-action">
{!!canSelectDate && (
<button
className={cn(
"btn btn-info btn-outline btn-wide",
event.type === "COURSE" && "btn-secondary",
)}
onClick={async () => {
const data = selectAppointmentForm.getValues();
if (!data.eventAppointmentId) return;
const participant = await upsertParticipant({
...data,
enscriptionDate: new Date(),
statusLog: data.statusLog?.filter((log) => log !== null),
appointmentCancelled: false,
});
await handleParticipantEnrolled(participant.id.toString());
router.refresh();
closeModal();
}}
disabled={!selectAppointmentForm.watch("eventAppointmentId")}
>
<EnterIcon /> Anmelden
</button>
)}
{selectedAppointment &&
!participant?.appointmentCancelled &&
!participant?.attended && (
<button
onClick={async () => {
await upsertParticipant({
eventId: event.id,
userId: participant!.userId,
appointmentCancelled: true,
statusLog: [
...(participant?.statusLog as unknown as InputJsonValueType[]),
{
data: {
appointmentId: selectedAppointment.id,
appointmentDate: selectedAppointment.appointmentDate,
},
user: `${user?.firstname} ${user?.lastname} - ${user?.publicId}`,
event: "Termin abgesagt",
timestamp: new Date(),
},
],
});
toast.success("Termin abgesagt");
router.refresh();
}}
className="btn btn-error btn-outline btn-wide"
>
Termin Absagen
</button>
)}
</div>
</div> </div>
</div> </div>
<button className="modal-backdrop" onClick={closeModal}> <button className="modal-backdrop" onClick={closeModal}>
@@ -335,7 +149,6 @@ const MoodleCourseIndicator = ({
completed, completed,
moodleCourseId, moodleCourseId,
event, event,
participant,
user, user,
}: { }: {
user: User; user: User;
@@ -345,13 +158,6 @@ 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 (event.hasPresenceEvents && !participant?.attended)
return (
<p className="flex items-center justify-center gap-2 py-4">
<Clock10Icon className="text-error" />
Abschlusstest erst nach Teilnahme verfügbar
</p>
);
if (completed) if (completed)
return ( return (
<p className="flex items-center justify-center gap-2 py-4"> <p className="flex items-center justify-center gap-2 py-4">

View File

@@ -14,13 +14,6 @@ const page = async () => {
hidden: false, hidden: false,
}, },
include: { include: {
Appointments: {
where: {
appointmentDate: {
gte: new Date(),
},
},
},
Participants: { Participants: {
where: { where: {
userId: user.id, userId: user.id,
@@ -32,42 +25,6 @@ const page = async () => {
id: "desc", id: "desc",
}, },
}); });
const appointments = await prisma.eventAppointment.findMany({
where: {
appointmentDate: {
gte: new Date(),
},
},
include: {
Participants: {
select: {
enscriptionDate: true,
id: true,
userId: true,
},
where: {
appointmentCancelled: false,
},
orderBy: {
enscriptionDate: "asc",
},
},
_count: {
select: {
Participants: true,
},
},
},
});
const userAppointments = await prisma.eventAppointment.findMany({
where: {
Participants: {
some: {
userId: user.id,
},
},
},
});
return ( return (
<div className="grid grid-cols-6 gap-4"> <div className="grid grid-cols-6 gap-4">
@@ -78,15 +35,7 @@ const page = async () => {
</div> </div>
{events.map((event) => { {events.map((event) => {
return ( return <EventCard user={user} event={event} key={event.id} />;
<EventCard
appointments={appointments}
selectedAppointments={userAppointments}
user={user}
event={event}
key={event.id}
/>
);
})} })}
</div> </div>
); );

View File

@@ -24,15 +24,6 @@ export async function GET(request: Request): Promise<NextResponse> {
userId: session.user.id, userId: session.user.id,
}, },
}, },
Appointments: {
include: {
Participants: {
where: {
appointmentCancelled: false,
},
},
},
},
}, },
}); });

View File

@@ -1,13 +1,9 @@
import { Event, EventAppointment, Participant, Prisma } from "@repo/db"; import { Event, Participant, Prisma } from "@repo/db";
import axios from "axios"; import axios from "axios";
export const getEvents = async (filter: Prisma.EventWhereInput) => { export const getEvents = async (filter: Prisma.EventWhereInput) => {
const { data } = await axios.get< const { data } = await axios.get<
(Event & { (Event & {
Appointments: (EventAppointment & {
Appointments: EventAppointment[];
Participants: Participant[];
})[];
Participants: Participant[]; Participants: Participant[];
})[] })[]
>(`/api/event`, { >(`/api/event`, {

View File

@@ -21,7 +21,7 @@
"node": ">=18", "node": ">=18",
"pnpm": ">=10" "pnpm": ">=10"
}, },
"packageManager": "pnpm@10.28.0", "packageManager": "pnpm@10.28.2",
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
"packages/*" "packages/*"

View File

@@ -25,6 +25,7 @@
"zod-prisma-types": "^3.2.4" "zod-prisma-types": "^3.2.4"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.1.0",
"prisma": "^6.12.0" "prisma": "^6.12.0"
} }
} }

View File

@@ -5,25 +5,10 @@ enum EVENT_TYPE {
EVENT EVENT
} }
model EventAppointment {
id Int @id @default(autoincrement())
eventId Int
appointmentDate DateTime
presenterId String
// relations:
Users User[] @relation("EventAppointmentUser")
Participants Participant[]
Event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
Presenter User @relation(fields: [presenterId], references: [id])
}
model Participant { model Participant {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId String @map(name: "user_id") userId String @map(name: "user_id")
finisherMoodleCurseCompleted Boolean @default(false) finisherMoodleCurseCompleted Boolean @default(false)
attended Boolean @default(false)
appointmentCancelled Boolean @default(false)
eventAppointmentId Int?
enscriptionDate DateTime @default(now()) enscriptionDate DateTime @default(now())
statusLog Json[] @default([]) statusLog Json[] @default([])
@@ -31,7 +16,6 @@ model Participant {
// relations: // relations:
User User @relation(fields: [userId], references: [id], onDelete: Cascade) User User @relation(fields: [userId], references: [id], onDelete: Cascade)
Event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) Event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
EventAppointment EventAppointment? @relation(fields: [eventAppointmentId], references: [id])
} }
model Event { model Event {
@@ -41,8 +25,6 @@ model Event {
description String description String
type EVENT_TYPE @default(EVENT) type EVENT_TYPE @default(EVENT)
discordRoleId String? @default("") discordRoleId String? @default("")
hasPresenceEvents Boolean @default(false)
maxParticipants Int? @default(0)
finisherMoodleCourseId String? @default("") finisherMoodleCourseId String? @default("")
finishedBadges BADGES[] @default([]) finishedBadges BADGES[] @default([])
requiredBadges BADGES[] @default([]) requiredBadges BADGES[] @default([])
@@ -51,7 +33,6 @@ model Event {
// relations: // relations:
Participants Participant[] Participants Participant[]
Appointments EventAppointment[]
} }
model File { model File {

View File

@@ -0,0 +1,35 @@
/*
Warnings:
- You are about to drop the column `appointmentCancelled` on the `Participant` table. All the data in the column will be lost.
- You are about to drop the column `attended` on the `Participant` table. All the data in the column will be lost.
- You are about to drop the column `eventAppointmentId` on the `Participant` table. All the data in the column will be lost.
- You are about to drop the `EventAppointment` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `_EventAppointmentUser` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "EventAppointment" DROP CONSTRAINT "EventAppointment_eventId_fkey";
-- DropForeignKey
ALTER TABLE "EventAppointment" DROP CONSTRAINT "EventAppointment_presenterId_fkey";
-- DropForeignKey
ALTER TABLE "Participant" DROP CONSTRAINT "Participant_eventAppointmentId_fkey";
-- DropForeignKey
ALTER TABLE "_EventAppointmentUser" DROP CONSTRAINT "_EventAppointmentUser_A_fkey";
-- DropForeignKey
ALTER TABLE "_EventAppointmentUser" DROP CONSTRAINT "_EventAppointmentUser_B_fkey";
-- AlterTable
ALTER TABLE "Participant" DROP COLUMN "appointmentCancelled",
DROP COLUMN "attended",
DROP COLUMN "eventAppointmentId";
-- DropTable
DROP TABLE "EventAppointment";
-- DropTable
DROP TABLE "_EventAppointmentUser";

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `hasPresenceEvents` on the `Event` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Event" DROP COLUMN "hasPresenceEvents";

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `maxParticipants` on the `Event` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Event" DROP COLUMN "maxParticipants";

View File

@@ -69,8 +69,6 @@ model User {
// relations: // relations:
oauthTokens OAuthToken[] oauthTokens OAuthToken[]
participants Participant[] participants Participant[]
EventAppointmentUser EventAppointment[] @relation("EventAppointmentUser")
EventAppointment EventAppointment[]
SentMessages ChatMessage[] @relation("SentMessages") SentMessages ChatMessage[] @relation("SentMessages")
ReceivedMessages ChatMessage[] @relation("ReceivedMessages") ReceivedMessages ChatMessage[] @relation("ReceivedMessages")
SentReports Report[] @relation("SentReports") SentReports Report[] @relation("SentReports")

View File

@@ -3,6 +3,5 @@ import { Event, Participant } from "@repo/db";
export const eventCompleted = (event: Event, participant?: Participant) => { export const eventCompleted = (event: Event, participant?: Participant) => {
if (!participant) return false; if (!participant) return false;
if (event.finisherMoodleCourseId && !participant.finisherMoodleCurseCompleted) return false; if (event.finisherMoodleCourseId && !participant.finisherMoodleCurseCompleted) return false;
if (event.hasPresenceEvents && !participant.attended) return false;
return true; return true;
}; };

94
pnpm-lock.yaml generated
View File

@@ -130,7 +130,7 @@ importers:
version: 0.5.8(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.15.3(@types/dom-mediacapture-record@1.0.22)) version: 0.5.8(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.15.3(@types/dom-mediacapture-record@1.0.22))
'@next-auth/prisma-adapter': '@next-auth/prisma-adapter':
specifier: ^1.0.7 specifier: ^1.0.7
version: 1.0.7(@prisma/client@6.12.0(prisma@6.12.0(typescript@5.8.3))(typescript@5.8.3))(next-auth@4.24.13(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(nodemailer@7.0.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) version: 1.0.7(@prisma/client@6.12.0(prisma@6.12.0(typescript@5.8.3))(typescript@5.8.3))(next-auth@4.24.13(next@16.1.1(@babel/core@7.27.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(nodemailer@7.0.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))
'@radix-ui/react-icons': '@radix-ui/react-icons':
specifier: ^1.3.2 specifier: ^1.3.2
version: 1.3.2(react@19.1.0) version: 1.3.2(react@19.1.0)
@@ -211,10 +211,10 @@ importers:
version: 0.525.0(react@19.1.0) version: 0.525.0(react@19.1.0)
next: next:
specifier: '>=15.4.9' specifier: '>=15.4.9'
version: 16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) version: 16.1.1(@babel/core@7.27.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next-auth: next-auth:
specifier: '>=4.24.12' specifier: '>=4.24.12'
version: 4.24.13(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(nodemailer@7.0.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) version: 4.24.13(next@16.1.1(@babel/core@7.27.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(nodemailer@7.0.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
npm: npm:
specifier: ^11.4.2 specifier: ^11.4.2
version: 11.4.2 version: 11.4.2
@@ -588,6 +588,9 @@ importers:
specifier: ^3.2.4 specifier: ^3.2.4
version: 3.2.4(@prisma/client@6.12.0(prisma@6.12.0(typescript@5.9.3))(typescript@5.9.3))(prisma@6.12.0(typescript@5.9.3)) version: 3.2.4(@prisma/client@6.12.0(prisma@6.12.0(typescript@5.9.3))(typescript@5.9.3))(prisma@6.12.0(typescript@5.9.3))
devDependencies: devDependencies:
'@types/node':
specifier: ^25.1.0
version: 25.1.0
prisma: prisma:
specifier: ^6.12.0 specifier: ^6.12.0
version: 6.12.0(typescript@5.9.3) version: 6.12.0(typescript@5.9.3)
@@ -2161,6 +2164,9 @@ packages:
'@types/node@22.15.34': '@types/node@22.15.34':
resolution: {integrity: sha512-8Y6E5WUupYy1Dd0II32BsWAx5MWdcnRd8L84Oys3veg1YrYtNtzgO4CFhiBg6MDSjk7Ay36HYOnU7/tuOzIzcw==} resolution: {integrity: sha512-8Y6E5WUupYy1Dd0II32BsWAx5MWdcnRd8L84Oys3veg1YrYtNtzgO4CFhiBg6MDSjk7Ay36HYOnU7/tuOzIzcw==}
'@types/node@25.1.0':
resolution: {integrity: sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==}
'@types/nodemailer@6.4.17': '@types/nodemailer@6.4.17':
resolution: {integrity: sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==} resolution: {integrity: sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==}
@@ -5290,6 +5296,9 @@ packages:
undici-types@6.21.0: undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
undici@6.21.3: undici@6.21.3:
resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==} resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==}
engines: {node: '>=18.17'} engines: {node: '>=18.17'}
@@ -5603,7 +5612,7 @@ snapshots:
'@babel/parser': 7.27.7 '@babel/parser': 7.27.7
'@babel/template': 7.27.2 '@babel/template': 7.27.2
'@babel/types': 7.27.7 '@babel/types': 7.27.7
debug: 4.4.1 debug: 4.4.1(supports-color@5.5.0)
globals: 11.12.0 globals: 11.12.0
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -5852,7 +5861,7 @@ snapshots:
'@eslint/eslintrc@3.3.1': '@eslint/eslintrc@3.3.1':
dependencies: dependencies:
ajv: 6.12.6 ajv: 6.12.6
debug: 4.4.1 debug: 4.4.1(supports-color@5.5.0)
espree: 10.4.0 espree: 10.4.0
globals: 14.0.0 globals: 14.0.0
ignore: 5.3.2 ignore: 5.3.2
@@ -6091,11 +6100,6 @@ snapshots:
'@prisma/client': 6.12.0(prisma@6.12.0(typescript@5.8.3))(typescript@5.8.3) '@prisma/client': 6.12.0(prisma@6.12.0(typescript@5.8.3))(typescript@5.8.3)
next-auth: 4.24.13(next@16.1.1(@babel/core@7.27.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(nodemailer@7.0.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) next-auth: 4.24.13(next@16.1.1(@babel/core@7.27.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(nodemailer@7.0.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@next-auth/prisma-adapter@1.0.7(@prisma/client@6.12.0(prisma@6.12.0(typescript@5.8.3))(typescript@5.8.3))(next-auth@4.24.13(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(nodemailer@7.0.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))':
dependencies:
'@prisma/client': 6.12.0(prisma@6.12.0(typescript@5.8.3))(typescript@5.8.3)
next-auth: 4.24.13(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(nodemailer@7.0.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@next/env@16.1.1': {} '@next/env@16.1.1': {}
'@next/eslint-plugin-next@15.4.2': '@next/eslint-plugin-next@15.4.2':
@@ -7678,9 +7682,13 @@ snapshots:
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
'@types/node@25.1.0':
dependencies:
undici-types: 7.16.0
'@types/nodemailer@6.4.17': '@types/nodemailer@6.4.17':
dependencies: dependencies:
'@types/node': 22.15.29 '@types/node': 22.15.34
'@types/parse-json@4.0.2': {} '@types/parse-json@4.0.2': {}
@@ -7749,7 +7757,7 @@ snapshots:
'@typescript-eslint/types': 8.37.0 '@typescript-eslint/types': 8.37.0
'@typescript-eslint/typescript-estree': 8.37.0(typescript@5.8.3) '@typescript-eslint/typescript-estree': 8.37.0(typescript@5.8.3)
'@typescript-eslint/visitor-keys': 8.37.0 '@typescript-eslint/visitor-keys': 8.37.0
debug: 4.4.1 debug: 4.4.1(supports-color@5.5.0)
eslint: 9.31.0(jiti@2.4.2) eslint: 9.31.0(jiti@2.4.2)
typescript: 5.8.3 typescript: 5.8.3
transitivePeerDependencies: transitivePeerDependencies:
@@ -7759,7 +7767,7 @@ snapshots:
dependencies: dependencies:
'@typescript-eslint/tsconfig-utils': 8.37.0(typescript@5.8.3) '@typescript-eslint/tsconfig-utils': 8.37.0(typescript@5.8.3)
'@typescript-eslint/types': 8.37.0 '@typescript-eslint/types': 8.37.0
debug: 4.4.1 debug: 4.4.1(supports-color@5.5.0)
typescript: 5.8.3 typescript: 5.8.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -7778,7 +7786,7 @@ snapshots:
'@typescript-eslint/types': 8.37.0 '@typescript-eslint/types': 8.37.0
'@typescript-eslint/typescript-estree': 8.37.0(typescript@5.8.3) '@typescript-eslint/typescript-estree': 8.37.0(typescript@5.8.3)
'@typescript-eslint/utils': 8.37.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3) '@typescript-eslint/utils': 8.37.0(eslint@9.31.0(jiti@2.4.2))(typescript@5.8.3)
debug: 4.4.1 debug: 4.4.1(supports-color@5.5.0)
eslint: 9.31.0(jiti@2.4.2) eslint: 9.31.0(jiti@2.4.2)
ts-api-utils: 2.1.0(typescript@5.8.3) ts-api-utils: 2.1.0(typescript@5.8.3)
typescript: 5.8.3 typescript: 5.8.3
@@ -7793,7 +7801,7 @@ snapshots:
'@typescript-eslint/tsconfig-utils': 8.37.0(typescript@5.8.3) '@typescript-eslint/tsconfig-utils': 8.37.0(typescript@5.8.3)
'@typescript-eslint/types': 8.37.0 '@typescript-eslint/types': 8.37.0
'@typescript-eslint/visitor-keys': 8.37.0 '@typescript-eslint/visitor-keys': 8.37.0
debug: 4.4.1 debug: 4.4.1(supports-color@5.5.0)
fast-glob: 3.3.3 fast-glob: 3.3.3
is-glob: 4.0.3 is-glob: 4.0.3
minimatch: 9.0.5 minimatch: 9.0.5
@@ -8424,10 +8432,6 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
debug@4.4.1:
dependencies:
ms: 2.1.3
debug@4.4.1(supports-color@5.5.0): debug@4.4.1(supports-color@5.5.0):
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
@@ -8770,7 +8774,7 @@ snapshots:
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.31.0(jiti@2.4.2)): eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.31.0(jiti@2.4.2)):
dependencies: dependencies:
'@nolyfill/is-core-module': 1.0.39 '@nolyfill/is-core-module': 1.0.39
debug: 4.4.1 debug: 4.4.1(supports-color@5.5.0)
eslint: 9.31.0(jiti@2.4.2) eslint: 9.31.0(jiti@2.4.2)
get-tsconfig: 4.10.1 get-tsconfig: 4.10.1
is-bun-module: 2.0.0 is-bun-module: 2.0.0
@@ -10223,23 +10227,6 @@ snapshots:
optionalDependencies: optionalDependencies:
nodemailer: 7.0.11 nodemailer: 7.0.11
next-auth@4.24.13(next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(nodemailer@7.0.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
'@babel/runtime': 7.27.6
'@panva/hkdf': 1.2.1
cookie: 0.7.2
jose: 4.15.9
next: 16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
oauth: 0.9.15
openid-client: 5.7.1
preact: 10.28.2
preact-render-to-string: 5.2.6(preact@10.28.2)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
uuid: 8.3.2
optionalDependencies:
nodemailer: 7.0.11
next-remove-imports@1.0.12(webpack@5.99.9): next-remove-imports@1.0.12(webpack@5.99.9):
dependencies: dependencies:
'@babel/core': 7.27.7 '@babel/core': 7.27.7
@@ -10275,32 +10262,6 @@ snapshots:
- '@babel/core' - '@babel/core'
- babel-plugin-macros - babel-plugin-macros
next@16.1.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
'@next/env': 16.1.1
'@swc/helpers': 0.5.15
baseline-browser-mapping: 2.9.14
caniuse-lite: 1.0.30001726
postcss: 8.4.31
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
styled-jsx: 5.1.6(react@19.1.0)
optionalDependencies:
'@next/swc-darwin-arm64': 16.1.1
'@next/swc-darwin-x64': 16.1.1
'@next/swc-linux-arm64-gnu': 16.1.1
'@next/swc-linux-arm64-musl': 16.1.1
'@next/swc-linux-x64-gnu': 16.1.1
'@next/swc-linux-x64-musl': 16.1.1
'@next/swc-win32-arm64-msvc': 16.1.1
'@next/swc-win32-x64-msvc': 16.1.1
'@opentelemetry/api': 1.9.0
'@playwright/test': 1.52.0
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
node-cron@4.2.1: {} node-cron@4.2.1: {}
node-releases@2.0.19: {} node-releases@2.0.19: {}
@@ -11238,11 +11199,6 @@ snapshots:
optionalDependencies: optionalDependencies:
'@babel/core': 7.27.7 '@babel/core': 7.27.7
styled-jsx@5.1.6(react@19.1.0):
dependencies:
client-only: 0.0.1
react: 19.1.0
stylis@4.2.0: {} stylis@4.2.0: {}
supports-color@5.5.0: supports-color@5.5.0:
@@ -11463,6 +11419,8 @@ snapshots:
undici-types@6.21.0: {} undici-types@6.21.0: {}
undici-types@7.16.0: {}
undici@6.21.3: {} undici@6.21.3: {}
unified@11.0.5: unified@11.0.5: