This commit is contained in:
PxlLoewe
2025-02-23 21:37:07 +01:00
36 changed files with 1687 additions and 1236 deletions

View File

@@ -8,6 +8,19 @@ export default async ({ params }: { params: Promise<{ id: string }> }) => {
id: parseInt(id), id: parseInt(id),
}, },
}); });
const users = await prisma.user.findMany({
select: {
id: true,
firstname: true,
lastname: true,
publicId: true,
},
});
const appointments = await prisma.eventAppointment.findMany({
where: {
eventId: parseInt(id),
},
});
if (!event) return <div>Event not found</div>; if (!event) return <div>Event not found</div>;
return <Form event={event} />; return <Form event={event} users={users} appointments={appointments} />;
}; };

View File

@@ -1,28 +1,97 @@
'use client'; 'use client';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { EventOptionalDefaultsSchema } from '@repo/db/zod'; import {
EventAppointmentOptionalDefaults,
EventAppointmentOptionalDefaultsSchema,
EventOptionalDefaults,
EventOptionalDefaultsSchema,
ParticipantOptionalDefaultsSchema,
} from '@repo/db/zod';
import { set, useForm } from 'react-hook-form'; import { set, useForm } from 'react-hook-form';
import { z } from 'zod'; import { BADGES, Event, EventAppointment, User } from '@repo/db';
import { BosUse, Country, Event, prisma } from '@repo/db'; import { Bot, Calendar, FileText, UserIcon } from 'lucide-react';
import { FileText, LocateIcon, PlaneIcon, UserIcon } from 'lucide-react';
import { Input } from '../../../../_components/ui/Input'; import { Input } from '../../../../_components/ui/Input';
import { useState } from 'react'; import { useRef, useState } from 'react';
import { deleteEvent, upsertEvent } from '../action'; import { deleteEvent, upsertAppointment, upsertEvent } from '../action';
import { Button } from '../../../../_components/ui/Button'; import { Button } from '../../../../_components/ui/Button';
import { redirect } from 'next/navigation'; import { redirect, useRouter } from 'next/navigation';
import { Switch } from '../../../../_components/ui/Switch'; import { Switch } from '../../../../_components/ui/Switch';
import { PaginatedTable } from '../../../../_components/PaginatedTable'; import {
PaginatedTable,
PaginatedTableRef,
} from '../../../../_components/PaginatedTable';
import { Select } from '../../../../_components/ui/Select';
import { useSession } from 'next-auth/react';
export const Form = ({ event }: { event?: Event }) => { export const Form = ({
const form = useForm<z.infer<typeof EventOptionalDefaultsSchema>>({ event,
users,
appointments = [],
}: {
event?: Event;
users: {
id: string;
firstname: string;
lastname: string;
publicId: string;
}[];
appointments?: EventAppointment[];
}) => {
const { data: session } = useSession();
const form = useForm({
resolver: zodResolver(EventOptionalDefaultsSchema), resolver: zodResolver(EventOptionalDefaultsSchema),
defaultValues: event, defaultValues: event,
}); });
const appointmentForm = useForm<EventAppointmentOptionalDefaults>({
resolver: zodResolver(EventAppointmentOptionalDefaultsSchema),
defaultValues: {
eventId: event?.id,
presenterId: session?.user?.id,
},
});
const appointmentsTableRef = useRef<PaginatedTableRef>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false); const [deleteLoading, setDeleteLoading] = useState(false);
console.log(form.formState.errors); const addParticipantModal = useRef<HTMLDialogElement>(null);
return ( return (
<> <>
<dialog ref={addParticipantModal} 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>
</form>
<h3 className="font-bold text-lg">Termin hinzufügen</h3>
<form
onSubmit={appointmentForm.handleSubmit(async (values) => {
if (!event) return;
const createdAppointment = await upsertAppointment({
appointmentDate: values.appointmentDate,
eventId: event?.id,
presenterId: values.presenterId,
});
console.log(createdAppointment);
addParticipantModal.current?.close();
appointmentsTableRef.current?.refresh();
})}
className="flex flex-col"
>
<Input
form={appointmentForm}
label="Datum"
name="appointmentDate"
type="date"
/>
<div className="modal-action">
<Button type="submit" className="btn btn-primary">
Hinzufügen
</Button>
</div>
</form>
</div>
</dialog>
<form <form
onSubmit={form.handleSubmit(async (values) => { onSubmit={form.handleSubmit(async (values) => {
setLoading(true); setLoading(true);
@@ -44,41 +113,134 @@ export const Form = ({ event }: { event?: Event }) => {
name="description" name="description"
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="hidden" label="Versteckt" /> <Switch form={form} name="hidden" label="Versteckt" />
</div> </div>
</div> </div>
<div className="card bg-base-200 shadow-xl col-span-2 max-xl:col-span-6"> <div className="card bg-base-200 shadow-xl col-span-2 max-xl:col-span-6">
<div className="card-body"> <div className="card-body">
<h2 className="card-title"> <h2 className="card-title">
<UserIcon className="w-5 h-5" /> Teilnehmer <Bot className="w-5 h-5" /> Automation
</h2> </h2>
<Input
form={form}
name="starterMoodleCourseId"
label="Moodle Anmelde Kurs ID"
className="input-sm"
/>
<Input
name="finisherMoodleCourseId"
form={form}
label="Moodle Abschluss Kurs ID"
className="input-sm"
/>
<Select
isMulti
form={form}
name="finishedBadges"
label="Badges bei Abschluss"
options={Object.entries(BADGES).map(([key, value]) => ({
label: value,
value: key,
}))}
/>
<Select
isMulti
form={form}
name="requiredBadges"
label="Benötigte Badges"
options={Object.entries(BADGES).map(([key, value]) => ({
label: value,
value: key,
}))}
/>
<Switch
form={form}
name="hasPresenceEvents"
label="Hat Live Event"
/>
</div>
</div>
<div className="card bg-base-200 shadow-xl col-span-2 max-xl:col-span-6">
<div className="card-body">
<div className="flex justify-between">
<h2 className="card-title">
<Calendar className="w-5 h-5" /> Termine
</h2>
<button
className="btn btn-primary btn-outline"
onClick={() => addParticipantModal.current?.showModal()}
>
Hinzufügen
</button>
</div>
<PaginatedTable <PaginatedTable
prismaModel={'participant'} ref={appointmentsTableRef}
prismaModel={'eventAppointment'}
filter={{ filter={{
eventId: event?.id, eventId: event?.id,
}} }}
include={[ include={{
{ Presenter: true,
user: true, Participants: true,
}, }}
]}
columns={[ columns={[
{ {
header: 'Vorname', header: 'Datum',
accessorKey: 'user.firstName', accessorKey: 'appointmentDate',
accessorFn: (date) =>
new Date(date.appointmentDate).toLocaleDateString(),
}, },
{ {
header: 'Nachname', header: 'Presenter',
accessorKey: 'user.lastname', accessorKey: 'presenter',
cell: ({ row }) => (
<div className="flex items-center">
<span className="ml-2">
{(row.original as any).Presenter.firstname}{' '}
{(row.original as any).Presenter.lastname}
</span>
</div>
),
}, },
{ {
header: 'VAR ID', header: 'Teilnehmer',
accessorKey: 'user.publicId', accessorKey: 'Participants',
cell: ({ row }) => (
<div className="flex items-center">
<UserIcon className="w-5 h-5" />
<span className="ml-2">
{row.original.Participants.length}
</span>
</div>
),
}, },
{ {
header: 'Status', header: 'Aktionen',
accessorKey: 'status', cell: ({ row }) => {
return (
<div className="flex gap-2">
<button
onSubmit={() => false}
type="button"
onClick={() => {
console.log(row.original);
}}
className="btn btn-sm btn-outline"
>
Bearbeiten
</button>
</div>
);
},
}, },
]} ]}
/> />

View File

@@ -1,6 +1,6 @@
'use server'; 'use server';
import { prisma, Prisma, Event } from '@repo/db'; import { prisma, Prisma, Event, Participant, EventAppointment } from '@repo/db';
export const upsertEvent = async ( export const upsertEvent = async (
event: Prisma.EventCreateInput, event: Prisma.EventCreateInput,
@@ -18,3 +18,48 @@ export const upsertEvent = async (
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 (
eventAppointment: Prisma.XOR<
Prisma.EventAppointmentCreateInput,
Prisma.EventAppointmentUncheckedCreateInput
>,
id?: EventAppointment['id']
) => {
const newEventAppointment = id
? await prisma.eventAppointment.update({
where: { id: id },
data: eventAppointment,
})
: await prisma.eventAppointment.create({ data: eventAppointment });
return newEventAppointment;
};
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 upsertParticipant = async (
participant: Prisma.ParticipantCreateInput,
id?: Participant['id']
) => {
const newParticipant = id
? await prisma.participant.update({
where: { id: id },
data: participant,
})
: await prisma.participant.create({ data: participant });
return newParticipant;
};
export const deleteParticipant = async (id: Participant['id']) => {
await prisma.participant.delete({ where: { id: id } });
};

View File

@@ -1,5 +1,14 @@
import { prisma } from '@repo/db';
import { Form } from '../_components/Form'; import { Form } from '../_components/Form';
export default () => { export default async () => {
return <Form />; const users = await prisma.user.findMany({
select: {
id: true,
firstname: true,
lastname: true,
publicId: true,
},
});
return <Form users={users} />;
}; };

View File

@@ -5,16 +5,6 @@ import Link from 'next/link';
export default () => { export default () => {
return ( return (
<> <>
<p className="text-2xl font-semibold text-left flex items-center gap-2 justify-between">
<span className="flex items-center gap-2">
<PartyPopperIcon className="w-5 h-5" /> Events
</span>
<Link href={'/admin/event/new'}>
<button className="btn btn-sm btn-outline btn-primary">
Erstellen
</button>
</Link>
</p>
<PaginatedTable <PaginatedTable
showEditButton showEditButton
prismaModel="event" prismaModel="event"
@@ -28,6 +18,18 @@ export default () => {
accessorKey: 'hidden', accessorKey: 'hidden',
}, },
]} ]}
leftOfSearch={
<span className="flex items-center gap-2">
<PartyPopperIcon className="w-5 h-5" /> Events
</span>
}
rightOfSearch={
<Link href={'/admin/event/new'}>
<button className="btn btn-sm btn-outline btn-primary">
Erstellen
</button>
</Link>
}
/> />
</> </>
); );

View File

@@ -1,42 +1,46 @@
import { DatabaseBackupIcon } from "lucide-react"; import { DatabaseBackupIcon } from 'lucide-react';
import { PaginatedTable } from "../../../_components/PaginatedTable"; import { PaginatedTable } from '../../../_components/PaginatedTable';
import Link from "next/link"; import Link from 'next/link';
export default () => { export default () => {
return ( return (
<> <>
<p className="text-2xl font-semibold text-left flex items-center gap-2 justify-between"> <PaginatedTable
showEditButton
prismaModel="station"
searchFields={['bosCallsign', 'bosUse', 'country', 'operator']}
columns={[
{
header: 'BOS Name',
accessorKey: 'bosCallsign',
},
{
header: 'Bos Use',
accessorKey: 'bosUse',
},
{
header: 'Country',
accessorKey: 'country',
},
{
header: 'operator',
accessorKey: 'operator',
},
]}
leftOfSearch={
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<DatabaseBackupIcon className="w-5 h-5" /> Stationen <DatabaseBackupIcon className="w-5 h-5" /> Stationen
</span> </span>
<Link href={"/admin/station/new"}> }
rightOfSearch={
<p className="text-2xl font-semibold text-left flex items-center gap-2 justify-between">
<Link href={'/admin/station/new'}>
<button className="btn btn-sm btn-outline btn-primary"> <button className="btn btn-sm btn-outline btn-primary">
Erstellen Erstellen
</button> </button>
</Link> </Link>
</p> </p>
<PaginatedTable }
showEditButton
prismaModel="station"
searchFields={["bosCallsign", "bosUse", "country", "operator"]}
columns={[
{
header: "BOS Name",
accessorKey: "bosCallsign",
},
{
header: "Bos Use",
accessorKey: "bosUse",
},
{
header: "Country",
accessorKey: "country",
},
{
header: "operator",
accessorKey: "operator",
},
]}
/> />
</> </>
); );

View File

@@ -1,34 +1,36 @@
import { User2 } from "lucide-react"; import { User2 } from 'lucide-react';
import { PaginatedTable } from "../../../_components/PaginatedTable"; import { PaginatedTable } from '../../../_components/PaginatedTable';
export default async () => { export default async () => {
return ( return (
<> <>
<p className="text-2xl font-semibold text-left flex items-center gap-2">
<User2 className="w-5 h-5" /> Benutzer
</p>
<PaginatedTable <PaginatedTable
showEditButton showEditButton
prismaModel="user" prismaModel="user"
searchFields={["publicId", "firstname", "lastname", "email"]} searchFields={['publicId', 'firstname', 'lastname', 'email']}
columns={[ columns={[
{ {
header: "ID", header: 'ID',
accessorKey: "publicId", accessorKey: 'publicId',
}, },
{ {
header: "Vorname", header: 'Vorname',
accessorKey: "firstname", accessorKey: 'firstname',
}, },
{ {
header: "Nachname", header: 'Nachname',
accessorKey: "lastname", accessorKey: 'lastname',
}, },
{ {
header: "Email", header: 'Email',
accessorKey: "email", accessorKey: 'email',
}, },
]} ]}
leftOfSearch={
<p className="text-2xl font-semibold text-left flex items-center gap-2">
<User2 className="w-5 h-5" /> Benutzer
</p>
}
/> />
</> </>
); );

View File

@@ -0,0 +1,93 @@
"use client";
import { DrawingPinFilledIcon, EnterIcon } from "@radix-ui/react-icons";
import { User } from "@repo/db";
import ModalBtn from "./modalBtn";
export const KursItem = ({
user,
title,
type,
beschreibung,
badge,
moodleReq,
}: {
user: User;
title: string;
type: string;
beschreibung: string;
badge: string;
moodleReq: boolean;
}) => {
return (
<div className="col-span-full">
<div className="card bg-base-200 shadow-xl mb-4">
<div className="card-body">
<h2 className="card-title">{title}</h2>
<div className="absolute top-0 right-0 m-4">
<span className="badge badge-info badge-outline">
Zusatzqualifikation
</span>
</div>
<div className="grid grid-cols-6 gap-4">
<div className="col-span-4">
<p className="text-left text-balance">{beschreibung}</p>
</div>
<div className="col-span-2">{badge}</div>
</div>
<div className="card-actions flex justify-between items-center mt-5">
<p className="text-gray-600 text-left flex items-center gap-2">
<DrawingPinFilledIcon /> <b>Teilnahmevoraussetzungen:</b>
<a className="link link-info" href="">
Moodle Kurs /MOODLEKURSTITLE\
</a>
</p>
<ModalBtn
title={title}
dates={["Dienstag, 25 Februar 2025", "Mittwoch, 26 Februar 2025"]}
modalId={title + "_modal" + Math.random()}
/>
</div>
</div>
</div>
</div>
);
};
export const PilotKurs = ({ user }: { user: User }) => {
{
/* STATISCH, DA FÜR ALLE NEUEN MITGLIEDER MANDATORY, WIRD AUSGEBLENDET WENN ABSOLVIERT */
}
return (
<div className="col-span-full">
<div className="card card-bordered border-secondary bg-base-200 shadow-xl mb-4">
<div className="card-body">
<h2 className="card-title">Einsteigerkurs für Piloten</h2>
<div className="absolute top-0 right-0 m-4">
<span className="badge badge-secondary badge-outline">
Verpflichtend
</span>
</div>
<div className="grid grid-cols-6 gap-4">
<div className="col-span-4">
<p className="text-left text-balance">
In diesem Kurs lernen Piloten die Grundlagen der Luftrettung,
Einsatzverfahren, den Umgang mit dem BOS-Funk und einige
medizinische Basics. Der Kurs bietet eine ideale Vorbereitung
für alle Standard Operations bei Virtual Air Rescue.
</p>
</div>
<div className="col-span-2">Badge</div>
</div>
<div className="card-actions flex justify-between items-center mt-5">
<p className="text-gray-600 text-left flex items-center gap-2">
<DrawingPinFilledIcon /> <b>Teilnahmevoraussetzungen:</b> Keine
</p>
<button className="btn btn-outline btn-secondary btn-wide">
<EnterIcon /> Zum Moodle Kurs
</button>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,87 @@
"use client";
import { useEffect } from "react";
import {
CheckCircledIcon,
CalendarIcon,
EnterIcon,
} from "@radix-ui/react-icons";
interface ModalBtnProps {
title: string;
dates: string[];
modalId: string;
}
const ModalBtn = ({ title, dates, modalId }: ModalBtnProps) => {
useEffect(() => {
const modal = document.getElementById(modalId) as HTMLDialogElement;
const handleOpen = () => {
document.body.classList.add("modal-open");
};
const handleClose = () => {
document.body.classList.remove("modal-open");
};
modal?.addEventListener("show", handleOpen);
modal?.addEventListener("close", handleClose);
return () => {
modal?.removeEventListener("show", handleOpen);
modal?.removeEventListener("close", handleClose);
};
}, [modalId]);
const openModal = () => {
const modal = document.getElementById(modalId) as HTMLDialogElement;
document.body.classList.add("modal-open");
modal?.showModal();
};
const closeModal = () => {
const modal = document.getElementById(modalId) as HTMLDialogElement;
document.body.classList.remove("modal-open");
modal?.close();
};
return (
<>
<button className="btn btn-outline btn-info btn-wide" onClick={openModal}>
<EnterIcon /> Anmelden
</button>
<dialog id={modalId} className="modal">
<div className="modal-box">
<h3 className="font-bold text-lg">{title}</h3>
<p className="py-4 flex items-center gap-2 justify-center">
<CheckCircledIcon className="text-success" />
Moodle Kurs abgeschlossen
</p>
<div>
<div className="flex items-center gap-2 justify-center">
<CalendarIcon />
<select className="select w-full max-w-xs" defaultValue={0}>
<option disabled>Bitte wähle einen Termin aus</option>
{dates.map((date, index) => (
<option key={index}>{date}</option>
))}
</select>
</div>
<p className="mt-3 text-center">
Bitte finde dich an diesem Termin in unserem Discord ein.
</p>
</div>
<div className="modal-action flex justify-between">
<button className="btn" onClick={closeModal}>
Abbrechen
</button>
<button className="btn btn-info btn-outline btn-wide">
<EnterIcon /> Anmelden
</button>
</div>
</div>
<button className="modal-backdrop" onClick={closeModal}>
Abbrechen
</button>
</dialog>
</>
);
};
export default ModalBtn;

View File

@@ -0,0 +1,45 @@
import { getServerSession } from "../../api/auth/[...nextauth]/auth";
import { PrismaClient } from "@repo/db";
import { PilotKurs, KursItem } from "./_components/item";
import {
RocketIcon,
DrawingPinFilledIcon,
EnterIcon,
} from "@radix-ui/react-icons";
export default async () => {
const prisma = new PrismaClient();
const session = await getServerSession();
if (!session) return null;
const user = await prisma.user.findFirst({
where: {
id: session.user.id,
},
});
if (!user) return null;
return (
<div className="grid grid-cols-6 gap-4">
<div className="col-span-full">
<p className="text-2xl font-semibold text-left flex items-center gap-2">
<RocketIcon className="w-5 h-5" /> Events & Kurse
</p>
</div>
<PilotKurs user={user} />
<KursItem
user={user}
title="Einsteigerkurs für Disponenten"
type="1"
beschreibung="In diesem Kurs lernen Teilnehmer die Aufgaben eines
Disponenten in der Rettungsfliegerei kennen. Dazu gehören die
Koordination von Notfalleinsätzen, die effiziente Planung von
Ressourcen und die Kommunikation mit Piloten sowie
Rettungsdiensten. Der Kurs vermittelt praxisnahe Kenntnisse
für die schnelle und präzise Entscheidungsfindung unter
Zeitdruck, um eine reibungslose Abwicklung von
Rettungseinsätzen zu gewährleisten."
badge="Badge"
moodleReq={true}
/>
</div>
);
};

View File

@@ -4,8 +4,11 @@ import {
InstagramLogoIcon, InstagramLogoIcon,
ReaderIcon, ReaderIcon,
} from "@radix-ui/react-icons"; } from "@radix-ui/react-icons";
import { HorizontalNav, VerticalNav } from "../_components/ui/Nav"; import { HorizontalNav, VerticalNav } from "../_components/Nav";
import { Toaster } from "react-hot-toast"; import { Toaster } from "react-hot-toast";
import { redirect } from "next/navigation";
import { getServerSession } from "../api/auth/[...nextauth]/auth";
import { headers } from "next/headers";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "Create Next App",
@@ -17,6 +20,10 @@ export default async function RootLayout({
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
const session = await getServerSession();
if (!session) redirect(`/login`);
return ( return (
<div <div
className="hero min-h-screen" className="hero min-h-screen"
@@ -35,12 +42,12 @@ export default async function RootLayout({
<HorizontalNav /> <HorizontalNav />
{/* Hauptlayout: Sidebar + Content (nimmt Resthöhe ein) */} {/* Hauptlayout: Sidebar + Content (nimmt Resthöhe ein) */}
<div className="flex flex-grow overflow-hidden"> <div className="flex grow overflow-hidden">
{/* Linke Sidebar */} {/* Linke Sidebar */}
<VerticalNav /> <VerticalNav />
{/* Scrollbarer Content-Bereich */} {/* Scrollbarer Content-Bereich */}
<div className="flex-grow bg-base-100 p-6 rounded-lg shadow-md ml-4 overflow-auto h-full"> <div className="flex-grow bg-base-100 p-6 rounded-lg shadow-md ml-4 overflow-auto h-full max-w-full w-full">
{children} {children}
</div> </div>
</div> </div>

View File

@@ -1,14 +1,14 @@
'use client'; "use client";
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from "@hookform/resolvers/zod";
import { DiscordAccount, User } from '@repo/db'; import { DiscordAccount, User } from "@repo/db";
import { useState } from 'react'; import { useState } from "react";
import { useForm } from 'react-hook-form'; import { useForm } from "react-hook-form";
import { z } from 'zod'; import { z } from "zod";
import { unlinkDiscord, updateUser, changePassword } from '../actions'; import { unlinkDiscord, updateUser, changePassword } from "../actions";
import { Toaster, toast } from 'react-hot-toast'; import { Toaster, toast } from "react-hot-toast";
import { useSession } from 'next-auth/react'; import { useSession } from "next-auth/react";
import { useRouter } from 'next/navigation'; import { useRouter } from "next/navigation";
import { Button } from '../../../_components/ui/Button'; import { Button } from "../../../_components/ui/Button";
import { import {
PersonIcon, PersonIcon,
EnvelopeClosedIcon, EnvelopeClosedIcon,
@@ -20,14 +20,14 @@ import {
LockClosedIcon, LockClosedIcon,
LockOpen2Icon, LockOpen2Icon,
LockOpen1Icon, LockOpen1Icon,
} from '@radix-ui/react-icons'; } from "@radix-ui/react-icons";
export const ProfileForm = ({ user }: { user: User }) => { export const ProfileForm = ({ user }: { user: User }) => {
const schema = z.object({ const schema = z.object({
firstname: z.string().min(2).max(30), firstname: z.string().min(2).max(30),
lastname: z.string().min(2).max(30), lastname: z.string().min(2).max(30),
email: z.string().email({ email: z.string().email({
message: 'Bitte gebe eine gültige E-Mail Adresse ein', message: "Bitte gebe eine gültige E-Mail Adresse ein",
}), }),
}); });
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -49,12 +49,10 @@ export const ProfileForm = ({ user }: { user: User }) => {
await updateUser(values); await updateUser(values);
form.reset(values); form.reset(values);
setIsLoading(false); setIsLoading(false);
toast.success('Deine Änderungen wurden gespeichert!', { toast.success("Deine Änderungen wurden gespeichert!", {
style: { style: {
background: background: "var(--color-base-100)",
'var(--fallback-b1, oklch(var(--b1) / var(--tw-bg-opacity, 1)))', color: "var(--color-base-content)",
color:
'var(--fallback-nc, oklch(var(--nc) / var(--tw-text-opacity, 1)))',
}, },
}); });
})} })}
@@ -62,15 +60,13 @@ export const ProfileForm = ({ user }: { user: User }) => {
<h2 className="card-title"> <h2 className="card-title">
<MixerHorizontalIcon className="w-5 h-5" /> Persönliche Informationen <MixerHorizontalIcon className="w-5 h-5" /> Persönliche Informationen
</h2> </h2>
<div className=""> <div className="text-left">
<label className="form-control w-full"> <label className="floating-label w-full mb-5 mt-5">
<div className="label"> <span className="text-lg flex items-center gap-2">
<span className="label-text text-lg flex items-center gap-2">
<PersonIcon /> Vorname <PersonIcon /> Vorname
</span> </span>
</div>
<input <input
{...form.register('firstname')} {...form.register("firstname")}
type="text" type="text"
className="input input-bordered w-full" className="input input-bordered w-full"
defaultValue={user.firstname} defaultValue={user.firstname}
@@ -82,14 +78,12 @@ export const ProfileForm = ({ user }: { user: User }) => {
{form.formState.errors.firstname.message} {form.formState.errors.firstname.message}
</p> </p>
)} )}
<label className="form-control w-full"> <label className="floating-label w-full mb-5">
<div className="label"> <span className="text-lg flex items-center gap-2">
<span className="label-text text-lg flex items-center gap-2">
<PersonIcon /> Nachname <PersonIcon /> Nachname
</span> </span>
</div>
<input <input
{...form.register('lastname')} {...form.register("lastname")}
type="text" type="text"
className="input input-bordered w-full" className="input input-bordered w-full"
defaultValue={user.lastname} defaultValue={user.lastname}
@@ -101,14 +95,12 @@ export const ProfileForm = ({ user }: { user: User }) => {
{form.formState.errors.lastname?.message} {form.formState.errors.lastname?.message}
</p> </p>
)} )}
<label className="form-control w-full"> <label className="floating-label w-full">
<div className="label"> <span className="text-lg flex items-center gap-2">
<span className="label-text text-lg flex items-center gap-2">
<EnvelopeClosedIcon /> E-Mail <EnvelopeClosedIcon /> E-Mail
</span> </span>
</div>
<input <input
{...form.register('email')} {...form.register("email")}
type="text" type="text"
className="input input-bordered w-full" className="input input-bordered w-full"
defaultValue={user.email} defaultValue={user.email}
@@ -158,6 +150,7 @@ export const SocialForm = ({
}); });
if (!user) return null; if (!user) return null;
console.log("Dirty", form.formState.isDirty);
return ( return (
<form <form
className="card-body" className="card-body"
@@ -170,12 +163,10 @@ export const SocialForm = ({
}); });
setVatsimLoading(false); setVatsimLoading(false);
form.reset(values); form.reset(values);
toast.success('Deine Änderungen wurden gespeichert!', { toast.success("Deine Änderungen wurden gespeichert!", {
style: { style: {
background: background: "var(--color-base-100)",
'var(--fallback-b1, oklch(var(--b1) / var(--tw-bg-opacity, 1)))', color: "var(--color-base-content)",
color:
'var(--fallback-nc, oklch(var(--nc) / var(--tw-text-opacity, 1)))',
}, },
}); });
})} })}
@@ -207,12 +198,16 @@ export const SocialForm = ({
Verbunden mit {discordAccount.username} Verbunden mit {discordAccount.username}
</span> </span>
<span className="hidden group-hover:inline"> <span className="hidden group-hover:inline">
Verbindung trennen{isLoading && '...'} Verbindung trennen{isLoading && "..."}
</span> </span>
</Button> </Button>
) : ( ) : (
<a href={process.env.NEXT_PUBLIC_DISCORD_URL}> <a href={process.env.NEXT_PUBLIC_DISCORD_URL}>
<button className="btn btn-primary btn-block"> <button
type="button"
className="btn btn-primary btn-block"
onSubmit={() => false}
>
<DiscordLogoIcon className="w-5 h-5" /> Mit Discord verbinden <DiscordLogoIcon className="w-5 h-5" /> Mit Discord verbinden
</button> </button>
</a> </a>
@@ -220,18 +215,16 @@ export const SocialForm = ({
</div> </div>
</div> </div>
<div className="content-center"> <div className="content-center">
<label className="form-control w-full"> <label className="floating-label w-full mt-5">
<div className="label"> <span className="text-lg flex items-center gap-2">
<span className="label-text text-lg flex items-center gap-2">
<PaperPlaneIcon /> VATSIM-CID <PaperPlaneIcon /> VATSIM-CID
</span> </span>
</div>
<input <input
type="number" type="number"
className="input input-bordered w-full" className="input input-bordered w-full"
placeholder="1445241" placeholder="1445241"
defaultValue={user.vatsimCid as number | undefined} defaultValue={user.vatsimCid as number | undefined}
{...form.register('vatsimCid', { {...form.register("vatsimCid", {
valueAsNumber: true, valueAsNumber: true,
})} })}
/> />
@@ -269,7 +262,6 @@ export const PasswordForm = ({ user }: { user: User }) => {
defaultValues: {}, defaultValues: {},
resolver: zodResolver(schema), resolver: zodResolver(schema),
}); });
console.log(form.formState.errors);
return ( return (
<form <form
className="card-body" className="card-body"
@@ -282,17 +274,15 @@ export const PasswordForm = ({ user }: { user: User }) => {
form.reset(values); form.reset(values);
setIsLoading(false); setIsLoading(false);
if (result.error) { if (result.error) {
form.setError('password', { form.setError("password", {
type: 'manual', type: "manual",
message: result.error, message: result.error,
}); });
} else if (result.success) { } else if (result.success) {
toast.success('Dein Passwort wurde geändert!', { toast.success("Dein Passwort wurde geändert!", {
style: { style: {
background: background: "var(--color-base-100)",
'var(--fallback-b1, oklch(var(--b1) / var(--tw-bg-opacity, 1)))', color: "var(--color-base-content)",
color:
'var(--fallback-nc, oklch(var(--nc) / var(--tw-text-opacity, 1)))',
}, },
}); });
} }
@@ -302,33 +292,29 @@ export const PasswordForm = ({ user }: { user: User }) => {
<LockClosedIcon className="w-5 h-5" /> Password Ändern <LockClosedIcon className="w-5 h-5" /> Password Ändern
</h2> </h2>
<div className=""> <div className="">
<label className="form-control w-full"> <label className="floating-label w-full mt-5 mb-5">
<div className="label"> <span className="text-lg flex items-center gap-2">
<span className="label-text text-lg flex items-center gap-2">
<LockOpen2Icon /> Aktuelles Passwort <LockOpen2Icon /> Aktuelles Passwort
</span> </span>
</div>
<input <input
{...form.register('password')} {...form.register("password")}
type="text" type="text"
className="input input-bordered w-full" className="input input-bordered w-full"
defaultValue={''} defaultValue={""}
/> />
</label> </label>
{form.formState.errors.password && ( {form.formState.errors.password && (
<p className="text-error">{form.formState.errors.password.message}</p> <p className="text-error">{form.formState.errors.password.message}</p>
)} )}
<label className="form-control w-full"> <label className="floating-label w-full mb-5">
<div className="label"> <span className="text-lg flex items-center gap-2">
<span className="label-text text-lg flex items-center gap-2">
<LockOpen1Icon /> Neues Passwort <LockOpen1Icon /> Neues Passwort
</span> </span>
</div>
<input <input
{...form.register('newPassword')} {...form.register("newPassword")}
type="text" type="text"
className="input input-bordered w-full" className="input input-bordered w-full"
defaultValue={''} defaultValue={""}
/> />
</label> </label>
{form.formState.errors.newPassword && ( {form.formState.errors.newPassword && (
@@ -336,17 +322,15 @@ export const PasswordForm = ({ user }: { user: User }) => {
{form.formState.errors.newPassword?.message} {form.formState.errors.newPassword?.message}
</p> </p>
)} )}
<label className="form-control w-full"> <label className="floating-label w-full">
<div className="label"> <span className="text-lg flex items-center gap-2">
<span className="label-text text-lg flex items-center gap-2">
<LockOpen1Icon /> Passwort wiederholen <LockOpen1Icon /> Passwort wiederholen
</span> </span>
</div>
<input <input
{...form.register('newPasswordConfirm')} {...form.register("newPasswordConfirm")}
type="text" type="text"
className="input input-bordered w-full" className="input input-bordered w-full"
defaultValue={''} defaultValue={""}
/> />
</label> </label>
{form.formState.errors.newPasswordConfirm && ( {form.formState.errors.newPasswordConfirm && (

View File

@@ -13,10 +13,10 @@ const AuthLayout: NextPage<
'url(https://img.daisyui.com/images/stock/photo-1507358522600-9f71e620c44e.webp)', 'url(https://img.daisyui.com/images/stock/photo-1507358522600-9f71e620c44e.webp)',
}} }}
> >
<div className="hero-overlay bg-opacity-60"></div> <div className="hero-overlay bg-neutral/60"></div>
<div className="hero-content text-neutral-content text-center "> <div className="hero-content text-center ">
<div className="max-w-lg"> <div className="max-w-lg">
<div className="card bg-base-100 w-full min-w-[500px] shadow-2xl max-md:min-w-[400px]"> <div className="card rounded-2xl bg-base-100 w-full min-w-[500px] shadow-2xl max-md:min-w-[400px]">
{children} {children}
</div> </div>
</div> </div>

View File

@@ -1,12 +1,12 @@
'use client'; "use client";
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from "@hookform/resolvers/zod";
import { signIn } from 'next-auth/react'; import { signIn } from "next-auth/react";
import Link from 'next/link'; import Link from "next/link";
import { useSearchParams } from 'next/navigation'; import { redirect, useSearchParams } from "next/navigation";
import { useState } from 'react'; import { useState } from "react";
import { useForm } from 'react-hook-form'; import { useForm } from "react-hook-form";
import { Toaster, toast } from 'react-hot-toast'; import { Toaster, toast } from "react-hot-toast";
import { z } from 'zod'; import { z } from "zod";
export const Login = () => { export const Login = () => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -27,23 +27,22 @@ export const Login = () => {
className="card-body" className="card-body"
onSubmit={form.handleSubmit(async () => { onSubmit={form.handleSubmit(async () => {
setIsLoading(true); setIsLoading(true);
const data = await signIn('credentials', { const data = await signIn("credentials", {
redirect: false, redirect: false,
callbackUrl: searchParams.get('redirect') || '/', email: form.getValues("email"),
email: form.getValues('email'), password: form.getValues("password"),
password: form.getValues('password'),
}); });
setIsLoading(false);
if (!data || data.error) { if (!data || data.error) {
toast.error('E-Mail / Passwort ist falsch!', { toast.error("E-Mail / Passwort ist falsch!", {
style: { style: {
background: background: "var(--color-base-100)",
'var(--fallback-b1, oklch(var(--b1) / var(--tw-bg-opacity, 1)))', color: "var(--color-base-content)",
color:
'var(--fallback-nc, oklch(var(--nc) / var(--tw-text-opacity, 1)))',
}, },
}); });
return;
} }
setIsLoading(false); redirect(searchParams.get("redirect") || "/");
})} })}
> >
<div> <div>
@@ -51,12 +50,12 @@ export const Login = () => {
</div> </div>
<h1 className="text-3xl font-bold">Login</h1> <h1 className="text-3xl font-bold">Login</h1>
<span className="text-sm font-medium"> <span className="text-sm font-medium">
Noch keinen Account? Zur{' '} Noch keinen Account? Zur{" "}
<Link href="/register" className="link link-accent link-hover"> <Link href="/register" className="link link-accent link-hover">
Registrierung Registrierung
</Link> </Link>
</span> </span>
<label className="input input-bordered flex items-center gap-2"> <label className="input input-bordered flex items-center gap-2 w-full">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16" viewBox="0 0 16 16"
@@ -69,16 +68,16 @@ export const Login = () => {
<input <input
type="text" type="text"
className="grow" className="grow"
{...form.register('email')} {...form.register("email")}
placeholder="Email" placeholder="Email"
/> />
</label> </label>
<p className="text-error"> <p className="text-error">
{typeof form.formState.errors.email?.message === 'string' {typeof form.formState.errors.email?.message === "string"
? form.formState.errors.email.message ? form.formState.errors.email.message
: ''} : ""}
</p> </p>
<label className="input input-bordered flex items-center gap-2 mt-2"> <label className="input input-bordered flex items-center gap-2 mt-2 w-full">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16" viewBox="0 0 16 16"
@@ -94,21 +93,17 @@ export const Login = () => {
<input <input
autoComplete="current-password" autoComplete="current-password"
type="password" type="password"
{...form.register('password')} {...form.register("password")}
placeholder="Passwort" placeholder="Passwort"
className="grow" className="grow"
/> />
</label> </label>
<div className="form-control mt-6"> <div className="card-actions mt-6">
<button <button className="btn btn-primary btn-block" disabled={isLoading}>
className="btn btn-primary"
name="loginBtn"
disabled={isLoading}
>
{isLoading && ( {isLoading && (
<span className="loading loading-spinner loading-sm"></span> <span className="loading loading-spinner loading-sm"></span>
)} )}
Login{isLoading && '...'} Login{isLoading && "..."}
</button> </button>
</div> </div>
</form> </form>

View File

@@ -71,7 +71,7 @@ export const Register = () => {
</Link> </Link>
</span> </span>
<div className="mt-5 mb-2"> <div className="mt-5 mb-2">
<label className="input input-bordered flex items-center gap-2 mt-2"> <label className="input input-bordered flex items-center gap-2 mt-2 w-full">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16" viewBox="0 0 16 16"
@@ -92,7 +92,7 @@ export const Register = () => {
? form.formState.errors.firstname.message ? form.formState.errors.firstname.message
: ''} : ''}
</p> </p>
<label className="input input-bordered flex items-center gap-2 mt-2"> <label className="input input-bordered flex items-center gap-2 mt-2 w-full">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16" viewBox="0 0 16 16"
@@ -113,8 +113,8 @@ export const Register = () => {
? form.formState.errors.lastname.message ? form.formState.errors.lastname.message
: ''} : ''}
</p> </p>
<div className="divider divider-neutral">Account</div> <div className="divider">Account</div>
<label className="input input-bordered flex items-center gap-2"> <label className="input input-bordered flex items-center gap-2 w-full">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16" viewBox="0 0 16 16"
@@ -137,7 +137,7 @@ export const Register = () => {
? form.formState.errors.email.message ? form.formState.errors.email.message
: ''} : ''}
</p> </p>
<label className="input input-bordered flex items-center gap-2 mt-2"> <label className="input input-bordered flex items-center gap-2 mt-2 w-full">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16" viewBox="0 0 16 16"
@@ -163,7 +163,7 @@ export const Register = () => {
? form.formState.errors.password.message ? form.formState.errors.password.message
: ''} : ''}
</p> </p>
<label className="input input-bordered flex items-center gap-2 mt-2"> <label className="input input-bordered flex items-center gap-2 mt-2 w-full">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16" viewBox="0 0 16 16"
@@ -189,9 +189,9 @@ export const Register = () => {
? form.formState.errors.passwordConfirm.message ? form.formState.errors.passwordConfirm.message
: ''} : ''}
</p> </p>
<div className="form-control mt-6"> <div className="card-actions mt-6">
<button <button
className="btn btn-primary" className="btn btn-primary btn-block"
name="registerBtn" name="registerBtn"
disabled={isLoading} disabled={isLoading}
> >

View File

@@ -0,0 +1,85 @@
import {
HomeIcon,
PersonIcon,
GearIcon,
ExitIcon,
LockClosedIcon,
RocketIcon,
} from "@radix-ui/react-icons";
import Link from "next/link";
export const VerticalNav = () => {
return (
<ul className="menu w-64 bg-base-300 p-4 rounded-lg shadow-md">
<li>
<Link href="/">
<HomeIcon /> Dashboard
</Link>
</li>
<li>
<Link href="/profile">
<PersonIcon /> Profile
</Link>
</li>
<li>
<Link href="/events">
<RocketIcon />
Events & Kurse
</Link>
</li>
<li>
<details open>
<summary>
<LockClosedIcon />
Admin
</summary>
<ul>
<li>
<Link href="/admin/user">Benutzer</Link>
</li>
<li>
<Link href="/admin/station">Stationen</Link>
</li>
<li>
<Link href="/admin/event">Events</Link>
</li>
</ul>
</details>
</li>
<li>
<Link href="/settings">
<GearIcon />
Einstellungen
</Link>
</li>
</ul>
);
};
export const HorizontalNav = () => (
<div className="navbar bg-base-200 shadow-md rounded-lg mb-4">
<div className="flex items-center">
<a className="btn btn-ghost normal-case text-xl">
Virtual Air Rescue - HUB
</a>
</div>
<div className="flex items-center ml-auto">
<ul className="flex space-x-2 px-1">
<li>
<Link href="/">
<button className="btn btn-sm btn-outline btn-primary">
Zur Leitstelle
</button>
</Link>
</li>
<li>
<Link href="/logout">
<button className="btn btn-sm btn-ghost">
<ExitIcon /> Logout
</button>
</Link>
</li>
</ul>
</div>
</div>
);

View File

@@ -1,9 +1,19 @@
'use client'; 'use client';
import { useEffect, useState, useCallback } from 'react'; import {
useEffect,
useState,
useCallback,
Ref,
useImperativeHandle,
} from 'react';
import SortableTable, { Pagination, SortableTableProps } from './Table'; import SortableTable, { Pagination, SortableTableProps } from './Table';
import { PrismaClient } from '@repo/db'; import { PrismaClient } from '@repo/db';
import { getData } from './pagiantedTableActions'; import { getData } from './pagiantedTableActions';
export interface PaginatedTableRef {
refresh: () => void;
}
interface PaginatedTableProps<TData> interface PaginatedTableProps<TData>
extends Omit<SortableTableProps<TData>, 'data'> { extends Omit<SortableTableProps<TData>, 'data'> {
prismaModel: keyof PrismaClient; prismaModel: keyof PrismaClient;
@@ -11,7 +21,10 @@ interface PaginatedTableProps<TData>
rowsPerPage?: number; rowsPerPage?: number;
showEditButton?: boolean; showEditButton?: boolean;
searchFields?: string[]; searchFields?: string[];
include?: Record<string, boolean>[]; include?: Record<string, boolean>;
leftOfSearch?: React.ReactNode;
rightOfSearch?: React.ReactNode;
ref?: Ref<PaginatedTableRef>;
} }
export function PaginatedTable<TData>({ export function PaginatedTable<TData>({
@@ -21,6 +34,9 @@ export function PaginatedTable<TData>({
searchFields = [], searchFields = [],
filter, filter,
include, include,
ref,
leftOfSearch,
rightOfSearch,
...restProps ...restProps
}: PaginatedTableProps<TData>) { }: PaginatedTableProps<TData>) {
const [data, setData] = useState<TData[]>([]); const [data, setData] = useState<TData[]>([]);
@@ -29,6 +45,30 @@ export function PaginatedTable<TData>({
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(searchTerm); const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(searchTerm);
const RefreshTableData = async () => {
getData(
prismaModel,
rowsPerPage,
page * rowsPerPage,
debouncedSearchTerm,
searchFields,
filter,
include
).then((result) => {
if (result) {
setData(result.data);
setTotal(result.total);
}
});
};
useImperativeHandle(ref, () => ({
refresh: () => {
console.log('refresh');
RefreshTableData();
},
}));
const debounce = (func: Function, delay: number) => { const debounce = (func: Function, delay: number) => {
let timer: NodeJS.Timeout; let timer: NodeJS.Timeout;
return (...args: any[]) => { return (...args: any[]) => {
@@ -45,25 +85,14 @@ export function PaginatedTable<TData>({
); );
useEffect(() => { useEffect(() => {
getData( RefreshTableData();
prismaModel,
rowsPerPage,
page * rowsPerPage,
debouncedSearchTerm,
searchFields,
filter
).then((result) => {
if (result) {
setData(result.data);
setTotal(result.total);
}
});
}, [page, debouncedSearchTerm]); }, [page, debouncedSearchTerm]);
return ( return (
<div className="space-y-4 m-4"> <div className="space-y-4 m-4">
{searchFields.length > 0 && ( {searchFields.length > 0 && (
<div className="flex justify-end"> <div className="flex items-center gap-2">
<div className="flex-1">{leftOfSearch}</div>
<input <input
type="text" type="text"
placeholder="Suchen..." placeholder="Suchen..."
@@ -74,6 +103,7 @@ export function PaginatedTable<TData>({
}} }}
className="input input-bordered w-full max-w-xs justify-end" className="input input-bordered w-full max-w-xs justify-end"
/> />
<div className="flex justify-center">{rightOfSearch}</div>
</div> </div>
)} )}
<SortableTable <SortableTable

View File

@@ -116,7 +116,7 @@ export const Pagination = ({
<ArrowLeft size={16} /> <ArrowLeft size={16} />
</button> </button>
<select <select
className="select join-item" className="select join-item w-16"
value={page} value={page}
onChange={(e) => setPage(Number(e.target.value))} onChange={(e) => setPage(Number(e.target.value))}
> >

View File

@@ -10,7 +10,7 @@ export async function getData(
searchTerm: string, searchTerm: string,
searchFields: string[], searchFields: string[],
filter?: Record<string, any>, filter?: Record<string, any>,
include?: Record<string, boolean>[] include?: Record<string, boolean>
) { ) {
if (!model || !prisma[model]) { if (!model || !prisma[model]) {
return { data: [], total: 0 }; return { data: [], total: 0 };

View File

@@ -1,14 +1,14 @@
import React from 'react'; import React from "react";
import { import {
FieldValues, FieldValues,
Path, Path,
RegisterOptions, RegisterOptions,
UseFormReturn, UseFormReturn,
} from 'react-hook-form'; } from "react-hook-form";
import { cn } from '../../../helper/cn'; import { cn } from "../../../helper/cn";
interface InputProps<T extends FieldValues> interface InputProps<T extends FieldValues>
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'form'> { extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "form"> {
name: Path<T>; name: Path<T>;
form: UseFormReturn<T>; form: UseFormReturn<T>;
formOptions?: RegisterOptions<T>; formOptions?: RegisterOptions<T>;
@@ -26,17 +26,13 @@ export const Input = <T extends FieldValues>({
...inputProps ...inputProps
}: InputProps<T>) => { }: InputProps<T>) => {
return ( return (
<label className="form-control w-full"> <label className="floating-label w-full mt-5">
<div className="label"> <span className="text-lg flex items-center gap-2">{label}</span>
<span className="label-text text-lg flex items-center gap-2">
{label}
</span>
</div>
<input <input
{...form.register(name, formOptions)} {...form.register(name, formOptions)}
type="text" type="text"
className={cn( className={cn(
'input input-bordered w-full placeholder:text-neutral-600', "input input-bordered w-full placeholder:text-neutral-600",
className className
)} )}
placeholder={placeholder} placeholder={placeholder}

View File

@@ -4,8 +4,9 @@ import {
GearIcon, GearIcon,
ExitIcon, ExitIcon,
LockClosedIcon, LockClosedIcon,
} from '@radix-ui/react-icons'; RocketIcon,
import Link from 'next/link'; } from "@radix-ui/react-icons";
import Link from "next/link";
export const VerticalNav = () => { export const VerticalNav = () => {
return ( return (
@@ -18,7 +19,13 @@ export const VerticalNav = () => {
</li> </li>
<li> <li>
<Link href="/profile"> <Link href="/profile">
<PersonIcon /> Profile <PersonIcon /> Profil
</Link>
</li>
<li>
<Link href="/events">
<RocketIcon />
Events & Kurse
</Link> </Link>
</li> </li>
<li> <li>
@@ -62,7 +69,7 @@ export const HorizontalNav = () => (
<ul className="flex space-x-2 px-1"> <ul className="flex space-x-2 px-1">
<li> <li>
<Link href="/"> <Link href="/">
<button className="btn btn-sm btn-outline btn-primary"> <button className="btn btn-sm btn-outline btn-info">
Zur Leitstelle Zur Leitstelle
</button> </button>
</Link> </Link>

View File

@@ -0,0 +1,62 @@
'use client';
import {
FieldValues,
Path,
RegisterOptions,
UseFormReturn,
} from 'react-hook-form';
import SelectTemplate, { Props as SelectTemplateProps } from 'react-select';
import { cn } from '../../../helper/cn';
import dynamic from 'next/dynamic';
interface SelectProps<T extends FieldValues> {
name: Path<T>;
form: UseFormReturn<T>;
formOptions?: RegisterOptions<T>;
label?: string;
placeholder?: string;
className?: string;
}
const SelectCom = <T extends FieldValues>({
name,
label = name,
placeholder = label,
form,
formOptions,
className,
...inputProps
}: SelectProps<T>) => {
return (
<div>
<span className="label-text text-lg flex items-center gap-2">
{label}
</span>
<SelectTemplate
{...form.register(name, formOptions)}
onChange={(newValue: any) => {
if ('value' in newValue) {
form.setValue(name, newValue.value);
} else {
form.setValue(name, newValue);
}
form.trigger(name);
}}
className={cn('w-full placeholder:text-neutral-600', className)}
placeholder={placeholder}
{...inputProps}
/>
{form.formState.errors[name] && (
<p className="text-error">
{form.formState.errors[name].message as string}
</p>
)}
</div>
);
};
const SelectWrapper = (props: any) => <SelectCom {...props} />;
export const Select = dynamic(() => Promise.resolve(SelectWrapper), {
ssr: false,
});

View File

@@ -1,9 +1,31 @@
@tailwind base; @import 'tailwindcss';
@tailwind components; @plugin "daisyui";
@tailwind utilities;
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
:root { :root {
--background: #ffffff; --background: #ffffff;
--foreground: #171717; --foreground: #171717;
/* --p: 47.67% 0.2484 267.02; */ /* --p: 47.67% 0.2484 267.02; */
--nc: #a6adbb;
}
body.modal-open {
overflow: hidden;
} }

View File

@@ -11,8 +11,8 @@
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.10.0", "@hookform/resolvers": "^3.10.0",
"@next-auth/prisma-adapter": "^1.0.7", "@next-auth/prisma-adapter": "^1.0.7",
"@repo/ui": "*",
"@repo/db": "*", "@repo/db": "*",
"@repo/ui": "*",
"@tanstack/react-table": "^8.20.6", "@tanstack/react-table": "^8.20.6",
"axios": "^1.7.9", "axios": "^1.7.9",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
@@ -23,24 +23,27 @@
"next": "15.1.4", "next": "15.1.4",
"next-auth": "^4.24.11", "next-auth": "^4.24.11",
"react": "^19.0.0", "react": "^19.0.0",
"react-day-picker": "^9.5.1",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"react-hot-toast": "^2.5.1", "react-hot-toast": "^2.5.1",
"react-select": "^5.10.0",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4.0.8",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.8", "@types/jsonwebtoken": "^9.0.8",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"daisyui": "^4.12.23", "daisyui": "^5.0.0-beta.8",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.1.4", "eslint-config-next": "15.1.4",
"postcss": "^8", "postcss": "^8",
"tailwindcss": "^3.4.1", "tailwindcss": "^4.0.8",
"typescript": "^5" "typescript": "^5"
} }
} }

View File

@@ -1,7 +1,7 @@
/** @type {import('postcss-load-config').Config} */ /** @type {import('postcss-load-config').Config} */
const config = { const config = {
plugins: { plugins: {
tailwindcss: {}, '@tailwindcss/postcss': {},
}, },
}; };

View File

@@ -1,10 +0,0 @@
import type { Config } from 'tailwindcss';
export default {
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
plugins: [require('daisyui')],
} satisfies Config;

1545
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
/*
Warnings:
- Made the column `eventId` on table `Participant` required. This step will fail if there are existing NULL values in that column.
*/
-- DropForeignKey
ALTER TABLE "Participant" DROP CONSTRAINT "Participant_eventId_fkey";
-- AlterTable
ALTER TABLE "Event" ALTER COLUMN "maxParticipants" SET DEFAULT 0;
-- AlterTable
ALTER TABLE "Participant" ALTER COLUMN "eventId" SET NOT NULL;
-- AddForeignKey
ALTER TABLE "Participant" ADD CONSTRAINT "Participant_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "Event" ALTER COLUMN "discordRoleId" SET DEFAULT '',
ALTER COLUMN "starterMoodleCourseId" SET DATA TYPE TEXT,
ALTER COLUMN "finisherMoodleCourseId" SET DATA TYPE TEXT;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Event" ALTER COLUMN "starterMoodleCourseId" SET DEFAULT '',
ALTER COLUMN "finisherMoodleCourseId" SET DEFAULT '';

View File

@@ -0,0 +1,9 @@
-- CreateEnum
CREATE TYPE "BADGES" AS ENUM ('P1', 'P2', 'P3', 'D1', 'D2', 'D3', 'DAY1');
-- CreateEnum
CREATE TYPE "PERMISSION" AS ENUM ('ADMIN_EVENT', 'ADMIN_USER', 'SUSPENDED');
-- AlterTable
ALTER TABLE "users" ADD COLUMN "badges" "BADGES"[] DEFAULT ARRAY[]::"BADGES"[],
ADD COLUMN "permissions" "PERMISSION"[] DEFAULT ARRAY[]::"PERMISSION"[];

View File

@@ -0,0 +1,57 @@
/*
Warnings:
- You are about to drop the column `selectedForParticipatioon` on the `Participant` table. All the data in the column will be lost.
- You are about to drop the column `status` on the `Participant` table. All the data in the column will be lost.
- Added the required column `eventAppointmentId` to the `Participant` table without a default value. This is not possible if the table is not empty.
*/
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "PERMISSION" ADD VALUE 'PILOT';
ALTER TYPE "PERMISSION" ADD VALUE 'DISPO';
-- AlterTable
ALTER TABLE "Participant" DROP COLUMN "selectedForParticipatioon",
DROP COLUMN "status",
ADD COLUMN "eventAppointmentId" INTEGER NOT NULL,
ADD COLUMN "finisherMoodleCurseCompleted" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "starterMoodleCurseCompleted" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "EventAppointment" (
"id" SERIAL NOT NULL,
"eventId" INTEGER NOT NULL,
"appointmentDate" TIMESTAMP(3) NOT NULL,
CONSTRAINT "EventAppointment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_EventAppointmentToUser" (
"A" INTEGER NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_EventAppointmentToUser_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateIndex
CREATE INDEX "_EventAppointmentToUser_B_index" ON "_EventAppointmentToUser"("B");
-- AddForeignKey
ALTER TABLE "EventAppointment" ADD CONSTRAINT "EventAppointment_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Participant" ADD CONSTRAINT "Participant_eventAppointmentId_fkey" FOREIGN KEY ("eventAppointmentId") REFERENCES "EventAppointment"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_EventAppointmentToUser" ADD CONSTRAINT "_EventAppointmentToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "EventAppointment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_EventAppointmentToUser" ADD CONSTRAINT "_EventAppointmentToUser_B_fkey" FOREIGN KEY ("B") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,8 @@
-- DropForeignKey
ALTER TABLE "Participant" DROP CONSTRAINT "Participant_eventAppointmentId_fkey";
-- AlterTable
ALTER TABLE "Participant" ALTER COLUMN "eventAppointmentId" DROP NOT NULL;
-- AddForeignKey
ALTER TABLE "Participant" ADD CONSTRAINT "Participant_eventAppointmentId_fkey" FOREIGN KEY ("eventAppointmentId") REFERENCES "EventAppointment"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,38 @@
/*
Warnings:
- You are about to drop the `_EventAppointmentToUser` table. If the table is not empty, all the data it contains will be lost.
- Added the required column `presenterId` to the `EventAppointment` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "_EventAppointmentToUser" DROP CONSTRAINT "_EventAppointmentToUser_A_fkey";
-- DropForeignKey
ALTER TABLE "_EventAppointmentToUser" DROP CONSTRAINT "_EventAppointmentToUser_B_fkey";
-- AlterTable
ALTER TABLE "EventAppointment" ADD COLUMN "presenterId" TEXT NOT NULL;
-- DropTable
DROP TABLE "_EventAppointmentToUser";
-- CreateTable
CREATE TABLE "_EventAppointmentUser" (
"A" INTEGER NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_EventAppointmentUser_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateIndex
CREATE INDEX "_EventAppointmentUser_B_index" ON "_EventAppointmentUser"("B");
-- AddForeignKey
ALTER TABLE "EventAppointment" ADD CONSTRAINT "EventAppointment_presenterId_fkey" FOREIGN KEY ("presenterId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_EventAppointmentUser" ADD CONSTRAINT "_EventAppointmentUser_A_fkey" FOREIGN KEY ("A") REFERENCES "EventAppointment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_EventAppointmentUser" ADD CONSTRAINT "_EventAppointmentUser_B_fkey" FOREIGN KEY ("B") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -10,27 +10,41 @@ enum PARTICIPANT_STATUS {
WAVED WAVED
} }
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])
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")
status PARTICIPANT_STATUS starterMoodleCurseCompleted Boolean @default(false)
selectedForParticipatioon Boolean @default(false) finisherMoodleCurseCompleted Boolean @default(false)
eventAppointmentId Int?
statusLog Json[] statusLog Json[]
eventId Int eventId Int
// relations: // relations:
user User @relation(fields: [userId], references: [id]) User User @relation(fields: [userId], references: [id])
Event Event? @relation(fields: [eventId], references: [id]) Event Event @relation(fields: [eventId], references: [id])
EventAppointment EventAppointment? @relation(fields: [eventAppointmentId], references: [id])
} }
model Event { model Event {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
description String description String
discordRoleId String? discordRoleId String? @default("")
hasPresenceEvents Boolean @default(false) hasPresenceEvents Boolean @default(false)
maxParticipants Int? @default(0) maxParticipants Int? @default(0)
starterMoodleCourseId Int? starterMoodleCourseId String? @default("")
finisherMoodleCourseId Int? finisherMoodleCourseId String? @default("")
finishedBadges String[] @default([]) finishedBadges String[] @default([])
requiredBadges String[] @default([]) requiredBadges String[] @default([])
finishedPermissions String[] @default([]) finishedPermissions String[] @default([])
@@ -38,6 +52,7 @@ model Event {
// relations: // relations:
participants Participant[] participants Participant[]
appointments EventAppointment[]
} }
model File { model File {

View File

@@ -1,3 +1,21 @@
enum BADGES {
P1
P2
P3
D1
D2
D3
DAY1
}
enum PERMISSION {
ADMIN_EVENT
ADMIN_USER
SUSPENDED
PILOT
DISPO
}
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
publicId String @unique publicId String @unique
@@ -8,6 +26,8 @@ model User {
vatsimCid Int? @map(name: "vatsim_cid") vatsimCid Int? @map(name: "vatsim_cid")
emailVerified DateTime? @map(name: "email_verified") emailVerified DateTime? @map(name: "email_verified")
image String? image String?
badges BADGES[] @default([])
permissions PERMISSION[] @default([])
createdAt DateTime @default(now()) @map(name: "created_at") createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @default(now()) @map(name: "updated_at") updatedAt DateTime @default(now()) @map(name: "updated_at")
@@ -15,6 +35,8 @@ model User {
oauthTokens OAuthToken[] oauthTokens OAuthToken[]
discordAccounts DiscordAccount[] discordAccounts DiscordAccount[]
participants Participant[] participants Participant[]
EventAppointmentUser EventAppointment[] @relation("EventAppointmentUser")
EventAppointment EventAppointment[]
@@map(name: "users") @@map(name: "users")
} }