Pfadauswahl hinzugefügt

This commit is contained in:
PxlLoewe
2025-06-25 21:06:03 -07:00
parent 7ad75bff63
commit 2bd8a455c8
14 changed files with 253 additions and 90 deletions

View File

@@ -1,6 +1,6 @@
import { getServerSession } from "../../api/auth/[...nextauth]/auth";
import { prisma } from "@repo/db";
import { KursItem } from "../events/_components/item";
import { EventCard } from "../events/_components/item";
import { RocketIcon } from "lucide-react";
import { eventCompleted } from "../../../helper/events";
@@ -15,15 +15,15 @@ const page = async () => {
const events = await prisma.event.findMany({
where: {
type: "OBLIGATED_COURSE",
type: "EVENT",
},
include: {
participants: {
Participants: {
where: {
userId: user.id,
},
},
appointments: {
Appointments: {
include: {
Participants: {
where: {
@@ -36,12 +36,10 @@ const page = async () => {
});
const filteredEvents = events.filter((event) => {
const userParticipant = event.participants.find(
const userParticipant = event.Participants.find(
(participant) => participant.userId === user.id,
);
if (eventCompleted(event, userParticipant)) return false;
if (event.type === "OBLIGATED_COURSE" && !eventCompleted(event, event.participants[0]))
return true;
return false;
});
@@ -56,9 +54,9 @@ const page = async () => {
<div className="grid grid-cols-6 gap-4">
{filteredEvents.map((event) => {
return (
<KursItem
appointments={event.appointments}
selectedAppointments={event.appointments.filter((a) =>
<EventCard
appointments={event.Appointments}
selectedAppointments={event.Appointments.filter((a) =>
a.Participants.find((p) => p.userId == user.id),
)}
user={user}

View File

@@ -1,26 +1,22 @@
"use client";
import { editUser } from "(app)/admin/user/action";
import { Button } from "_components/ui/Button";
import { Plane, Workflow } from "lucide-react";
import { useSession } from "next-auth/react";
import { useRef, useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { getEvents } from "../../../helper/events";
import { EventCard } from "(app)/events/_components/item";
export const FirstPath = () => {
const modalRef = useRef<HTMLDialogElement>(null);
const [selected, setSelected] = useState<"disponent" | "pilot" | null>(null);
useEffect(() => {
if (modalRef.current && !modalRef.current.open) {
modalRef.current.showModal();
}
}, []);
const PathsOptions = ({
selected,
setSelected,
}: {
selected: "disponent" | "pilot" | null;
setSelected: (value: "disponent" | "pilot") => void;
}) => {
return (
<dialog ref={modalRef} className="modal">
<div className="modal-box w-11/12 max-w-5xl">
<h3 className="flex items-center gap-2 text-lg font-bold mb-10">Wähle deinen Einstieg!</h3>
<p className="mb-8 text-base text-base-content/80 text-center">
Willkommen bei Virtual Air Rescue!
<br /> Wähle deinen ersten Pfad aus. Du kannst später jederzeit auch den anderen Pfad
ausprobieren, wenn du möchtest.
</p>
<div className="flex flex-col items-center justify-center m-20">
<>
<div className="flex gap-6">
{/* Disponent Card */}
<div
@@ -32,12 +28,14 @@ export const FirstPath = () => {
tabIndex={0}
aria-pressed={selected === "disponent"}
>
<div className="font-semibold text-lg mb-2">Disponent</div>
<h1 className="font-semibold text-lg mb-2 flex gap-2 justify-center items-center">
Disponent <Workflow />
</h1>
<div className="text-sm text-base-content/70">
Denkt sich realistische Einsatzszenarien aus, koordiniert deren Ablauf und ist die
zentrale Schnittstelle zwischen Piloten und bodengebundenen Rettungsmitteln. Er
trägt die Verantwortung für einen reibungslosen Ablauf und der erfolgreichen
Durchführung der Einsätze.
zentrale Schnittstelle zwischen Piloten und bodengebundenen Rettungsmitteln. Er trägt
die Verantwortung für einen reibungslosen Ablauf und der erfolgreichen Durchführung der
Einsätze.
<div className="badge badge-sm badge-secondary mt-3">
Teilnahme an Einführungsevent Nötig
</div>
@@ -53,23 +51,97 @@ export const FirstPath = () => {
tabIndex={0}
aria-pressed={selected === "pilot"}
>
<div className="font-semibold text-lg mb-2">Pilot</div>
<h1 className="font-semibold text-lg mb-2 flex gap-2 justify-center items-center">
Pilot <Plane />
</h1>
<div className="text-sm text-base-content/70">
Fliegt die vom Disponenten erstellten Einsätze und transportiert die Med-Crew sicher
zum Einsatzort. Er übernimmt die navigatorische Vorbereitung, achtet auf
Wetterentwicklungen und sorgt für die Sicherheit seiner Crew im Flug.
Fliegt die vom Disponenten erstellten Einsätze und transportiert die Med-Crew sicher zum
Einsatzort. Er übernimmt die navigatorische Vorbereitung, achtet auf Wetterentwicklungen
und sorgt für die Sicherheit seiner Crew im Flug.
</div>
</div>
</div>
</>
);
};
const EventSelect = ({ pathSelected }: { pathSelected: "disponent" | "pilot" }) => {
const { data: events } = useQuery({
queryKey: ["events", "initial", pathSelected],
queryFn: () =>
getEvents({
type: pathSelected === "pilot" ? "PILOT_STARTER" : "DISPATCH_STARTER",
}),
});
const user = useSession().data?.user;
if (!user) return null;
return events?.map((event) => {
return (
<EventCard
appointments={event.Appointments}
selectedAppointments={event.Appointments.filter((a) =>
a.Participants.find((p) => p.userId == user.id),
)}
user={user}
event={event}
key={event.id}
/>
);
});
};
export const FirstPath = () => {
const modalRef = useRef<HTMLDialogElement>(null);
const { data: session } = useSession();
const [selected, setSelected] = useState<"disponent" | "pilot" | null>(null);
const [page, setPage] = useState<"path" | "event-select">("path");
useEffect(() => {
if (modalRef.current && !modalRef.current.open) {
modalRef.current.showModal();
}
}, []);
return (
<dialog ref={modalRef} className="modal">
<div className="modal-box w-11/12 max-w-5xl">
<h3 className="flex items-center gap-2 text-lg font-bold mb-10">Wähle deinen Einstieg!</h3>
<p className="mb-8 text-base text-base-content/80 text-center">
Willkommen bei Virtual Air Rescue!
<br /> Wie möchtest du bei uns starten? Du kannst später jederzeit auch den anderen Pfad
ausprobieren, wenn du möchtest.
</p>
<div className="flex flex-col items-center justify-center m-20">
{page === "path" && <PathsOptions selected={selected} setSelected={setSelected} />}
{page === "event-select" && (
<div className="flex flex-col gap-3 min-w-[800px]">
<div>
<p className="text-left text-gray-400 text-sm">Wähle dein Einführungs-Event aus:</p>
</div>
<EventSelect pathSelected={selected!} />
</div>
)}
</div>
<div className="modal-action">
<button
<button className="btn" disabled={page === "path"} onClick={() => setPage("path")}>
Zurück
</button>
<Button
className="btn btn-info"
disabled={!selected}
onClick={() => modalRef.current?.close()}
onClick={async () => {
if (page === "path") {
setPage("event-select");
} else if (session?.user.id) {
await editUser(session?.user.id, {
pathSelected: true,
});
modalRef.current?.close();
}
}}
>
Auswahl Bestätigen
</button>
{page === "path" ? "Weiter" : "Pfad auswählen"}
</Button>
</div>
</div>
</dialog>

View File

@@ -5,7 +5,7 @@ import ModalBtn from "./modalBtn";
import MDEditor from "@uiw/react-md-editor";
import { Badge } from "../../../_components/Badge/Badge";
export const KursItem = ({
export const EventCard = ({
user,
event,
selectedAppointments,
@@ -13,8 +13,8 @@ export const KursItem = ({
}: {
user: User;
event: Event & {
appointments: EventAppointment[];
participants: Participant[];
Appointments: EventAppointment[];
Participants: Participant[];
};
selectedAppointments: EventAppointment[];
appointments: EventAppointment[];
@@ -28,8 +28,8 @@ export const KursItem = ({
{event.type === "COURSE" && (
<span className="badge badge-info badge-outline">Zusatzqualifikation</span>
)}
{event.type === "OBLIGATED_COURSE" && (
<span className="badge badge-secondary badge-outline">Verpflichtend</span>
{event.type === "EVENT" && (
<span className="badge badge-secondary badge-outline">Event</span>
)}
</div>
<div className="grid grid-cols-6 gap-4">
@@ -75,7 +75,7 @@ export const KursItem = ({
event={event}
title={event.name}
dates={appointments}
participant={event.participants[0]}
participant={event.Participants[0]}
modalId={`${event.name}_modal.${event.id}`}
/>
</div>

View File

@@ -92,7 +92,7 @@ const ModalBtn = ({
<button
className={cn(
"btn btn-outline btn-info btn-wide",
event.type === "OBLIGATED_COURSE" && "btn-secondary",
event.type === "COURSE" && "btn-secondary",
eventCompleted(event, participant) && "btn-success",
)}
onClick={openModal}
@@ -219,7 +219,7 @@ const ModalBtn = ({
<button
className={cn(
"btn btn-info btn-outline btn-wide",
event.type === "OBLIGATED_COURSE" && "btn-secondary",
event.type === "COURSE" && "btn-secondary",
)}
onClick={async () => {
const data = selectAppointmentForm.getValues();

View File

@@ -1,6 +1,6 @@
import { prisma } from "@repo/db";
import { getServerSession } from "../../api/auth/[...nextauth]/auth";
import { KursItem } from "./_components/item";
import { EventCard } from "./_components/item";
import { RocketIcon } from "@radix-ui/react-icons";
const page = async () => {
@@ -11,14 +11,14 @@ const page = async () => {
const events = await prisma.event.findMany({
include: {
appointments: {
Appointments: {
where: {
appointmentDate: {
gte: new Date(),
},
},
},
participants: {
Participants: {
where: {
userId: user.id,
},
@@ -69,7 +69,7 @@ const page = async () => {
{events.map((event) => {
return (
<KursItem
<EventCard
appointments={appointments}
selectedAppointments={userAppointments}
user={user}

View File

@@ -46,7 +46,7 @@ export default async function RootLayout({
<EmailVerification />
</div>
)}
<FirstPath />
{!session.user.pathSelected && <FirstPath />}
{children}
</div>
</div>

View File

@@ -0,0 +1,25 @@
// components/TanstackProvider.tsx
"use client";
import { toast } from "react-hot-toast";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactNode, useEffect, useState } from "react";
export function QueryProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
mutations: {
onError: (error) => {
toast.error("An error occurred: " + (error as Error).message, {
position: "top-right",
});
},
},
},
}),
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

View File

@@ -0,0 +1,46 @@
import { Prisma, prisma } from "@repo/db";
import { getServerSession } from "api/auth/[...nextauth]/auth";
import { NextResponse } from "next/server";
export async function GET(request: Request): Promise<NextResponse> {
try {
const session = await getServerSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const filter = JSON.parse(
new URL(request.url).searchParams.get("filter") || "{}",
) as Prisma.EventWhereInput;
const connectedAircraft = await prisma.event.findMany({
where: {
...filter, // Ensure filter is parsed correctly
},
include: {
Participants: {
where: {
userId: session.user.id,
},
},
Appointments: {
include: {
Participants: {
where: {
appointmentCancelled: false,
},
},
},
},
},
});
return NextResponse.json(connectedAircraft, {
status: 200,
});
} catch (error) {
console.error(error);
return NextResponse.json({ error: "Failed to fetch Aircrafts" }, { status: 500 });
}
}

View File

@@ -4,6 +4,7 @@ import { getServerSession } from "./api/auth/[...nextauth]/auth";
import { CustomErrorBoundary } from "_components/ErrorBoundary";
import { Toaster } from "react-hot-toast";
import "./globals.css";
import { QueryProvider } from "_components/QueryClient";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -33,7 +34,9 @@ const RootLayout = async ({
reverseOrder={false}
/>
</div>
<QueryProvider>
<CustomErrorBoundary>{children}</CustomErrorBoundary>
</QueryProvider>
</body>
</NextAuthSessionProvider>
</html>

View File

@@ -1,5 +1,23 @@
import { Event, Participant } from "@repo/db";
import { Event, EventAppointment, Participant, Prisma } from "@repo/db";
import axios from "axios";
import { da } from "date-fns/locale";
export const getEvents = async (filter: Prisma.EventWhereInput) => {
const { data } = await axios.get<
(Event & {
Appointments: (EventAppointment & {
Appointments: EventAppointment[];
Participants: Participant[];
})[];
Participants: Participant[];
})[]
>(`/api/event`, {
params: {
filter: JSON.stringify(filter),
},
});
return data;
};
export const eventCompleted = (event: Event, participant?: Participant) => {
if (!participant) return false;

View File

@@ -10,19 +10,29 @@
"lint": "next lint"
},
"dependencies": {
"@eslint/eslintrc": "^3",
"@hookform/resolvers": "^5.0.1",
"@next-auth/prisma-adapter": "^1.0.7",
"@radix-ui/react-icons": "^1.3.2",
"@repo/db": "workspace:*",
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@tanstack/react-query": "^5.79.0",
"@tailwindcss/postcss": "^4.1.8",
"@tanstack/react-query": "^5.79.2",
"@tanstack/react-table": "^8.21.3",
"@types/bcryptjs": "^3.0.0",
"@types/jsonwebtoken": "^9.0.9",
"@types/node": "^22.15.29",
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.5",
"@uiw/react-md-editor": "^4.0.7",
"axios": "^1.9.0",
"bcryptjs": "^3.0.2",
"clsx": "^2.1.1",
"daisyui": "^5.0.43",
"date-fns": "^4.1.0",
"eslint": "^9.15.0",
"eslint-config-next": "^15.3.3",
"i": "^0.3.7",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
@@ -31,6 +41,7 @@
"next-auth": "^4.24.11",
"next-remove-imports": "^1.0.12",
"npm": "^11.4.1",
"postcss": "^8.5.4",
"react": "^19.1.0",
"react-datepicker": "^8.4.0",
"react-day-picker": "^9.7.0",
@@ -40,20 +51,8 @@
"react-hot-toast": "^2.5.2",
"react-select": "^5.10.1",
"tailwind-merge": "^3.3.0",
"zod": "^3.25.46",
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4.1.8",
"@types/bcryptjs": "^3.0.0",
"@types/jsonwebtoken": "^9.0.9",
"@types/node": "^22.15.29",
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.5",
"daisyui": "^5.0.43",
"eslint": "^9.15.0",
"eslint-config-next": "^15.3.3",
"postcss": "^8.5.4",
"tailwindcss": "^4.1.8",
"typescript": "^5.8.3"
},
"devDependencies": {}
"typescript": "^5.8.3",
"zod": "^3.25.46"
}
}

View File

@@ -1,6 +1,7 @@
enum EVENT_TYPE {
COURSE
OBLIGATED_COURSE
PILOT_STARTER
DISPATCH_STARTER
EVENT
}
@@ -12,7 +13,7 @@ model EventAppointment {
// relations:
Users User[] @relation("EventAppointmentUser")
Participants Participant[]
Event Event @relation(fields: [eventId], references: [id])
Event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
Presenter User @relation(fields: [presenterId], references: [id])
}
@@ -29,7 +30,7 @@ model Participant {
eventId Int
// relations:
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
Event Event @relation(fields: [eventId], references: [id])
Event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
EventAppointment EventAppointment? @relation(fields: [eventAppointmentId], references: [id])
}
@@ -48,8 +49,8 @@ model Event {
hidden Boolean @default(true)
// relations:
participants Participant[]
appointments EventAppointment[]
Participants Participant[]
Appointments EventAppointment[]
}
model File {

View File

@@ -33,6 +33,7 @@ model User {
moodleId Int? @map(name: "moodle_id")
// Settings:
pathSelected Boolean @default(false)
settingsNtfyRoom String? @map(name: "settings_ntfy_room")
settingsMicDevice String? @map(name: "settings_mic_device")
settingsMicVolume Float? @map(name: "settings_mic_volume")

2
pnpm-lock.yaml generated
View File

@@ -342,7 +342,7 @@ importers:
specifier: ^4.1.8
version: 4.1.8
'@tanstack/react-query':
specifier: ^5.79.0
specifier: ^5.79.2
version: 5.79.2(react@19.1.0)
'@tanstack/react-table':
specifier: ^8.21.3