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

View File

@@ -1,9 +1,100 @@
"use client"; "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 { useRef, useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { getEvents } from "../../../helper/events";
import { EventCard } from "(app)/events/_components/item";
const PathsOptions = ({
selected,
setSelected,
}: {
selected: "disponent" | "pilot" | null;
setSelected: (value: "disponent" | "pilot") => void;
}) => {
return (
<>
<div className="flex gap-6">
{/* Disponent Card */}
<div
className={`cursor-pointer border rounded-lg p-6 w-80 transition-colors ${
selected === "disponent" ? "border-info ring-2 ring-info" : "border-base-300"
}`}
onClick={() => setSelected("disponent")}
role="button"
tabIndex={0}
aria-pressed={selected === "disponent"}
>
<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.
<div className="badge badge-sm badge-secondary mt-3">
Teilnahme an Einführungsevent Nötig
</div>
</div>
</div>
{/* Pilot Card */}
<div
className={`cursor-pointer border rounded-lg p-6 w-80 transition-colors ${
selected === "pilot" ? "border-info ring-2 ring-info" : "border-base-300"
}`}
onClick={() => setSelected("pilot")}
role="button"
tabIndex={0}
aria-pressed={selected === "pilot"}
>
<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.
</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 = () => { export const FirstPath = () => {
const modalRef = useRef<HTMLDialogElement>(null); const modalRef = useRef<HTMLDialogElement>(null);
const { data: session } = useSession();
const [selected, setSelected] = useState<"disponent" | "pilot" | null>(null); const [selected, setSelected] = useState<"disponent" | "pilot" | null>(null);
const [page, setPage] = useState<"path" | "event-select">("path");
useEffect(() => { useEffect(() => {
if (modalRef.current && !modalRef.current.open) { if (modalRef.current && !modalRef.current.open) {
@@ -17,59 +108,40 @@ export const FirstPath = () => {
<h3 className="flex items-center gap-2 text-lg font-bold mb-10">Wähle deinen Einstieg!</h3> <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"> <p className="mb-8 text-base text-base-content/80 text-center">
Willkommen bei Virtual Air Rescue! Willkommen bei Virtual Air Rescue!
<br /> Wähle deinen ersten Pfad aus. Du kannst später jederzeit auch den anderen Pfad <br /> Wie möchtest du bei uns starten? Du kannst später jederzeit auch den anderen Pfad
ausprobieren, wenn du möchtest. ausprobieren, wenn du möchtest.
</p> </p>
<div className="flex flex-col items-center justify-center m-20"> <div className="flex flex-col items-center justify-center m-20">
<div className="flex gap-6"> {page === "path" && <PathsOptions selected={selected} setSelected={setSelected} />}
{/* Disponent Card */} {page === "event-select" && (
<div <div className="flex flex-col gap-3 min-w-[800px]">
className={`cursor-pointer border rounded-lg p-6 w-80 transition-colors ${ <div>
selected === "disponent" ? "border-info ring-2 ring-info" : "border-base-300" <p className="text-left text-gray-400 text-sm">Wähle dein Einführungs-Event aus:</p>
}`}
onClick={() => setSelected("disponent")}
role="button"
tabIndex={0}
aria-pressed={selected === "disponent"}
>
<div className="font-semibold text-lg mb-2">Disponent</div>
<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.
<div className="badge badge-sm badge-secondary mt-3">
Teilnahme an Einführungsevent Nötig
</div>
</div> </div>
<EventSelect pathSelected={selected!} />
</div> </div>
{/* Pilot Card */} )}
<div
className={`cursor-pointer border rounded-lg p-6 w-80 transition-colors ${
selected === "pilot" ? "border-info ring-2 ring-info" : "border-base-300"
}`}
onClick={() => setSelected("pilot")}
role="button"
tabIndex={0}
aria-pressed={selected === "pilot"}
>
<div className="font-semibold text-lg mb-2">Pilot</div>
<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.
</div>
</div>
</div>
</div> </div>
<div className="modal-action"> <div className="modal-action">
<button <button className="btn" disabled={page === "path"} onClick={() => setPage("path")}>
Zurück
</button>
<Button
className="btn btn-info" className="btn btn-info"
disabled={!selected} 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 {page === "path" ? "Weiter" : "Pfad auswählen"}
</button> </Button>
</div> </div>
</div> </div>
</dialog> </dialog>

View File

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

View File

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

View File

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

View File

@@ -46,7 +46,7 @@ export default async function RootLayout({
<EmailVerification /> <EmailVerification />
</div> </div>
)} )}
<FirstPath /> {!session.user.pathSelected && <FirstPath />}
{children} {children}
</div> </div>
</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 { CustomErrorBoundary } from "_components/ErrorBoundary";
import { Toaster } from "react-hot-toast"; import { Toaster } from "react-hot-toast";
import "./globals.css"; import "./globals.css";
import { QueryProvider } from "_components/QueryClient";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -33,7 +34,9 @@ const RootLayout = async ({
reverseOrder={false} reverseOrder={false}
/> />
</div> </div>
<CustomErrorBoundary>{children}</CustomErrorBoundary> <QueryProvider>
<CustomErrorBoundary>{children}</CustomErrorBoundary>
</QueryProvider>
</body> </body>
</NextAuthSessionProvider> </NextAuthSessionProvider>
</html> </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 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) => { export const eventCompleted = (event: Event, participant?: Participant) => {
if (!participant) return false; if (!participant) return false;

View File

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

View File

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

View File

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

2
pnpm-lock.yaml generated
View File

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