continue Event page

This commit is contained in:
PxlLoewe
2025-02-17 10:39:29 +01:00
parent 8928455c3a
commit 30af30bd1d
13 changed files with 242 additions and 227 deletions

View File

@@ -1,13 +1,13 @@
import { prisma } from '@repo/db'; import { prisma } from '@repo/db';
import { StationForm } from '../_components/Form'; import { Form } from '../_components/Form';
export default async ({ params }: { params: Promise<{ id: string }> }) => { export default async ({ params }: { params: Promise<{ id: string }> }) => {
const { id } = await params; const { id } = await params;
const station = await prisma.station.findUnique({ const event = await prisma.event.findUnique({
where: { where: {
id: parseInt(id), id: parseInt(id),
}, },
}); });
if (!station) return <div>Station not found</div>; if (!event) return <div>Event not found</div>;
return <StationForm station={station} />; return <Form event={event} />;
}; };

View File

@@ -1,20 +1,22 @@
'use client'; 'use client';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { StationOptionalDefaultsSchema } from '@repo/db/zod'; import { EventOptionalDefaultsSchema } from '@repo/db/zod';
import { set, useForm } from 'react-hook-form'; import { set, useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { BosUse, Country, Station } from '@repo/db'; import { BosUse, Country, Event, prisma } from '@repo/db';
import { FileText, LocateIcon, PlaneIcon } 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 { useState } from 'react';
import { deleteStation, upsertStation } from '../action'; import { deleteEvent, upsertEvent } from '../action';
import { Button } from '../../../../_components/ui/Button'; import { Button } from '../../../../_components/ui/Button';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { Switch } from '../../../../_components/ui/Switch';
import { PaginatedTable } from '../../../../_components/PaginatedTable';
export const StationForm = ({ station }: { station?: Station }) => { export const Form = ({ event }: { event?: Event }) => {
const form = useForm<z.infer<typeof StationOptionalDefaultsSchema>>({ const form = useForm<z.infer<typeof EventOptionalDefaultsSchema>>({
resolver: zodResolver(StationOptionalDefaultsSchema), resolver: zodResolver(EventOptionalDefaultsSchema),
defaultValues: station, defaultValues: event,
}); });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false); const [deleteLoading, setDeleteLoading] = useState(false);
@@ -24,9 +26,9 @@ export const StationForm = ({ station }: { station?: Station }) => {
<form <form
onSubmit={form.handleSubmit(async (values) => { onSubmit={form.handleSubmit(async (values) => {
setLoading(true); setLoading(true);
const createdStation = await upsertStation(values, station?.id); const createdEvent = await upsertEvent(values, event?.id);
setLoading(false); setLoading(false);
if (!station) redirect(`/admin/station`); if (!event) redirect(`/admin/event`);
})} })}
className="grid grid-cols-6 gap-3" className="grid grid-cols-6 gap-3"
> >
@@ -35,182 +37,50 @@ export const StationForm = ({ station }: { station?: Station }) => {
<h2 className="card-title"> <h2 className="card-title">
<FileText className="w-5 h-5" /> Allgemeines <FileText className="w-5 h-5" /> Allgemeines
</h2> </h2>
<Input form={form} label="Name" name="name" className="input-sm" />
<Input <Input
form={form} form={form}
label="BOS Rufname" label="Beschreibung"
name="bosCallsign" name="description"
className="input-sm" className="input-sm"
/> />
<Input <Switch form={form} name="hidden" label="Versteckt" />
form={form}
label="BOS Rufname (kurz)"
name="bosCallsignShort"
className="input-sm"
/>
<Input
form={form}
label="Betreiber"
name="operator"
className="input-sm"
/>
<Input
form={form}
label="ATC Rufname"
name="atcCallsign"
className="input-sm"
/>
<Input
form={form}
label="FIR (Flight Information Region)"
name="fir"
className="input-sm"
/>
<Input
form={form}
label="Leitstelle Rufname"
name="bosRadioArea"
className="input-sm"
/>
<label className="form-control w-full">
<span className="label-text text-lg flex items-center gap-2">
BOS Nutzung
</span>
<select
className="input-sm select select-bordered select-sm"
{...form.register('bosUse')}
>
{Object.keys(BosUse).map((use) => (
<option key={use} value={use}>
{use}
</option>
))}
</select>
</label>
</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">
<LocateIcon className="w-5 h-5" /> Standort + Ausrüstung <UserIcon className="w-5 h-5" /> Teilnehmer
</h2> </h2>
<label className="form-control w-full">
<span className="label-text text-lg flex items-center gap-2">
Land
</span>
<select
className="input-sm select select-bordered select-sm"
{...form.register('country', {})}
>
{Object.keys(Country).map((use) => (
<option key={use} value={use}>
{use}
</option>
))}
</select>
</label>
<Input
form={form}
label="Bundesland"
name="locationState"
className="input-sm"
/>
<Input
form={form}
label="Bundesland (kurz)"
name="locationStateShort"
className="input-sm"
/>
<span className="label-text text-lg flex items-center gap-2">
Ausgerüstet mit:
</span>
<div className="form-control">
<label className="label cursor-pointer">
<span>Winde</span>
<input
type="checkbox"
className="toggle"
{...form.register('hasWinch')}
/>
</label>
<label className="label cursor-pointer">
<span>Nachtsicht Gerät</span>
<input
type="checkbox"
className="toggle"
{...form.register('hasNvg')}
/>
</label>
<label className="label cursor-pointer">
<span>24-Stunden Einsatzfähig</span>
<input
type="checkbox"
className="toggle"
{...form.register('is24h')}
/>
</label>
<label className="label cursor-pointer">
<span>Bergetau</span>
<input
type="checkbox"
className="toggle"
{...form.register('hasRope')}
/>
</label>
</div>
<Input <PaginatedTable
form={form} prismaModel={'participant'}
label="Breitengrad" filter={{
name="latitude" eventId: event?.id,
className="input-sm" }}
formOptions={{ valueAsNumber: true }} include={[
type="number" {
step="any" user: true,
/> },
<Input ]}
form={form} columns={[
label="Längengrad" {
name="longitude" header: 'Vorname',
className="input-sm" accessorKey: 'user.firstName',
formOptions={{ valueAsNumber: true }} },
type="number" {
step="any" header: 'Nachname',
/> accessorKey: 'user.lastname',
<label className="label cursor-pointer"> },
<span className="text-lg">Reichweiten ausblenden</span> {
<input header: 'VAR ID',
type="checkbox" accessorKey: 'user.publicId',
className="toggle" },
{...form.register('hideRangeRings')} {
/> header: 'Status',
</label> accessorKey: 'status',
</div> },
</div> ]}
<div className="card bg-base-200 shadow-xl col-span-2 max-xl:col-span-6">
<div className="card-body">
<h2 className="card-title">
<PlaneIcon className="w-5 h-5" /> Hubschrauber
</h2>
<Input
form={form}
label="Hubschrauber Typ"
name="aircraft"
className="input-sm"
/>
<Input
form={form}
formOptions={{ valueAsNumber: true }}
type="number"
label="Hubschrauber Geschwindigkeit"
className="input-sm"
name="aircraftSpeed"
/>
<Input
form={form}
label="Hubschrauber Registrierung"
name="aircraftRegistration"
className="input-sm"
/> />
</div> </div>
</div> </div>
@@ -224,13 +94,13 @@ export const StationForm = ({ station }: { station?: Station }) => {
> >
Speichern Speichern
</Button> </Button>
{station && ( {event && (
<Button <Button
isLoading={deleteLoading} isLoading={deleteLoading}
onClick={async () => { onClick={async () => {
setDeleteLoading(true); setDeleteLoading(true);
await deleteStation(station.id); await deleteEvent(event.id);
redirect('/admin/station'); redirect('/admin/event');
}} }}
className="btn btn-error" className="btn btn-error"
> >

View File

@@ -1,20 +1,20 @@
'use server'; 'use server';
import { prisma, Prisma, Station } from '@repo/db'; import { prisma, Prisma, Event } from '@repo/db';
export const upsertStation = async ( export const upsertEvent = async (
station: Prisma.StationCreateInput, event: Prisma.EventCreateInput,
id?: Station['id'] id?: Event['id']
) => { ) => {
const newStation = id const newEvent = id
? await prisma.station.update({ ? await prisma.event.update({
where: { id: id }, where: { id: id },
data: station, data: event,
}) })
: await prisma.station.create({ data: station }); : await prisma.event.create({ data: event });
return newStation; return newEvent;
}; };
export const deleteStation = async (id: Station['id']) => { export const deleteEvent = async (id: Event['id']) => {
await prisma.station.delete({ where: { id: id } }); await prisma.event.delete({ where: { id: id } });
}; };

View File

@@ -1,5 +1,5 @@
import { StationForm } from '../_components/Form'; import { Form } from '../_components/Form';
export default () => { export default () => {
return <StationForm />; return <Form />;
}; };

View File

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

View File

@@ -1,28 +1,32 @@
"use client"; 'use client';
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } 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';
interface PaginatedTableProps<TData> interface PaginatedTableProps<TData>
extends Omit<SortableTableProps<TData>, "data"> { extends Omit<SortableTableProps<TData>, 'data'> {
prismaModel: keyof PrismaClient; prismaModel: keyof PrismaClient;
filter?: Record<string, any>;
rowsPerPage?: number; rowsPerPage?: number;
showEditButton?: boolean; showEditButton?: boolean;
searchFields: string[]; searchFields?: string[];
include?: Record<string, boolean>[];
} }
export function PaginatedTable<TData>({ export function PaginatedTable<TData>({
prismaModel, prismaModel,
rowsPerPage = 10, rowsPerPage = 10,
showEditButton = false, showEditButton = false,
searchFields, searchFields = [],
filter,
include,
...restProps ...restProps
}: PaginatedTableProps<TData>) { }: PaginatedTableProps<TData>) {
const [data, setData] = useState<TData[]>([]); const [data, setData] = useState<TData[]>([]);
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState('');
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(searchTerm); const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(searchTerm);
const debounce = (func: Function, delay: number) => { const debounce = (func: Function, delay: number) => {
@@ -46,7 +50,8 @@ export function PaginatedTable<TData>({
rowsPerPage, rowsPerPage,
page * rowsPerPage, page * rowsPerPage,
debouncedSearchTerm, debouncedSearchTerm,
searchFields searchFields,
filter
).then((result) => { ).then((result) => {
if (result) { if (result) {
setData(result.data); setData(result.data);

View File

@@ -1,5 +1,5 @@
"use server"; 'use server';
import { PrismaClient } from "@repo/db"; import { PrismaClient } from '@repo/db';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@@ -8,7 +8,9 @@ export async function getData(
limit: number, limit: number,
offset: number, offset: number,
searchTerm: string, searchTerm: string,
searchFields: string[] searchFields: string[],
filter?: Record<string, any>,
include?: Record<string, boolean>[]
) { ) {
if (!model || !prisma[model]) { if (!model || !prisma[model]) {
return { data: [], total: 0 }; return { data: [], total: 0 };
@@ -24,8 +26,9 @@ export async function getData(
[field]: { contains: searchTerm }, [field]: { contains: searchTerm },
})), })),
].filter(Boolean), ].filter(Boolean),
...filter,
} }
: {}; : { ...filter };
if (!prisma[model]) { if (!prisma[model]) {
return { data: [], total: 0 }; return { data: [], total: 0 };
@@ -35,6 +38,7 @@ export async function getData(
where, where,
take: limit, take: limit,
skip: offset, skip: offset,
include,
}); });
const total = await (prisma[model] as any).count({ where }); const total = await (prisma[model] as any).count({ where });

View File

@@ -4,8 +4,8 @@ import {
GearIcon, GearIcon,
ExitIcon, ExitIcon,
LockClosedIcon, LockClosedIcon,
} from "@radix-ui/react-icons"; } from '@radix-ui/react-icons';
import Link from "next/link"; import Link from 'next/link';
export const VerticalNav = () => { export const VerticalNav = () => {
return ( return (
@@ -34,6 +34,9 @@ export const VerticalNav = () => {
<li> <li>
<Link href="/admin/station">Stationen</Link> <Link href="/admin/station">Stationen</Link>
</li> </li>
<li>
<Link href="/admin/event">Events</Link>
</li>
</ul> </ul>
</details> </details>
</li> </li>

View File

@@ -0,0 +1,33 @@
import {
FieldValues,
Path,
RegisterOptions,
UseFormReturn,
} from 'react-hook-form';
import { cn } from '../../../helper/cn';
interface InputProps<T extends FieldValues>
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'form'> {
name: Path<T>;
form: UseFormReturn<T>;
formOptions?: RegisterOptions<T>;
label?: string;
}
export const Switch = <T extends FieldValues>({
name,
label = name,
form,
formOptions,
className,
...inputProps
}: InputProps<T>) => {
return (
<div className="form-control">
<label className="label cursor-pointer">
<span className={cn('label-text', className)}>{label}</span>
<input type="checkbox" className="toggle" {...form.register(name)} />
</label>
</div>
);
};

View File

@@ -0,0 +1,46 @@
-- CreateEnum
CREATE TYPE "PARTICIPANT_STATUS" AS ENUM ('WAITING_FOR_ENTRY_TEST', 'ENTRY_TEST_FAILED', 'READY_FOR_EVENT', 'PARTICIPATED', 'WAITING_FOR_EXIT_TEST', 'EXIT_TEST_FAILED', 'WAITING_FOR_PERMISISONS', 'FINISHED', 'WAVED');
-- CreateTable
CREATE TABLE "Participant" (
"id" SERIAL NOT NULL,
"user_id" TEXT NOT NULL,
"status" "PARTICIPANT_STATUS" NOT NULL,
"selectedForParticipatioon" BOOLEAN NOT NULL DEFAULT false,
"statusLog" JSONB[],
"eventId" INTEGER,
CONSTRAINT "Participant_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Event" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"discordRoleId" TEXT,
"hasPresenceEvents" BOOLEAN NOT NULL DEFAULT false,
"maxParticipants" INTEGER NOT NULL,
"starterMoodleCourseId" INTEGER,
"finisherMoodleCourseId" INTEGER,
"finished" BOOLEAN NOT NULL DEFAULT false,
"finishedBadges" TEXT[],
"requiredBadges" TEXT[],
"finishedPermissions" TEXT[],
"hidden" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "Event_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "File" (
"id" SERIAL NOT NULL,
CONSTRAINT "File_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Participant" ADD CONSTRAINT "Participant_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Participant" ADD CONSTRAINT "Participant_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,14 @@
/*
Warnings:
- You are about to drop the column `finished` on the `Event` table. All the data in the column will be lost.
- Made the column `description` on table `Event` required. This step will fail if there are existing NULL values in that column.
*/
-- AlterTable
ALTER TABLE "Event" DROP COLUMN "finished",
ALTER COLUMN "description" SET NOT NULL,
ALTER COLUMN "maxParticipants" DROP NOT NULL,
ALTER COLUMN "finishedBadges" SET DEFAULT ARRAY[]::TEXT[],
ALTER COLUMN "requiredBadges" SET DEFAULT ARRAY[]::TEXT[],
ALTER COLUMN "finishedPermissions" SET DEFAULT ARRAY[]::TEXT[];

View File

@@ -0,0 +1,47 @@
enum PARTICIPANT_STATUS {
WAITING_FOR_ENTRY_TEST
ENTRY_TEST_FAILED
READY_FOR_EVENT
PARTICIPATED
WAITING_FOR_EXIT_TEST
EXIT_TEST_FAILED
WAITING_FOR_PERMISISONS
FINISHED
WAVED
}
model Participant {
id Int @id @default(autoincrement())
userId String @map(name: "user_id")
status PARTICIPANT_STATUS
selectedForParticipatioon Boolean @default(false)
statusLog Json[]
eventId Int
// relations:
user User @relation(fields: [userId], references: [id])
Event Event? @relation(fields: [eventId], references: [id])
}
model Event {
id Int @id @default(autoincrement())
name String
description String
discordRoleId String?
hasPresenceEvents Boolean @default(false)
maxParticipants Int? @default(0)
starterMoodleCourseId Int?
finisherMoodleCourseId Int?
finishedBadges String[] @default([])
requiredBadges String[] @default([])
finishedPermissions String[] @default([])
hidden Boolean @default(true)
// relations:
participants Participant[]
}
model File {
id Int @id @default(autoincrement())
// Weitere Felder für das File-Modell
}

View File

@@ -14,6 +14,7 @@ model User {
// relations: // relations:
oauthTokens OAuthToken[] oauthTokens OAuthToken[]
discordAccounts DiscordAccount[] discordAccounts DiscordAccount[]
participants Participant[]
@@map(name: "users") @@map(name: "users")
} }