Event admin redesign #153
@@ -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} />
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
260
apps/hub/app/(app)/admin/event/_components/AppointmentForm.tsx
Normal file
260
apps/hub/app/(app)/admin/event/_components/AppointmentForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
218
apps/hub/app/(app)/admin/event/_components/ParticipantForm.tsx
Normal file
218
apps/hub/app/(app)/admin/event/_components/ParticipantForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user