CHanged Event admin layout

This commit is contained in:
PxlLoewe
2025-03-10 18:11:28 -07:00
parent 77b266e0bf
commit c01e618a56
17 changed files with 918 additions and 170 deletions

View File

@@ -1,2 +1,6 @@
MOODLE_TOKEN=
MOODLE_URL=
MOODLE_URL=
MAIL_SERVER=
MAIL_USER=
MAIL_PASSWORD=
MAIL_PORT=

View File

@@ -2,6 +2,8 @@ import { getMoodleCourseCompletionStatus, getMoodleUserById } from "./moodle";
import { CronJob } from "cron";
import { prisma } from "@repo/db";
import { eventCompleted } from "@repo/ui/helper";
import { sendCourseCompletedEmail } from "modules/mail";
import { handleParticipantFinished } from "modules/event";
const syncMoodleIds = async () => {
try {
@@ -57,7 +59,7 @@ const updateParticipantMoodleResults = async () => {
);
if (quizzResult?.completionstatus?.completed === true) {
await prisma.participant.update({
return prisma.participant.update({
where: {
id: p.id,
},
@@ -65,7 +67,7 @@ const updateParticipantMoodleResults = async () => {
finisherMoodleCurseCompleted: true,
statusLog: {
push: {
event: "Finisher course completed",
event: "Moodle-Kurs abgeschlossen",
timestamp: new Date(),
user: "system",
},
@@ -77,33 +79,40 @@ const updateParticipantMoodleResults = async () => {
);
};
export const checkedFinishedParticipants = async () => {
export const checkFinishedParticipants = async () => {
console.log("Checking finished participants");
const participantsPending = await prisma.participant.findMany({
where: {
finished: false,
completetionWorkflowFinished: false,
},
include: {
Event: true,
User: true,
},
});
participantsPending.forEach(async (p) => {
if (!p.User) return;
if (!p.User.moodleId) return;
const completed = eventCompleted(p.Event, p);
if (!completed) return;
handleParticipantFinished(p.Event, p, p.User);
});
};
CronJob.from({ cronTime: "0 * * * *", onTick: syncMoodleIds, start: true });
CronJob.from({
cronTime: "*/5 * * * *",
cronTime: "*/1 * * * *",
onTick: async () => {
console.log("Updating participant moodle results");
await updateParticipantMoodleResults();
await checkFinishedParticipants();
},
start: true,
});
updateParticipantMoodleResults();
const debug = async () => {
await updateParticipantMoodleResults();
await checkFinishedParticipants();
};
debug();

View File

@@ -1,4 +1,5 @@
import { Event, Participant, prisma, User } from "@repo/db";
import { sendCourseCompletedEmail } from "modules/mail";
export const handleParticipantFinished = async (
event: Event,
@@ -11,9 +12,6 @@ export const handleParticipantFinished = async (
},
});
//TODO: Send Discord Message
//TODO: Send Email
await prisma.user.update({
where: {
id: user.id,
@@ -22,19 +20,24 @@ export const handleParticipantFinished = async (
badges: {
push: event.finishedBadges,
},
permissions: event.finishedPermissions,
permissions: {
push: event.finishedPermissions,
},
},
});
//TODO: Send Discord Message
await sendCourseCompletedEmail(user.email, user, event);
await prisma.participant.update({
where: {
id: participant.id,
},
data: {
finished: true,
completetionWorkflowFinished: true,
statusLog: {
push: {
event: "Event finished",
event: "Berechtigungen und Badges vergeben",
timestamp: new Date(),
user: "system",
},

View File

@@ -0,0 +1,21 @@
import * as React from "react";
import { Event, User } from "@repo/db";
import { Html, Button, render } from "@react-email/components";
const Template = ({ event, user }: { user: User; event: Event }) => (
<Html lang="en">
<p>You completed the Course {event.name}</p>
<p>Congratulation</p>
</Html>
);
export function renderCourseCompleted({
user,
event,
}: {
user: User;
event: Event;
}) {
return render(<Template event={event} user={user} />);
}

View File

@@ -0,0 +1,59 @@
import { Event, User } from "@repo/db";
import nodemailer from "nodemailer";
import { renderCourseCompleted } from "./mail-templates/CourseCompleted";
let transporter: nodemailer.Transporter | null = null;
const initTransporter = () => {
if (!process.env.MAIL_SERVER)
return console.error("MAIL_SERVER is not defined");
if (!process.env.MAIL_PORT) return console.error("MAIL_PORT is not defined");
if (!process.env.MAIL_USER) return console.error("MAIL_USER is not defined");
if (!process.env.MAIL_PASSWORD)
return console.error("MAIL_PASSWORD is not defined");
transporter = nodemailer.createTransport({
host: process.env.MAIL_SERVER,
port: parseInt(process.env.MAIL_PORT),
secure: true, // true for 465, false for other ports
auth: {
user: process.env.MAIL_USER,
pass: process.env.MAIL_PASSWORD,
},
});
transporter.on("error", (err) => {
console.error("Mail occurred:", err);
});
transporter.on("idle", () => {
console.log("Mail Idle");
});
};
initTransporter();
export const sendCourseCompletedEmail = async (
to: string,
user: User,
event: Event,
) => {
const emailHtml = await renderCourseCompleted({ user, event });
if (!transporter) {
console.error("Transporter is not initialized");
return;
}
transporter.sendMail(
{
from: process.env.MAIL_USER,
to,
subject: `Congratulations ${user.firstname} on completing ${event.name}`,
html: emailHtml,
},
(error, info) => {
if (error) {
console.error("Error sending email:", error);
} else {
}
},
);
};

View File

@@ -11,12 +11,16 @@
"@repo/db": "*",
"@repo/typescript-config": "*",
"@types/node": "^22.13.5",
"@types/nodemailer": "^6.4.17",
"concurrently": "^9.1.2",
"typescript": "latest"
},
"dependencies": {
"@react-email/components": "^0.0.33",
"axios": "^1.7.9",
"cron": "^4.1.0",
"dotenv": "^16.4.7"
"dotenv": "^16.4.7",
"nodemailer": "^6.10.0",
"react": "^19.0.0"
}
}

View File

@@ -1,10 +1,11 @@
{
"extends": "@repo/typescript-config/base.json",
"compilerOptions": {
"outDir": "dist",
"allowImportingTsExtensions": false,
"baseUrl": "."
},
"include": ["**/*.ts", "./index.ts"],
"exclude": ["node_modules", "dist"]
}
"extends": "@repo/typescript-config/base.json",
"compilerOptions": {
"outDir": "dist",
"allowImportingTsExtensions": false,
"baseUrl": ".",
"jsx": "react"
},
"include": ["**/*.ts", "./index.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -22,33 +22,37 @@ import { Switch } from "../../../../_components/ui/Switch";
interface AppointmentModalProps {
event?: Event;
ref: RefObject<HTMLDialogElement | null>;
participantModal: RefObject<HTMLDialogElement | null>;
appointmentsTableRef: React.RefObject<PaginatedTableRef>;
appointmentForm: UseFormReturn<
EventAppointmentOptionalDefaults,
any,
undefined
>;
participantForm: UseFormReturn<Participant, any, undefined>;
}
export const AppointmentModal = ({
event,
ref,
participantModal,
appointmentsTableRef,
appointmentForm,
participantForm,
}: AppointmentModalProps) => {
const { data: session } = useSession();
const participantTableRef = useRef<PaginatedTableRef>(null);
const participantForm = useForm<Participant>({
resolver: zodResolver(ParticipantOptionalDefaultsSchema),
});
return (
<dialog ref={ref} className="modal">
<div className="modal-box">
<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">
<button
className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
onClick={() => ref.current?.close()}
>
</button>
</form>
@@ -57,6 +61,7 @@ export const AppointmentModal = ({
</h3>
<form
onSubmit={appointmentForm.handleSubmit(async (values) => {
console.log(values);
if (!event) return;
const createdAppointment = await upsertAppointment(values);
ref.current?.close();
@@ -75,131 +80,83 @@ export const AppointmentModal = ({
}}
/> */}
<div>
{appointmentForm.watch("id") && (
<PaginatedTable
ref={participantTableRef}
columns={[
{
accessorKey: "User.firstname",
header: "Vorname",
},
{
accessorKey: "User.lastname",
header: "Nachname",
},
{
header: "Aktion",
cell: ({ row }: CellContext<Participant, any>) => {
return (
<>
<PaginatedTable
hide={appointmentForm.watch("id") === undefined}
ref={participantTableRef}
columns={[
{
accessorKey: "User.firstname",
header: "Vorname",
},
{
accessorKey: "User.lastname",
header: "Nachname",
},
{
header: "Aktion",
cell: ({ row }: CellContext<Participant, any>) => {
return (
<>
<button
onClick={() => {
participantForm.reset(row.original);
participantModal.current?.showModal();
}}
className="btn btn-outline btn-sm"
>
anzeigen
</button>
{!row.original.attended && event?.hasPresenceEvents && (
<button
onClick={() => {
participantForm.reset(row.original);
type="button"
onSubmit={() => {}}
onClick={async () => {
await upsertParticipant({
eventId: event!.id,
userId: participantForm.watch("userId"),
attended: true,
});
participantTableRef.current?.refresh();
}}
className="btn btn-outline btn-sm"
className="btn btn-outline btn-info btn-sm"
>
anzeigen
Anwesend
</button>
{!row.original.attended &&
event?.hasPresenceEvents && (
<button
type="button"
onSubmit={() => {}}
onClick={async () => {
await upsertParticipant({
eventId: event!.id,
userId: participantForm.watch("userId"),
attended: true,
});
participantTableRef.current?.refresh();
}}
className="btn btn-outline btn-info btn-sm"
>
Anwesend
</button>
)}
</>
);
},
)}
</>
);
},
]}
prismaModel={"participant"}
filter={{
eventAppointmentId: appointmentForm.watch("id"),
}}
include={{ User: true }}
leftOfPagination={
<div className="flex gap-2">
<Button type="submit" className="btn btn-primary">
Speichern
},
]}
prismaModel={"participant"}
filter={{
eventAppointmentId: appointmentForm.watch("id"),
}}
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>
{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>
}
/>
</div>
<div className="modal-action"></div>
</form>
{participantForm.watch("id") && (
<form
onSubmit={participantForm.handleSubmit(async (data) => {
await upsertParticipant({
...data,
statusLog: data.statusLog as Prisma.InputJsonValue[],
});
participantTableRef.current?.refresh();
})}
className="space-y-1"
>
<h1 className="text-2xl">Teilnehmer bearbeiten</h1>
<Switch
form={participantForm}
name="appointmentCancelled"
label="Termin abgesagt"
/>
<Switch
form={participantForm}
name="finisherMoodleCurseCompleted"
label="Abschluss-Moodle kurs abgeschlossen"
/>
{event?.hasPresenceEvents && (
<Switch
form={participantForm}
name="attended"
label="An Presenstermin teilgenommen"
/>
)}
<div className="flex flex-col">
<h3 className="text-xl">Verlauf</h3>
{participantForm.watch("statusLog").map((s) => {
return (
<div
className="flex justify-between"
key={(s as any).timestamp}
>
<p>{(s as any).event}</p>
<p>{new Date((s as any).timestamp).toLocaleString()}</p>
</div>
);
})}
</div>
<Button>Speichern</Button>
</form>
)}
</div>
</dialog>
);

View File

@@ -17,7 +17,7 @@ import {
prisma,
Prisma,
} from "@repo/db";
import { Bot, Calendar, FileText, UserIcon } from "lucide-react";
import { Bot, Calendar, FileText, User, UserIcon } from "lucide-react";
import { Input } from "../../../../_components/ui/Input";
import { useRef, useState } from "react";
import {
@@ -39,6 +39,7 @@ import { Select } from "../../../../_components/ui/Select";
import { useSession } from "next-auth/react";
import { MarkdownEditor } from "../../../../_components/ui/MDEditor";
import { AppointmentModal } from "./AppointmentModal";
import { ParticipantModal } from "./ParticipantModal";
export const Form = ({ event }: { event?: Event }) => {
const { data: session } = useSession();
@@ -53,17 +54,28 @@ export const Form = ({ event }: { event?: Event }) => {
presenterId: session?.user?.id,
},
});
const participantForm = useForm<Participant>({
resolver: zodResolver(ParticipantOptionalDefaultsSchema),
});
const appointmentsTableRef = useRef<PaginatedTableRef>(null);
const [loading, setLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const appointmentModal = useRef<HTMLDialogElement>(null);
const participantModal = useRef<HTMLDialogElement>(null);
return (
<>
<AppointmentModal
participantModal={participantModal}
participantForm={participantForm}
appointmentForm={appointmentForm}
ref={appointmentModal}
appointmentsTableRef={appointmentsTableRef}
event={event}
/>
<ParticipantModal
participantForm={participantForm}
ref={participantModal}
/>
<form
onSubmit={form.handleSubmit(async (values) => {
@@ -164,6 +176,8 @@ export const Form = ({ event }: { event?: Event }) => {
appointmentModal.current?.showModal();
appointmentForm.reset({
id: undefined,
eventId: event.id,
presenterId: session?.user?.id,
});
}}
>
@@ -238,6 +252,62 @@ export const Form = ({ event }: { event?: Event }) => {
</div>
</div>
) : null}
{!form.watch("hasPresenceEvents") ? (
<div className="card bg-base-200 shadow-xl col-span-6">
<div className="card-body">
<PaginatedTable
leftOfSearch={
<h2 className="card-title">
<Calendar className="w-5 h-5" /> Teilnehmer
</h2>
}
searchFields={["User.firstname", "User.lastname"]}
ref={appointmentsTableRef}
prismaModel={"participant"}
filter={{
eventId: event?.id,
}}
include={{
User: true,
}}
columns={[
{
header: "Vorname",
accessorKey: "User.firstname",
},
{
header: "Nachname",
accessorKey: "User.lastname",
},
{
header: "Moodle Kurs abgeschlossen",
accessorKey: "finisherMoodleCurseCompleted",
},
{
header: "Aktionen",
cell: ({ row }) => {
return (
<div className="flex gap-2">
<button
onSubmit={() => false}
type="button"
onClick={() => {
participantForm.reset(row.original);
participantModal.current?.showModal();
}}
className="btn btn-sm btn-outline"
>
Bearbeiten
</button>
</div>
);
},
},
]}
/>
</div>
</div>
) : null}
<div className="card bg-base-200 shadow-xl col-span-6">
<div className="card-body ">
<div className="flex w-full gap-4">

View File

@@ -0,0 +1,66 @@
import { Switch } from "../../../../_components/ui/Switch";
import { Button } from "../../../../_components/ui/Button";
import { Participant, Prisma } from "@repo/db";
import { UseFormReturn } from "react-hook-form";
import { upsertParticipant } from "../../../events/actions";
import { RefObject } from "react";
interface ParticipantModalProps {
participantForm: UseFormReturn<Participant>;
ref: RefObject<HTMLDialogElement | null>;
}
export const ParticipantModal = ({
participantForm,
ref,
}: ParticipantModalProps) => {
return (
<dialog className="modal" ref={ref}>
<div className="modal-box">
<form method="dialog">
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">
</button>
</form>
<h1 className="text-2xl">Teilnehmer bearbeiten</h1>
<form
onSubmit={participantForm.handleSubmit(async (data) => {
await upsertParticipant({
...data,
statusLog: data.statusLog as Prisma.InputJsonValue[],
});
participantForm.reset();
ref.current?.close();
})}
className="space-y-1"
>
<Switch
form={participantForm}
name="appointmentCancelled"
label="Termin abgesagt"
/>
<Switch
form={participantForm}
name="finisherMoodleCurseCompleted"
label="Abschluss-Moodle-Kurs abgeschlossen"
/>
<Switch
form={participantForm}
name="completetionWorkflowFinished"
label="Abgeschlossen (E-Mail-Benachrichtigung senden)"
/>
<div className="flex flex-col">
<h3 className="text-xl">Verlauf</h3>
{participantForm.watch("statusLog")?.map((s) => (
<div className="flex justify-between" key={(s as any).timestamp}>
<p>{(s as any).event}</p>
<p>{new Date((s as any).timestamp).toLocaleString()}</p>
</div>
))}
</div>
<Button type="submit">Speichern</Button>
</form>
</div>
</dialog>
);
};

View File

@@ -120,6 +120,7 @@ const ModalBtn = ({
<div className="flex items-center gap-2 justify-center">
<CalendarIcon />
{!!dates.length && (
// TODO: Prevent users from selecting an appointment that is full
<Select
form={selectAppointmentForm as any}
options={dates.map((date) => ({
@@ -180,10 +181,10 @@ const ModalBtn = ({
>
absagen
</button>
<p className="mt-3 text-center">
Bitte finde dich an diesem Termin in unserem Discord ein.
</p>
</div>
<p className="mt-3 text-center">
Bitte finde dich an diesem Termin in unserem Discord ein.
</p>
</>
)}
</div>
@@ -251,7 +252,7 @@ const MoodleCourseIndicator = ({
event: Event;
}) => {
const courseUrl = `${process.env.NEXT_PUBLIC_MOODLE_URL}/course/view.php?id=${moodleCourseId}`;
if (!participant || (event.hasPresenceEvents && !participant?.attended))
if (event.hasPresenceEvents && !participant?.attended)
return (
<p className="py-4 flex items-center gap-2 justify-center">
<Clock10Icon className="text-error" />

View File

@@ -25,6 +25,7 @@ interface PaginatedTableProps<TData>
leftOfSearch?: React.ReactNode;
rightOfSearch?: React.ReactNode;
leftOfPagination?: React.ReactNode;
hide?: boolean;
ref?: Ref<PaginatedTableRef>;
}
@@ -39,6 +40,7 @@ export function PaginatedTable<TData>({
leftOfSearch,
rightOfSearch,
leftOfPagination,
hide,
...restProps
}: PaginatedTableProps<TData>) {
const [data, setData] = useState<TData[]>([]);
@@ -111,19 +113,23 @@ export function PaginatedTable<TData>({
)}
<div className="flex justify-center">{rightOfSearch}</div>
</div>
<SortableTable
data={data}
prismaModel={prismaModel}
showEditButton={showEditButton}
{...restProps}
/>
{!hide && (
<SortableTable
data={data}
prismaModel={prismaModel}
showEditButton={showEditButton}
{...restProps}
/>
)}
<div className="flex items-between">
{leftOfPagination}
<Pagination
totalPages={Math.ceil(total / rowsPerPage)}
page={page}
setPage={setPage}
/>
{!hide && (
<Pagination
totalPages={Math.ceil(total / rowsPerPage)}
page={page}
setPage={setPage}
/>
)}
</div>
</div>
);

View File

@@ -23,10 +23,11 @@ export const GET = async (req: NextRequest) => {
});
if (!user)
return NextResponse.json({ error: "User not found" }, { status: 404 });
setTimeout(async () => {
console.log("getting moodle ID");
const moodleUser = await getMoodleUserById(user.id);
prisma.user.update({
console.log("got moodle ID", moodleUser.id);
await prisma.user.update({
where: {
id: user.id,
},
@@ -35,8 +36,6 @@ export const GET = async (req: NextRequest) => {
},
});
if (user.moodleId) return;
const participatingEvents = await prisma.participant.findMany({
where: {
userId: user.id,

View File

@@ -13,6 +13,7 @@
"@next-auth/prisma-adapter": "^1.0.7",
"@repo/db": "*",
"@repo/ui": "*",
"@tanstack/react-query": "^5.67.2",
"@tanstack/react-table": "^8.20.6",
"@uiw/react-md-editor": "^4.0.5",
"axios": "^1.7.9",