v2.0.8 #156

Merged
PxlLoewe merged 24 commits from staging into release 2026-01-31 22:08:50 +00:00
4 changed files with 562 additions and 0 deletions
Showing only changes of commit 606379d151 - Show all commits

View File

@@ -0,0 +1,34 @@
import { AppointmentForm } from "(app)/admin/event/_components/AppointmentForm";
import { prisma } from "@repo/db";
export default async function Page({
params,
}: {
params: Promise<{ id: string; appointmentId: string }>;
}) {
const { id: eventId, appointmentId } = await params;
const event = await prisma.event.findUnique({
where: { id: parseInt(eventId) },
});
if (!event) return <div>Event nicht gefunden</div>;
let appointment = null;
if (appointmentId !== "new") {
appointment = await prisma.eventAppointment.findUnique({
where: { id: parseInt(appointmentId) },
include: {
Presenter: true,
Participants: {
include: { User: true },
},
},
});
if (!appointment) return <div>Termin nicht gefunden</div>;
}
return (
<AppointmentForm event={event} initialAppointment={appointment} appointmentId={appointmentId} />
);
}

View File

@@ -0,0 +1,50 @@
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;
const event = await prisma.event.findUnique({
where: { id: parseInt(eventId) },
});
const participant = await prisma.participant.findUnique({
where: { id: parseInt(participantId) },
});
const user = await prisma.user.findUnique({
where: { id: participant?.userId },
});
if (!event) return <div>Event nicht gefunden</div>;
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} user={user} participant={participant} />
</div>
);
}

View File

@@ -0,0 +1,260 @@
"use client";
import {
EventAppointmentOptionalDefaults,
EventAppointmentOptionalDefaultsSchema,
} from "@repo/db/zod";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRef } from "react";
import { ColumnDef } from "@tanstack/react-table";
import { Event, EventAppointment, Participant, Prisma } from "@repo/db";
import { ArrowLeft, Calendar, Users } from "lucide-react";
import toast from "react-hot-toast";
import Link from "next/link";
import { PaginatedTable, PaginatedTableRef } from "../../../_components/PaginatedTable";
import { Button } from "../../../_components/ui/Button";
import { DateInput } from "../../../_components/ui/DateInput";
import { upsertParticipant } from "../../events/actions";
import { upsertAppointment, deleteAppoinement } from "../action";
import { InputJsonValueType } from "@repo/db/zod";
interface AppointmentFormProps {
event: Event;
initialAppointment:
| (EventAppointment & {
Presenter?: any;
Participants?: (Participant & { User?: any })[];
})
| null;
appointmentId: string;
}
export const AppointmentForm = ({
event,
initialAppointment,
appointmentId,
}: AppointmentFormProps) => {
const router = useRouter();
const participantTableRef = useRef<PaginatedTableRef>(null);
const form = useForm<EventAppointmentOptionalDefaults>({
resolver: zodResolver(EventAppointmentOptionalDefaultsSchema),
defaultValues: initialAppointment
? {
...initialAppointment,
eventId: event.id,
presenterId: initialAppointment.presenterId,
}
: undefined,
});
const onSubmit = async (values: EventAppointmentOptionalDefaults) => {
try {
await upsertAppointment({
...values,
eventId: event.id,
});
toast.success("Termin erfolgreich gespeichert");
router.back();
} catch {
toast.error("Fehler beim Speichern des Termins");
}
};
const handleDelete = async () => {
if (!confirm("Wirklich löschen?")) return;
try {
await deleteAppoinement(parseInt(appointmentId));
toast.success("Termin gelöscht");
router.back();
} catch {
toast.error("Fehler beim Löschen");
}
};
return (
<div className="bg-base-100 min-h-screen p-6">
<div className="mx-auto max-w-6xl">
{/* Header */}
<div className="mb-6">
<Link href={`/admin/event/${event.id}`} className="btn btn-ghost btn-sm mb-4">
<ArrowLeft className="h-4 w-4" /> Zurück
</Link>
<h1 className="text-3xl font-bold">
{appointmentId === "new" ? "Neuer Termin" : "Termin bearbeiten"}
</h1>
</div>
{/* Main Content */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Form Card */}
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 lg:col-span-1">
<div className="card bg-base-200 shadow-lg">
<div className="card-body">
<h2 className="card-title text-lg">
<Calendar className="h-5 w-5" /> Termin Details
</h2>
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">Datum & Zeit</span>
</label>
<DateInput
value={new Date(form.watch("appointmentDate") || Date.now())}
onChange={(date) => form.setValue("appointmentDate", date)}
/>
{form.formState.errors.appointmentDate && (
<span className="text-error mt-1 text-sm">
{form.formState.errors.appointmentDate.message}
</span>
)}
</div>
<div className="divider" />
<div className="flex gap-2 pt-2">
<Button
type="submit"
isLoading={form.formState.isSubmitting}
className="btn btn-primary flex-1"
>
Speichern
</Button>
{appointmentId !== "new" && (
<Button
type="button"
onClick={handleDelete}
isLoading={form.formState.isSubmitting}
className="btn btn-error"
>
Löschen
</Button>
)}
</div>
</div>
</div>
</form>
{/* Participants Table */}
<div className="lg:col-span-2">
<div className="card bg-base-200 shadow-lg">
<div className="card-body">
<h2 className="card-title text-lg">
<Users className="h-5 w-5" /> Teilnehmer
</h2>
{appointmentId !== "new" ? (
<PaginatedTable
ref={participantTableRef}
prismaModel={"participant"}
getFilter={() =>
({
eventAppointmentId: appointmentId,
}) as Prisma.ParticipantWhereInput
}
include={{ User: true }}
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: "Status",
cell: ({ row }) => {
if (row.original.attended) {
return <span className="badge badge-success">Anwesend</span>;
} else if (row.original.appointmentCancelled) {
return <span className="badge badge-error">Abgesagt</span>;
} else {
return <span className="badge badge-ghost">Offen</span>;
}
},
},
{
header: "Aktionen",
cell: ({ row }) => {
return (
<div className="flex gap-2">
<Link
href={`/admin/event/${event.id}/participant/${row.original.id}`}
className="btn btn-sm btn-outline"
>
Anzeigen
</Link>
{!row.original.attended && (
<button
type="button"
onClick={async () => {
await upsertParticipant({
eventId: event.id,
userId: row.original.userId,
attended: true,
appointmentCancelled: false,
});
participantTableRef.current?.refresh();
}}
className="btn btn-sm btn-info btn-outline"
>
</button>
)}
{!row.original.appointmentCancelled && (
<button
type="button"
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: "Admin",
},
],
});
participantTableRef.current?.refresh();
}}
className="btn btn-sm btn-error btn-outline"
>
</button>
)}
</div>
);
},
},
] as ColumnDef<Participant>[]
}
/>
) : (
<div className="py-8 text-center text-gray-500">
Speichern Sie den Termin um Teilnehmer hinzuzufügen
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,218 @@
"use client";
import { Participant, Event, User, 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;
user: User;
}
const checkEventCompleted = (participant: Participant, event: Event): boolean => {
if (event.hasPresenceEvents) {
return event.finisherMoodleCourseId
? participant.finisherMoodleCurseCompleted && participant.attended
: !!participant.attended;
}
return event.finisherMoodleCourseId ? participant.finisherMoodleCurseCompleted : false;
};
export const ParticipantForm = ({ event, participant, user }: 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>
{/* 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>
);
};