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 { StationForm } from '../_components/Form';
import { Form } from '../_components/Form';
export default async ({ params }: { params: Promise<{ id: string }> }) => {
const { id } = await params;
const station = await prisma.station.findUnique({
const event = await prisma.event.findUnique({
where: {
id: parseInt(id),
},
});
if (!station) return <div>Station not found</div>;
return <StationForm station={station} />;
if (!event) return <div>Event not found</div>;
return <Form event={event} />;
};

View File

@@ -1,20 +1,22 @@
'use client';
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 { z } from 'zod';
import { BosUse, Country, Station } from '@repo/db';
import { FileText, LocateIcon, PlaneIcon } from 'lucide-react';
import { BosUse, Country, Event, prisma } from '@repo/db';
import { FileText, LocateIcon, PlaneIcon, UserIcon } from 'lucide-react';
import { Input } from '../../../../_components/ui/Input';
import { useState } from 'react';
import { deleteStation, upsertStation } from '../action';
import { deleteEvent, upsertEvent } from '../action';
import { Button } from '../../../../_components/ui/Button';
import { redirect } from 'next/navigation';
import { Switch } from '../../../../_components/ui/Switch';
import { PaginatedTable } from '../../../../_components/PaginatedTable';
export const StationForm = ({ station }: { station?: Station }) => {
const form = useForm<z.infer<typeof StationOptionalDefaultsSchema>>({
resolver: zodResolver(StationOptionalDefaultsSchema),
defaultValues: station,
export const Form = ({ event }: { event?: Event }) => {
const form = useForm<z.infer<typeof EventOptionalDefaultsSchema>>({
resolver: zodResolver(EventOptionalDefaultsSchema),
defaultValues: event,
});
const [loading, setLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
@@ -24,9 +26,9 @@ export const StationForm = ({ station }: { station?: Station }) => {
<form
onSubmit={form.handleSubmit(async (values) => {
setLoading(true);
const createdStation = await upsertStation(values, station?.id);
const createdEvent = await upsertEvent(values, event?.id);
setLoading(false);
if (!station) redirect(`/admin/station`);
if (!event) redirect(`/admin/event`);
})}
className="grid grid-cols-6 gap-3"
>
@@ -35,182 +37,50 @@ export const StationForm = ({ station }: { station?: Station }) => {
<h2 className="card-title">
<FileText className="w-5 h-5" /> Allgemeines
</h2>
<Input form={form} label="Name" name="name" className="input-sm" />
<Input
form={form}
label="BOS Rufname"
name="bosCallsign"
label="Beschreibung"
name="description"
className="input-sm"
/>
<Input
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>
<Switch form={form} name="hidden" label="Versteckt" />
</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">
<LocateIcon className="w-5 h-5" /> Standort + Ausrüstung
<UserIcon className="w-5 h-5" /> Teilnehmer
</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
form={form}
label="Breitengrad"
name="latitude"
className="input-sm"
formOptions={{ valueAsNumber: true }}
type="number"
step="any"
/>
<Input
form={form}
label="Längengrad"
name="longitude"
className="input-sm"
formOptions={{ valueAsNumber: true }}
type="number"
step="any"
/>
<label className="label cursor-pointer">
<span className="text-lg">Reichweiten ausblenden</span>
<input
type="checkbox"
className="toggle"
{...form.register('hideRangeRings')}
/>
</label>
</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"
<PaginatedTable
prismaModel={'participant'}
filter={{
eventId: event?.id,
}}
include={[
{
user: true,
},
]}
columns={[
{
header: 'Vorname',
accessorKey: 'user.firstName',
},
{
header: 'Nachname',
accessorKey: 'user.lastname',
},
{
header: 'VAR ID',
accessorKey: 'user.publicId',
},
{
header: 'Status',
accessorKey: 'status',
},
]}
/>
</div>
</div>
@@ -224,13 +94,13 @@ export const StationForm = ({ station }: { station?: Station }) => {
>
Speichern
</Button>
{station && (
{event && (
<Button
isLoading={deleteLoading}
onClick={async () => {
setDeleteLoading(true);
await deleteStation(station.id);
redirect('/admin/station');
await deleteEvent(event.id);
redirect('/admin/event');
}}
className="btn btn-error"
>

View File

@@ -1,20 +1,20 @@
'use server';
import { prisma, Prisma, Station } from '@repo/db';
import { prisma, Prisma, Event } from '@repo/db';
export const upsertStation = async (
station: Prisma.StationCreateInput,
id?: Station['id']
export const upsertEvent = async (
event: Prisma.EventCreateInput,
id?: Event['id']
) => {
const newStation = id
? await prisma.station.update({
const newEvent = id
? await prisma.event.update({
where: { id: id },
data: station,
data: event,
})
: await prisma.station.create({ data: station });
return newStation;
: await prisma.event.create({ data: event });
return newEvent;
};
export const deleteStation = async (id: Station['id']) => {
await prisma.station.delete({ where: { id: id } });
export const deleteEvent = async (id: Event['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 () => {
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 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">
<span className="flex items-center gap-2">
<DatabaseBackupIcon className="w-5 h-5" /> Stationen
<PartyPopperIcon className="w-5 h-5" /> Events
</span>
<Link href={'/admin/station/new'}>
<Link href={'/admin/event/new'}>
<button className="btn btn-sm btn-outline btn-primary">
Erstellen
</button>
@@ -17,23 +17,15 @@ export default () => {
</p>
<PaginatedTable
showEditButton
prismaModel="station"
prismaModel="event"
columns={[
{
header: 'BOS Name',
accessorKey: 'bosCallsign',
header: 'Name',
accessorKey: 'name',
},
{
header: 'Bos Use',
accessorKey: 'bosUse',
},
{
header: 'Country',
accessorKey: 'country',
},
{
header: 'operator',
accessorKey: 'operator',
header: 'Versteckt',
accessorKey: 'hidden',
},
]}
/>

View File

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

View File

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

View File

@@ -4,8 +4,8 @@ import {
GearIcon,
ExitIcon,
LockClosedIcon,
} from "@radix-ui/react-icons";
import Link from "next/link";
} from '@radix-ui/react-icons';
import Link from 'next/link';
export const VerticalNav = () => {
return (
@@ -34,6 +34,9 @@ export const VerticalNav = () => {
<li>
<Link href="/admin/station">Stationen</Link>
</li>
<li>
<Link href="/admin/event">Events</Link>
</li>
</ul>
</details>
</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:
oauthTokens OAuthToken[]
discordAccounts DiscordAccount[]
participants Participant[]
@@map(name: "users")
}