formated project with prettier
This commit is contained in:
@@ -1,26 +1,13 @@
|
||||
import { prisma } from '@repo/db';
|
||||
import { Form } from '../_components/Form';
|
||||
import { prisma } from "@repo/db";
|
||||
import { Form } from "../_components/Form";
|
||||
|
||||
export default async ({ params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await params;
|
||||
const event = await prisma.event.findUnique({
|
||||
where: {
|
||||
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>;
|
||||
return <Form event={event} users={users} appointments={appointments} />;
|
||||
const { id } = await params;
|
||||
const event = await prisma.event.findUnique({
|
||||
where: {
|
||||
id: parseInt(id),
|
||||
},
|
||||
});
|
||||
if (!event) return <div>Event not found</div>;
|
||||
return <Form event={event} />;
|
||||
};
|
||||
|
||||
@@ -1,277 +1,264 @@
|
||||
'use client';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
"use client";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
EventAppointmentOptionalDefaults,
|
||||
EventAppointmentOptionalDefaultsSchema,
|
||||
EventOptionalDefaults,
|
||||
EventOptionalDefaultsSchema,
|
||||
ParticipantOptionalDefaultsSchema,
|
||||
} from '@repo/db/zod';
|
||||
import { set, useForm } from 'react-hook-form';
|
||||
import { BADGES, Event, EventAppointment, User } from '@repo/db';
|
||||
import { Bot, Calendar, FileText, UserIcon } from 'lucide-react';
|
||||
import { Input } from '../../../../_components/ui/Input';
|
||||
import { useRef, useState } from 'react';
|
||||
import { deleteEvent, upsertAppointment, upsertEvent } from '../action';
|
||||
import { Button } from '../../../../_components/ui/Button';
|
||||
import { redirect, useRouter } from 'next/navigation';
|
||||
import { Switch } from '../../../../_components/ui/Switch';
|
||||
EventAppointmentOptionalDefaults,
|
||||
EventAppointmentOptionalDefaultsSchema,
|
||||
EventOptionalDefaults,
|
||||
EventOptionalDefaultsSchema,
|
||||
ParticipantOptionalDefaultsSchema,
|
||||
} from "@repo/db/zod";
|
||||
import { set, useForm } from "react-hook-form";
|
||||
import { BADGES, Event, EventAppointment, User } from "@repo/db";
|
||||
import { Bot, Calendar, FileText, UserIcon } from "lucide-react";
|
||||
import { Input } from "../../../../_components/ui/Input";
|
||||
import { useRef, useState } from "react";
|
||||
import { deleteEvent, upsertAppointment, upsertEvent } from "../action";
|
||||
import { Button } from "../../../../_components/ui/Button";
|
||||
import { redirect, useRouter } from "next/navigation";
|
||||
import { Switch } from "../../../../_components/ui/Switch";
|
||||
import {
|
||||
PaginatedTable,
|
||||
PaginatedTableRef,
|
||||
} from '../../../../_components/PaginatedTable';
|
||||
import { Select } from '../../../../_components/ui/Select';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { MarkdownEditor } from '../../../../_components/ui/MDEditor';
|
||||
PaginatedTable,
|
||||
PaginatedTableRef,
|
||||
} from "../../../../_components/PaginatedTable";
|
||||
import { Select } from "../../../../_components/ui/Select";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { MarkdownEditor } from "../../../../_components/ui/MDEditor";
|
||||
|
||||
export const Form = ({
|
||||
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),
|
||||
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 [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const addParticipantModal = useRef<HTMLDialogElement>(null);
|
||||
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
|
||||
onSubmit={form.handleSubmit(async (values) => {
|
||||
setLoading(true);
|
||||
const createdEvent = await upsertEvent(values, event?.id);
|
||||
setLoading(false);
|
||||
if (!event) redirect(`/admin/event`);
|
||||
})}
|
||||
className="grid grid-cols-6 gap-3"
|
||||
>
|
||||
<div className="card bg-base-200 shadow-xl col-span-2 max-xl:col-span-6">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title">
|
||||
<FileText className="w-5 h-5" /> Allgemeines
|
||||
</h2>
|
||||
<Input form={form} label="Name" name="name" className="input-sm" />
|
||||
<MarkdownEditor form={form} name="description" />
|
||||
<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" />
|
||||
</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">
|
||||
<Bot className="w-5 h-5" /> Automation
|
||||
</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>
|
||||
{event && (
|
||||
<button
|
||||
className="btn btn-primary btn-outline"
|
||||
onClick={() => addParticipantModal.current?.showModal()}
|
||||
>
|
||||
Hinzufügen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
export const Form = ({ event }: { event?: Event }) => {
|
||||
const { data: session } = useSession();
|
||||
const form = useForm({
|
||||
resolver: zodResolver(EventOptionalDefaultsSchema),
|
||||
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 [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const addParticipantModal = useRef<HTMLDialogElement>(null);
|
||||
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
|
||||
onSubmit={form.handleSubmit(async (values) => {
|
||||
setLoading(true);
|
||||
const createdEvent = await upsertEvent(values, event?.id);
|
||||
setLoading(false);
|
||||
if (!event) redirect(`/admin/event`);
|
||||
})}
|
||||
className="grid grid-cols-6 gap-3"
|
||||
>
|
||||
<div className="card bg-base-200 shadow-xl col-span-3 max-xl:col-span-6">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title">
|
||||
<FileText className="w-5 h-5" /> Allgemeines
|
||||
</h2>
|
||||
<Input form={form} label="Name" name="name" className="input-sm" />
|
||||
<MarkdownEditor form={form} name="description" />
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="card bg-base-200 shadow-xl col-span-3 max-xl:col-span-6">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title">
|
||||
<Bot className="w-5 h-5" /> Automation
|
||||
</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-6">
|
||||
<div className="card-body">
|
||||
<div className="flex justify-between">
|
||||
<h2 className="card-title">
|
||||
<Calendar className="w-5 h-5" /> Termine
|
||||
</h2>
|
||||
{event && (
|
||||
<button
|
||||
className="btn btn-primary btn-outline"
|
||||
onClick={() => addParticipantModal.current?.showModal()}
|
||||
>
|
||||
Hinzufügen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PaginatedTable
|
||||
ref={appointmentsTableRef}
|
||||
prismaModel={'eventAppointment'}
|
||||
filter={{
|
||||
eventId: event?.id,
|
||||
}}
|
||||
include={{
|
||||
Presenter: true,
|
||||
Participants: true,
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
header: 'Datum',
|
||||
accessorKey: 'appointmentDate',
|
||||
accessorFn: (date) =>
|
||||
new Date(date.appointmentDate).toLocaleDateString(),
|
||||
},
|
||||
{
|
||||
header: 'Presenter',
|
||||
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: 'Teilnehmer',
|
||||
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: 'Aktionen',
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onSubmit={() => false}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
console.log(row.original);
|
||||
// TODO: open modal to edit appointment
|
||||
}}
|
||||
className="btn btn-sm btn-outline"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card bg-base-200 shadow-xl col-span-6">
|
||||
<div className="card-body ">
|
||||
<div className="flex w-full gap-4">
|
||||
<Button
|
||||
isLoading={loading}
|
||||
type="submit"
|
||||
className="btn btn-primary flex-1"
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
{event && (
|
||||
<Button
|
||||
isLoading={deleteLoading}
|
||||
onClick={async () => {
|
||||
setDeleteLoading(true);
|
||||
await deleteEvent(event.id);
|
||||
redirect('/admin/event');
|
||||
}}
|
||||
className="btn btn-error"
|
||||
>
|
||||
Löschen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
<PaginatedTable
|
||||
ref={appointmentsTableRef}
|
||||
prismaModel={"eventAppointment"}
|
||||
filter={{
|
||||
eventId: event?.id,
|
||||
}}
|
||||
include={{
|
||||
Presenter: true,
|
||||
Participants: true,
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
header: "Datum",
|
||||
accessorKey: "appointmentDate",
|
||||
accessorFn: (date) =>
|
||||
new Date(date.appointmentDate).toLocaleDateString(),
|
||||
},
|
||||
{
|
||||
header: "Presenter",
|
||||
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: "Teilnehmer",
|
||||
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: "Aktionen",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onSubmit={() => false}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
console.log(row.original);
|
||||
// TODO: open modal to edit appointment
|
||||
}}
|
||||
className="btn btn-sm btn-outline"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card bg-base-200 shadow-xl col-span-6">
|
||||
<div className="card-body ">
|
||||
<div className="flex w-full gap-4">
|
||||
<Button
|
||||
isLoading={loading}
|
||||
type="submit"
|
||||
className="btn btn-primary flex-1"
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
{event && (
|
||||
<Button
|
||||
isLoading={deleteLoading}
|
||||
onClick={async () => {
|
||||
setDeleteLoading(true);
|
||||
await deleteEvent(event.id);
|
||||
redirect("/admin/event");
|
||||
}}
|
||||
className="btn btn-error"
|
||||
>
|
||||
Löschen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import { prisma } from '@repo/db';
|
||||
import { Form } from '../_components/Form';
|
||||
import { prisma } from "@repo/db";
|
||||
import { Form } from "../_components/Form";
|
||||
|
||||
export default async () => {
|
||||
const users = await prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
firstname: true,
|
||||
lastname: true,
|
||||
publicId: true,
|
||||
},
|
||||
});
|
||||
return <Form users={users} />;
|
||||
return <Form />;
|
||||
};
|
||||
|
||||
@@ -1,107 +1,107 @@
|
||||
'use client';
|
||||
import { DrawingPinFilledIcon, EnterIcon } from '@radix-ui/react-icons';
|
||||
import { Event, User } from '@repo/db';
|
||||
import ModalBtn from './modalBtn';
|
||||
import MDEditor from '@uiw/react-md-editor';
|
||||
"use client";
|
||||
import { DrawingPinFilledIcon, EnterIcon } from "@radix-ui/react-icons";
|
||||
import { Event, User } from "@repo/db";
|
||||
import ModalBtn from "./modalBtn";
|
||||
import MDEditor from "@uiw/react-md-editor";
|
||||
|
||||
export const KursItem = ({ user, event }: { user: User; event: Event }) => {
|
||||
return (
|
||||
<div className="col-span-full">
|
||||
<div className="card bg-base-200 shadow-xl mb-4">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title">{event.name}</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">
|
||||
<div className="text-left text-balance">
|
||||
<MDEditor.Markdown
|
||||
source={event.description}
|
||||
className="whitespace-pre-wrap"
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2">{event.finishedBadges}</div>
|
||||
</div>
|
||||
<div className="card-actions flex justify-between items-center mt-5">
|
||||
<div>
|
||||
<p className="text-gray-600 text-left flex items-center gap-2">
|
||||
<DrawingPinFilledIcon /> <b>Teilnahmevoraussetzungen: </b>
|
||||
{(!event.starterMoodleCourseId ||
|
||||
!event.requiredBadges.length) &&
|
||||
'Keine'}
|
||||
{event.starterMoodleCourseId && (
|
||||
<a className="link link-info" href="">
|
||||
Moodle Kurs {event.starterMoodleCourseId}
|
||||
</a>
|
||||
)}
|
||||
</p>
|
||||
{!!event.requiredBadges.length && (
|
||||
<div className="flex ml-6">
|
||||
<b className="text-gray-600 text-left">Abzeichen:</b>
|
||||
<div className="flex gap-2">
|
||||
{event.requiredBadges.map((badge) => (
|
||||
<div className="badge badge-secondary badge-outline">
|
||||
{badge}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ModalBtn
|
||||
title={event.name}
|
||||
dates={['Dienstag, 25 Februar 2025', 'Mittwoch, 26 Februar 2025']}
|
||||
modalId={`${event.name}_modal.${event.id}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="col-span-full">
|
||||
<div className="card bg-base-200 shadow-xl mb-4">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title">{event.name}</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">
|
||||
<div className="text-left text-balance">
|
||||
<MDEditor.Markdown
|
||||
source={event.description}
|
||||
className="whitespace-pre-wrap"
|
||||
style={{
|
||||
backgroundColor: "transparent",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2">{event.finishedBadges}</div>
|
||||
</div>
|
||||
<div className="card-actions flex justify-between items-center mt-5">
|
||||
<div>
|
||||
<p className="text-gray-600 text-left flex items-center gap-2">
|
||||
<DrawingPinFilledIcon /> <b>Teilnahmevoraussetzungen: </b>
|
||||
{(!event.starterMoodleCourseId ||
|
||||
!event.requiredBadges.length) &&
|
||||
"Keine"}
|
||||
{event.starterMoodleCourseId && (
|
||||
<a className="link link-info" href="">
|
||||
Moodle Kurs {event.starterMoodleCourseId}
|
||||
</a>
|
||||
)}
|
||||
</p>
|
||||
{!!event.requiredBadges.length && (
|
||||
<div className="flex ml-6">
|
||||
<b className="text-gray-600 text-left">Abzeichen:</b>
|
||||
<div className="flex gap-2">
|
||||
{event.requiredBadges.map((badge) => (
|
||||
<div className="badge badge-secondary badge-outline">
|
||||
{badge}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ModalBtn
|
||||
title={event.name}
|
||||
dates={["Dienstag, 25 Februar 2025", "Mittwoch, 26 Februar 2025"]}
|
||||
modalId={`${event.name}_modal.${event.id}`}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
{
|
||||
/* 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Metadata } from "next";
|
||||
import {
|
||||
DiscordLogoIcon,
|
||||
InstagramLogoIcon,
|
||||
ReaderIcon,
|
||||
DiscordLogoIcon,
|
||||
InstagramLogoIcon,
|
||||
ReaderIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { HorizontalNav, VerticalNav } from "../_components/Nav";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
@@ -11,95 +11,95 @@ import { getServerSession } from "../api/auth/[...nextauth]/auth";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const session = await getServerSession();
|
||||
const session = await getServerSession();
|
||||
|
||||
if (!session) redirect(`/login`);
|
||||
if (!session) redirect(`/login`);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="hero min-h-screen"
|
||||
style={{
|
||||
backgroundImage: "url('/bg.png')",
|
||||
}}
|
||||
>
|
||||
<div className="hero-overlay bg-opacity-30"></div>
|
||||
<div>
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
</div>
|
||||
{/* Card */}
|
||||
<div className="hero-content text-neutral-content text-center w-full max-w-full h-full m-10">
|
||||
<div className="card bg-base-100 shadow-2xl w-full min-h-full h-full max-h-[calc(100vh-13rem)] p-4 flex flex-col mr-24 ml-24">
|
||||
{/* Top Navbar */}
|
||||
<HorizontalNav />
|
||||
return (
|
||||
<div
|
||||
className="hero min-h-screen"
|
||||
style={{
|
||||
backgroundImage: "url('/bg.png')",
|
||||
}}
|
||||
>
|
||||
<div className="hero-overlay bg-opacity-30"></div>
|
||||
<div>
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
</div>
|
||||
{/* Card */}
|
||||
<div className="hero-content text-neutral-content text-center w-full max-w-full h-full m-10">
|
||||
<div className="card bg-base-100 shadow-2xl w-full min-h-full h-full max-h-[calc(100vh-13rem)] p-4 flex flex-col mr-24 ml-24">
|
||||
{/* Top Navbar */}
|
||||
<HorizontalNav />
|
||||
|
||||
{/* Hauptlayout: Sidebar + Content (nimmt Resthöhe ein) */}
|
||||
<div className="flex grow overflow-hidden">
|
||||
{/* Linke Sidebar */}
|
||||
<VerticalNav />
|
||||
{/* Hauptlayout: Sidebar + Content (nimmt Resthöhe ein) */}
|
||||
<div className="flex grow overflow-hidden">
|
||||
{/* Linke Sidebar */}
|
||||
<VerticalNav />
|
||||
|
||||
{/* Scrollbarer Content-Bereich */}
|
||||
<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}
|
||||
</div>
|
||||
</div>
|
||||
{/* Scrollbarer Content-Bereich */}
|
||||
<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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="footer flex justify-between items-center p-4 bg-base-200 mt-4 rounded-lg shadow-md">
|
||||
{/* Left: Impressum & Datenschutz */}
|
||||
<div className="flex gap-4 text-sm">
|
||||
<a href="/impressum" className="hover:text-primary">
|
||||
Impressum
|
||||
</a>
|
||||
<a href="/datenschutz" className="hover:text-primary">
|
||||
Datenschutzerklärung
|
||||
</a>
|
||||
</div>
|
||||
{/* Footer */}
|
||||
<footer className="footer flex justify-between items-center p-4 bg-base-200 mt-4 rounded-lg shadow-md">
|
||||
{/* Left: Impressum & Datenschutz */}
|
||||
<div className="flex gap-4 text-sm">
|
||||
<a href="/impressum" className="hover:text-primary">
|
||||
Impressum
|
||||
</a>
|
||||
<a href="/datenschutz" className="hover:text-primary">
|
||||
Datenschutzerklärung
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Center: Copyright */}
|
||||
<p className="text-sm">
|
||||
Copyright © {new Date().getFullYear()} - Virtual Air Rescue
|
||||
</p>
|
||||
{/* Center: Copyright */}
|
||||
<p className="text-sm">
|
||||
Copyright © {new Date().getFullYear()} - Virtual Air Rescue
|
||||
</p>
|
||||
|
||||
{/* Right: Social Icons */}
|
||||
<div className="flex gap-4">
|
||||
<div className="tooltip tooltip-top" data-tip="Discord">
|
||||
<a
|
||||
href="https://discord.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-primary"
|
||||
>
|
||||
<DiscordLogoIcon className="w-5 h-5" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="tooltip tooltip-top" data-tip="Instagram">
|
||||
<a
|
||||
href="https://instagram.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-primary"
|
||||
>
|
||||
<InstagramLogoIcon className="w-5 h-5" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="tooltip tooltip-top" data-tip="Knowledgebase">
|
||||
<a href="/docs" className="hover:text-primary">
|
||||
<ReaderIcon className="w-5 h-5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{/* Right: Social Icons */}
|
||||
<div className="flex gap-4">
|
||||
<div className="tooltip tooltip-top" data-tip="Discord">
|
||||
<a
|
||||
href="https://discord.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-primary"
|
||||
>
|
||||
<DiscordLogoIcon className="w-5 h-5" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="tooltip tooltip-top" data-tip="Instagram">
|
||||
<a
|
||||
href="https://instagram.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-primary"
|
||||
>
|
||||
<InstagramLogoIcon className="w-5 h-5" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="tooltip tooltip-top" data-tip="Knowledgebase">
|
||||
<a href="/docs" className="hover:text-primary">
|
||||
<ReaderIcon className="w-5 h-5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,79 +3,79 @@ import { PaginatedTable } from "../_components/PaginatedTable";
|
||||
import { Header } from "../_components/ui/Header";
|
||||
|
||||
export default async function Home() {
|
||||
return (
|
||||
<div>
|
||||
<Header />
|
||||
<PaginatedTable
|
||||
rowsPerPage={10}
|
||||
prismaModel={"user"}
|
||||
searchFields={[]}
|
||||
columns={[
|
||||
{
|
||||
header: "ID",
|
||||
accessorKey: "id",
|
||||
},
|
||||
{
|
||||
header: "Email",
|
||||
accessorKey: "email",
|
||||
},
|
||||
{
|
||||
header: "First Name",
|
||||
accessorKey: "firstname",
|
||||
},
|
||||
{
|
||||
header: "Last Name",
|
||||
accessorKey: "lastname",
|
||||
footer: "Total",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
Map
|
||||
<br />
|
||||
Logbuch
|
||||
<br />
|
||||
Einlog-Zeit (7 Tage, total)
|
||||
<br />
|
||||
Stats
|
||||
<br />
|
||||
Badges
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<Header />
|
||||
<PaginatedTable
|
||||
rowsPerPage={10}
|
||||
prismaModel={"user"}
|
||||
searchFields={[]}
|
||||
columns={[
|
||||
{
|
||||
header: "ID",
|
||||
accessorKey: "id",
|
||||
},
|
||||
{
|
||||
header: "Email",
|
||||
accessorKey: "email",
|
||||
},
|
||||
{
|
||||
header: "First Name",
|
||||
accessorKey: "firstname",
|
||||
},
|
||||
{
|
||||
header: "Last Name",
|
||||
accessorKey: "lastname",
|
||||
footer: "Total",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
Map
|
||||
<br />
|
||||
Logbuch
|
||||
<br />
|
||||
Einlog-Zeit (7 Tage, total)
|
||||
<br />
|
||||
Stats
|
||||
<br />
|
||||
Badges
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
gd
|
||||
<br />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { NextPage } from 'next';
|
||||
import { ReactNode } from 'react';
|
||||
import { NextPage } from "next";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
const AuthLayout: NextPage<
|
||||
Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>
|
||||
Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>
|
||||
> = ({ children }) => (
|
||||
<div
|
||||
className="hero min-h-screen"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'url(https://img.daisyui.com/images/stock/photo-1507358522600-9f71e620c44e.webp)',
|
||||
}}
|
||||
>
|
||||
<div className="hero-overlay bg-neutral/60"></div>
|
||||
<div className="hero-content text-center ">
|
||||
<div className="max-w-lg">
|
||||
<div className="card rounded-2xl bg-base-100 w-full min-w-[500px] shadow-2xl max-md:min-w-[400px]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="hero min-h-screen"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"url(https://img.daisyui.com/images/stock/photo-1507358522600-9f71e620c44e.webp)",
|
||||
}}
|
||||
>
|
||||
<div className="hero-overlay bg-neutral/60"></div>
|
||||
<div className="hero-content text-center ">
|
||||
<div className="max-w-lg">
|
||||
<div className="card rounded-2xl bg-base-100 w-full min-w-[500px] shadow-2xl max-md:min-w-[400px]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default AuthLayout;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
import { Session } from 'next-auth';
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { Session } from "next-auth";
|
||||
|
||||
export const NextAuthSessionProvider = ({
|
||||
children,
|
||||
session,
|
||||
children,
|
||||
session,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
session: Session | null;
|
||||
children: React.ReactNode;
|
||||
session: Session | null;
|
||||
}) => <SessionProvider session={session}>{children}</SessionProvider>;
|
||||
|
||||
@@ -1,85 +1,85 @@
|
||||
import {
|
||||
HomeIcon,
|
||||
PersonIcon,
|
||||
GearIcon,
|
||||
ExitIcon,
|
||||
LockClosedIcon,
|
||||
RocketIcon,
|
||||
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>
|
||||
);
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -1,122 +1,122 @@
|
||||
'use client';
|
||||
"use client";
|
||||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
Ref,
|
||||
useImperativeHandle,
|
||||
} from 'react';
|
||||
import SortableTable, { Pagination, SortableTableProps } from './Table';
|
||||
import { PrismaClient } from '@repo/db';
|
||||
import { getData } from './pagiantedTableActions';
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
Ref,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
import SortableTable, { Pagination, SortableTableProps } from "./Table";
|
||||
import { PrismaClient } from "@repo/db";
|
||||
import { getData } from "./pagiantedTableActions";
|
||||
|
||||
export interface PaginatedTableRef {
|
||||
refresh: () => void;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
interface PaginatedTableProps<TData>
|
||||
extends Omit<SortableTableProps<TData>, 'data'> {
|
||||
prismaModel: keyof PrismaClient;
|
||||
filter?: Record<string, any>;
|
||||
rowsPerPage?: number;
|
||||
showEditButton?: boolean;
|
||||
searchFields?: string[];
|
||||
include?: Record<string, boolean>;
|
||||
leftOfSearch?: React.ReactNode;
|
||||
rightOfSearch?: React.ReactNode;
|
||||
ref?: Ref<PaginatedTableRef>;
|
||||
extends Omit<SortableTableProps<TData>, "data"> {
|
||||
prismaModel: keyof PrismaClient;
|
||||
filter?: Record<string, any>;
|
||||
rowsPerPage?: number;
|
||||
showEditButton?: boolean;
|
||||
searchFields?: string[];
|
||||
include?: Record<string, boolean>;
|
||||
leftOfSearch?: React.ReactNode;
|
||||
rightOfSearch?: React.ReactNode;
|
||||
ref?: Ref<PaginatedTableRef>;
|
||||
}
|
||||
|
||||
export function PaginatedTable<TData>({
|
||||
prismaModel,
|
||||
rowsPerPage = 10,
|
||||
showEditButton = false,
|
||||
searchFields = [],
|
||||
filter,
|
||||
include,
|
||||
ref,
|
||||
leftOfSearch,
|
||||
rightOfSearch,
|
||||
...restProps
|
||||
prismaModel,
|
||||
rowsPerPage = 10,
|
||||
showEditButton = false,
|
||||
searchFields = [],
|
||||
filter,
|
||||
include,
|
||||
ref,
|
||||
leftOfSearch,
|
||||
rightOfSearch,
|
||||
...restProps
|
||||
}: PaginatedTableProps<TData>) {
|
||||
const [data, setData] = useState<TData[]>([]);
|
||||
const [page, setPage] = useState(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(searchTerm);
|
||||
const [data, setData] = useState<TData[]>([]);
|
||||
const [page, setPage] = useState(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
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);
|
||||
}
|
||||
});
|
||||
};
|
||||
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();
|
||||
},
|
||||
}));
|
||||
useImperativeHandle(ref, () => ({
|
||||
refresh: () => {
|
||||
console.log("refresh");
|
||||
RefreshTableData();
|
||||
},
|
||||
}));
|
||||
|
||||
const debounce = (func: Function, delay: number) => {
|
||||
let timer: NodeJS.Timeout;
|
||||
return (...args: any[]) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => func(...args), delay);
|
||||
};
|
||||
};
|
||||
const debounce = (func: Function, delay: number) => {
|
||||
let timer: NodeJS.Timeout;
|
||||
return (...args: any[]) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => func(...args), delay);
|
||||
};
|
||||
};
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
debounce((value: string) => {
|
||||
setDebouncedSearchTerm(value);
|
||||
}, 500),
|
||||
[]
|
||||
);
|
||||
const handleSearchChange = useCallback(
|
||||
debounce((value: string) => {
|
||||
setDebouncedSearchTerm(value);
|
||||
}, 500),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
RefreshTableData();
|
||||
}, [page, debouncedSearchTerm]);
|
||||
useEffect(() => {
|
||||
RefreshTableData();
|
||||
}, [page, debouncedSearchTerm]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 m-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">{leftOfSearch}</div>
|
||||
{searchFields.length > 0 && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suchen..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
handleSearchChange(e.target.value);
|
||||
}}
|
||||
className="input input-bordered w-full max-w-xs justify-end"
|
||||
/>
|
||||
)}
|
||||
<div className="flex justify-center">{rightOfSearch}</div>
|
||||
</div>
|
||||
<SortableTable
|
||||
data={data}
|
||||
prismaModel={prismaModel}
|
||||
showEditButton={showEditButton}
|
||||
{...restProps}
|
||||
/>
|
||||
<Pagination
|
||||
totalPages={Math.ceil(total / rowsPerPage)}
|
||||
page={page}
|
||||
setPage={setPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="space-y-4 m-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">{leftOfSearch}</div>
|
||||
{searchFields.length > 0 && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suchen..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
handleSearchChange(e.target.value);
|
||||
}}
|
||||
className="input input-bordered w-full max-w-xs justify-end"
|
||||
/>
|
||||
)}
|
||||
<div className="flex justify-center">{rightOfSearch}</div>
|
||||
</div>
|
||||
<SortableTable
|
||||
data={data}
|
||||
prismaModel={prismaModel}
|
||||
showEditButton={showEditButton}
|
||||
{...restProps}
|
||||
/>
|
||||
<Pagination
|
||||
totalPages={Math.ceil(total / rowsPerPage)}
|
||||
page={page}
|
||||
setPage={setPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,138 +1,138 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
ColumnDef,
|
||||
SortingState,
|
||||
flexRender,
|
||||
} from '@tanstack/react-table';
|
||||
import { ArrowLeft, ArrowRight, ChevronDown, ChevronUp } from 'lucide-react'; // Icons for sorting
|
||||
import Link from 'next/link';
|
||||
import { PrismaClient } from '@repo/db';
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
ColumnDef,
|
||||
SortingState,
|
||||
flexRender,
|
||||
} from "@tanstack/react-table";
|
||||
import { ArrowLeft, ArrowRight, ChevronDown, ChevronUp } from "lucide-react"; // Icons for sorting
|
||||
import Link from "next/link";
|
||||
import { PrismaClient } from "@repo/db";
|
||||
|
||||
export interface SortableTableProps<TData> {
|
||||
data: TData[];
|
||||
columns: ColumnDef<TData>[];
|
||||
showEditButton?: boolean;
|
||||
prismaModel?: keyof PrismaClient;
|
||||
data: TData[];
|
||||
columns: ColumnDef<TData>[];
|
||||
showEditButton?: boolean;
|
||||
prismaModel?: keyof PrismaClient;
|
||||
}
|
||||
|
||||
export default function SortableTable<TData>({
|
||||
data,
|
||||
columns,
|
||||
prismaModel,
|
||||
showEditButton,
|
||||
data,
|
||||
columns,
|
||||
prismaModel,
|
||||
showEditButton,
|
||||
}: SortableTableProps<TData>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: showEditButton
|
||||
? [
|
||||
...columns,
|
||||
{
|
||||
header: 'Actions',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
href={`/admin/${prismaModel as string}/${(row.original as any).id}`}
|
||||
>
|
||||
<button className="btn btn-sm">Edit</button>
|
||||
</Link>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
state: { sorting },
|
||||
});
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: showEditButton
|
||||
? [
|
||||
...columns,
|
||||
{
|
||||
header: "Actions",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
href={`/admin/${prismaModel as string}/${(row.original as any).id}`}
|
||||
>
|
||||
<button className="btn btn-sm">Edit</button>
|
||||
</Link>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
state: { sorting },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table table-zebra w-full">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th
|
||||
key={header.id}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
<div className="flex items-center gap-1 cursor-pointer">
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{header.column.getIsSorted() === 'asc' && (
|
||||
<ChevronUp size={16} />
|
||||
)}
|
||||
{header.column.getIsSorted() === 'desc' && (
|
||||
<ChevronDown size={16} />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table table-zebra w-full">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th
|
||||
key={header.id}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
<div className="flex items-center gap-1 cursor-pointer">
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
{header.column.getIsSorted() === "asc" && (
|
||||
<ChevronUp size={16} />
|
||||
)}
|
||||
{header.column.getIsSorted() === "desc" && (
|
||||
<ChevronDown size={16} />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Pagination = ({
|
||||
page,
|
||||
totalPages,
|
||||
setPage,
|
||||
page,
|
||||
totalPages,
|
||||
setPage,
|
||||
}: {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
setPage: (page: number) => void;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
setPage: (page: number) => void;
|
||||
}) => {
|
||||
if (totalPages === 0) return null;
|
||||
return (
|
||||
<div className="join w-full justify-end">
|
||||
<button
|
||||
className="join-item btn"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(page - 1)}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
<select
|
||||
className="select join-item w-16"
|
||||
value={page}
|
||||
onChange={(e) => setPage(Number(e.target.value))}
|
||||
>
|
||||
{Array.from({ length: totalPages }).map((_, i) => (
|
||||
<option key={i} value={i}>
|
||||
{i + 1}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="join-item btn"
|
||||
disabled={page === totalPages - 1}
|
||||
onClick={() => page < totalPages && setPage(page + 1)}
|
||||
>
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
if (totalPages === 0) return null;
|
||||
return (
|
||||
<div className="join w-full justify-end">
|
||||
<button
|
||||
className="join-item btn"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(page - 1)}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
<select
|
||||
className="select join-item w-16"
|
||||
value={page}
|
||||
onChange={(e) => setPage(Number(e.target.value))}
|
||||
>
|
||||
{Array.from({ length: totalPages }).map((_, i) => (
|
||||
<option key={i} value={i}>
|
||||
{i + 1}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="join-item btn"
|
||||
disabled={page === totalPages - 1}
|
||||
onClick={() => page < totalPages && setPage(page + 1)}
|
||||
>
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,47 +1,47 @@
|
||||
'use server';
|
||||
import { PrismaClient } from '@repo/db';
|
||||
"use server";
|
||||
import { PrismaClient } from "@repo/db";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function getData(
|
||||
model: keyof PrismaClient,
|
||||
limit: number,
|
||||
offset: number,
|
||||
searchTerm: string,
|
||||
searchFields: string[],
|
||||
filter?: Record<string, any>,
|
||||
include?: Record<string, boolean>
|
||||
model: keyof PrismaClient,
|
||||
limit: number,
|
||||
offset: number,
|
||||
searchTerm: string,
|
||||
searchFields: string[],
|
||||
filter?: Record<string, any>,
|
||||
include?: Record<string, boolean>,
|
||||
) {
|
||||
if (!model || !prisma[model]) {
|
||||
return { data: [], total: 0 };
|
||||
}
|
||||
if (!model || !prisma[model]) {
|
||||
return { data: [], total: 0 };
|
||||
}
|
||||
|
||||
const formattedId = searchTerm.match(/^VAR(\d+)$/)?.[1];
|
||||
const formattedId = searchTerm.match(/^VAR(\d+)$/)?.[1];
|
||||
|
||||
const where = searchTerm
|
||||
? {
|
||||
OR: [
|
||||
formattedId ? { id: formattedId } : undefined,
|
||||
...searchFields.map((field) => ({
|
||||
[field]: { contains: searchTerm },
|
||||
})),
|
||||
].filter(Boolean),
|
||||
...filter,
|
||||
}
|
||||
: { ...filter };
|
||||
const where = searchTerm
|
||||
? {
|
||||
OR: [
|
||||
formattedId ? { id: formattedId } : undefined,
|
||||
...searchFields.map((field) => ({
|
||||
[field]: { contains: searchTerm },
|
||||
})),
|
||||
].filter(Boolean),
|
||||
...filter,
|
||||
}
|
||||
: { ...filter };
|
||||
|
||||
if (!prisma[model]) {
|
||||
return { data: [], total: 0 };
|
||||
}
|
||||
if (!prisma[model]) {
|
||||
return { data: [], total: 0 };
|
||||
}
|
||||
|
||||
const data = await (prisma[model] as any).findMany({
|
||||
where,
|
||||
take: limit,
|
||||
skip: offset,
|
||||
include,
|
||||
});
|
||||
const data = await (prisma[model] as any).findMany({
|
||||
where,
|
||||
take: limit,
|
||||
skip: offset,
|
||||
include,
|
||||
});
|
||||
|
||||
const total = await (prisma[model] as any).count({ where });
|
||||
const total = await (prisma[model] as any).count({ where });
|
||||
|
||||
return { data, total };
|
||||
return { data, total };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import 'tailwindcss';
|
||||
@import "tailwindcss";
|
||||
@plugin "daisyui";
|
||||
|
||||
/*
|
||||
@@ -10,22 +10,22 @@
|
||||
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);
|
||||
}
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentColor);
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
/* --p: 47.67% 0.2484 267.02; */
|
||||
--nc: #a6adbb;
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
/* --p: 47.67% 0.2484 267.02; */
|
||||
--nc: #a6adbb;
|
||||
}
|
||||
|
||||
body.modal-open {
|
||||
overflow: hidden;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -1,35 +1,34 @@
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { NextAuthSessionProvider } from "./_components/AuthSessionProvider";
|
||||
import { getServerSession } from "./api/auth/[...nextauth]/auth";
|
||||
import './globals.css';
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: '--font-geist-sans',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: '--font-geist-mono',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export default async ({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) => {
|
||||
const session = await getServerSession();
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<NextAuthSessionProvider session={session}>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</NextAuthSessionProvider>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) => {
|
||||
const session = await getServerSession();
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<NextAuthSessionProvider session={session}>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</NextAuthSessionProvider>
|
||||
</html>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user