Sticky headers fix, added Heliports

This commit is contained in:
PxlLoewe
2025-07-13 00:30:46 -07:00
parent 0730737bbe
commit 768c84f171
27 changed files with 432 additions and 22 deletions

View File

@@ -9,6 +9,7 @@ export default function Page() {
return ( return (
<> <>
<PaginatedTable <PaginatedTable
stickyHeaders
prismaModel="event" prismaModel="event"
columns={ columns={
[ [

View File

@@ -0,0 +1,13 @@
import { prisma } from "@repo/db";
import { HeliportForm } from "../_components/Form";
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const heliport = await prisma.heliport.findUnique({
where: {
id: parseInt(id),
},
});
if (!heliport) return <div>Heliport not found</div>;
return <HeliportForm heliport={heliport} />;
}

View File

@@ -0,0 +1,151 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { HeliportOptionalDefaultsSchema } from "@repo/db/zod";
import { useForm } from "react-hook-form";
import { Heliport, HeliportType } from "@repo/db";
import { FileText, LocateIcon } from "lucide-react";
import { Input } from "../../../../_components/ui/Input";
import { useState } from "react";
import { deleteHeliport, upsertHeliport } from "../action";
import { Button } from "../../../../_components/ui/Button";
import { redirect } from "next/navigation";
export const HeliportForm = ({ heliport }: { heliport?: Heliport }) => {
const form = useForm({
resolver: zodResolver(HeliportOptionalDefaultsSchema),
defaultValues: heliport,
});
const [loading, setLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
return (
<>
<form
onSubmit={form.handleSubmit(async (values) => {
setLoading(true);
await upsertHeliport(values, heliport?.id);
setLoading(false);
if (!heliport) redirect(`/admin/Heliport`);
})}
className="flex flex-wrap gap-3"
>
<div className="card bg-base-200 shadow-xl flex-1 basis-[300px]">
<div className="card-body">
<h2 className="card-title">
<FileText className="w-5 h-5" /> Allgemeines
</h2>
<label className="form-control w-full">
<select
className="input-sm select select-bordered select-sm w-full"
{...form.register("type")}
>
{Object.keys(HeliportType).map((use) => (
<option key={use} value={use}>
{use}
</option>
))}
</select>
</label>
<Input form={form} label="Designator" name="designator" className="input-sm" />
<Input
form={form}
label="Designator (6 Zeichen)"
name="designatorSub6"
className="input-sm"
/>
<Input form={form} label="FIR" name="fir" className="input-sm" />
<Input form={form} label="Name" name="siteName" className="input-sm" />
<Input
form={form}
label="Name (21 Zeichen)"
name="siteNameSub21"
className="input-sm"
/>
<Input
form={form}
label="Name (26 Zeichen)"
name="siteNameSub26"
className="input-sm"
/>
<textarea
className="textarea textarea-bordered textarea-sm w-full"
{...form.register("info")}
placeholder="Info"
/>
<Input form={form} label="Krankenhaus" name="hospital" className="input-sm" />
</div>
</div>
<div className="card bg-base-200 shadow-xl flex-1 basis-[300px]">
<div className="card-body">
<h2 className="card-title">
<LocateIcon className="w-5 h-5" /> Standort
</h2>
<Input form={form} label="Bundesland (voller Name)" name="state" className="input-sm" />
<Input
form={form}
label="Bundesland (2 Zeichen)"
name="stateShort"
className="input-sm"
/>
<Input
form={form}
formOptions={{
valueAsNumber: true,
value: null,
}}
label="Elevation"
name="siteElevation"
className="input-sm"
/>
<Input
form={form}
label="Elevation Einheit"
name="siteElevationUnit"
className="input-sm"
/>
<Input
form={form}
formOptions={{
valueAsNumber: true,
}}
label="Latitude"
name="lat"
className="input-sm"
/>
<Input
form={form}
formOptions={{
valueAsNumber: true,
}}
label="Longitude"
name="lng"
className="input-sm"
/>
<Input form={form} label="Land (2 Zeichen)" name="country" className="input-sm" />
</div>
</div>
<div className="card bg-base-200 shadow-xl flex-[100%]">
<div className="card-body ">
<div className="flex w-full gap-4">
<Button isLoading={loading} type="submit" className="btn btn-primary flex-1">
Speichern
</Button>
{heliport && (
<Button
isLoading={deleteLoading}
onClick={async () => {
setDeleteLoading(true);
await deleteHeliport(heliport.id);
redirect("/admin/Heliport");
}}
className="btn btn-error"
>
Löschen
</Button>
)}
</div>
</div>
</div>
</form>
</>
);
};

View File

@@ -0,0 +1,17 @@
"use server";
import { prisma, Prisma, Heliport } from "@repo/db";
export const upsertHeliport = async (heliport: Prisma.HeliportCreateInput, id?: Heliport["id"]) => {
const newHeliport = id
? await prisma.heliport.update({
where: { id: id },
data: heliport,
})
: await prisma.heliport.create({ data: heliport });
return newHeliport;
};
export const deleteHeliport = async (id: Heliport["id"]) => {
await prisma.heliport.delete({ where: { id: id } });
};

View File

@@ -0,0 +1,19 @@
import { Error } from "_components/Error";
import { getServerSession } from "api/auth/[...nextauth]/auth";
const AdminStationLayout = async ({ children }: { children: React.ReactNode }) => {
const session = await getServerSession();
if (!session) return <Error title="Nicht eingeloggt" statusCode={401} />;
const user = session.user;
if (!user?.permissions.includes("ADMIN_HELIPORT"))
return <Error title="Keine Berechtigung" statusCode={403} />;
return <>{children}</>;
};
AdminStationLayout.displayName = "AdminStationLayout";
export default AdminStationLayout;

View File

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

View File

@@ -0,0 +1,63 @@
"use client";
import { DatabaseBackupIcon } from "lucide-react";
import { PaginatedTable } from "../../../_components/PaginatedTable";
import Link from "next/link";
import { ColumnDef } from "@tanstack/react-table";
import { Heliport } from "@repo/db";
const page = () => {
return (
<>
<PaginatedTable
stickyHeaders
prismaModel="heliport"
searchFields={["siteName", "info", "hospital"]}
columns={
[
{
header: "Typ",
accessorKey: "type",
},
{
accessorKey: "designator",
header: "Designator",
},
{
header: "Name",
accessorKey: "siteName",
},
{
header: "Name (21 zeichen)",
accessorKey: "siteNameSub21",
},
{
header: "Aktionen",
cell: ({ row }) => (
<div className="flex items-center gap-1">
<Link href={`/admin/heliport/${row.original.id}`}>
<button className="btn btn-sm">Edit</button>
</Link>
</div>
),
},
] as ColumnDef<Heliport>[]
}
leftOfSearch={
<span className="flex items-center gap-2">
<DatabaseBackupIcon className="w-5 h-5" /> Heliports
</span>
}
rightOfSearch={
<p className="text-2xl font-semibold text-left flex items-center gap-2 justify-between">
<Link href={"/admin/heliport/new"}>
<button className="btn btn-sm btn-outline btn-primary">Erstellen</button>
</Link>
</p>
}
/>
</>
);
};
export default page;

View File

@@ -9,6 +9,7 @@ export default () => {
return ( return (
<> <>
<PaginatedTable <PaginatedTable
stickyHeaders
initialOrderBy={[{ id: "category", desc: true }]} initialOrderBy={[{ id: "category", desc: true }]}
prismaModel="keyword" prismaModel="keyword"
searchFields={["name", "abreviation", "description"]} searchFields={["name", "abreviation", "description"]}

View File

@@ -5,6 +5,7 @@ export default function ReportPage() {
return ( return (
<PaginatedTable <PaginatedTable
prismaModel="penalty" prismaModel="penalty"
stickyHeaders
include={{ include={{
CreatedUser: true, CreatedUser: true,
Report: true, Report: true,

View File

@@ -7,6 +7,7 @@ export default function ReportPage() {
<PaginatedTable <PaginatedTable
initialOrderBy={[{ id: "timestamp", desc: true }]} initialOrderBy={[{ id: "timestamp", desc: true }]}
prismaModel="report" prismaModel="report"
stickyHeaders
include={{ include={{
Sender: true, Sender: true,
Reported: true, Reported: true,

View File

@@ -11,6 +11,7 @@ const page = () => {
<PaginatedTable <PaginatedTable
prismaModel="station" prismaModel="station"
searchFields={["bosCallsign", "operator"]} searchFields={["bosCallsign", "operator"]}
stickyHeaders
columns={ columns={
[ [
{ {

View File

@@ -176,7 +176,14 @@ export const ProfileForm: React.FC<ProfileFormProps> = ({ user }: ProfileFormPro
onClick={() => onClick={() =>
form.setValue( form.setValue(
"permissions", "permissions",
["LOGIN_NEXTCLOUD", "PILOT", "DISPO", "AUDIO", "ADMIN_EVENT"], [
"LOGIN_NEXTCLOUD",
"PILOT",
"DISPO",
"AUDIO",
"ADMIN_EVENT",
"ADMIN_HELIPORT",
],
{ {
shouldDirty: true, shouldDirty: true,
}, },
@@ -191,7 +198,15 @@ export const ProfileForm: React.FC<ProfileFormProps> = ({ user }: ProfileFormPro
onClick={() => onClick={() =>
form.setValue( form.setValue(
"permissions", "permissions",
["LOGIN_NEXTCLOUD", "PILOT", "DISPO", "AUDIO", "ADMIN_KICK", "ADMIN_USER"], [
"LOGIN_NEXTCLOUD",
"PILOT",
"DISPO",
"AUDIO",
"ADMIN_KICK",
"ADMIN_USER",
"ADMIN_HELIPORT",
],
{ {
shouldDirty: true, shouldDirty: true,
}, },

View File

@@ -98,6 +98,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
total60Days: totalReports60Days, total60Days: totalReports60Days,
}; };
if (!user) return <Error statusCode={404} title="User not found" />; if (!user) return <Error statusCode={404} title="User not found" />;
return ( return (
<div className="grid grid-cols-6 gap-4"> <div className="grid grid-cols-6 gap-4">
<div className="col-span-full flex justify-between items-center"> <div className="col-span-full flex justify-between items-center">

View File

@@ -11,6 +11,7 @@ const AdminUserPage = () => {
return ( return (
<> <>
<PaginatedTable <PaginatedTable
stickyHeaders
prismaModel="user" prismaModel="user"
searchFields={["publicId", "firstname", "lastname", "email"]} searchFields={["publicId", "firstname", "lastname", "email"]}
initialOrderBy={[ initialOrderBy={[

View File

@@ -73,6 +73,11 @@ export const VerticalNav = async () => {
<Link href="/admin/keyword">Stichworte</Link> <Link href="/admin/keyword">Stichworte</Link>
</li> </li>
)} )}
{session.user.permissions.includes("ADMIN_HELIPORT") && (
<li>
<Link href="/admin/heliport">Heliports</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_EVENT") && ( {session.user.permissions.includes("ADMIN_EVENT") && (
<li> <li>
<Link href="/admin/event">Events</Link> <Link href="/admin/event">Events</Link>

View File

@@ -3,7 +3,7 @@ import { useState, Ref, useImperativeHandle, useEffect, useCallback } from "reac
import SortableTable, { Pagination, RowsPerPage, SortableTableProps } from "./Table"; import SortableTable, { Pagination, RowsPerPage, SortableTableProps } from "./Table";
import { PrismaClient } from "@repo/db"; import { PrismaClient } from "@repo/db";
import { getData } from "./pagiantedTableActions"; import { getData } from "./pagiantedTableActions";
import { useDebounce } from "@repo/shared-components"; import { cn, useDebounce } from "@repo/shared-components";
export interface PaginatedTableRef { export interface PaginatedTableRef {
refresh: () => void; refresh: () => void;
@@ -11,6 +11,7 @@ export interface PaginatedTableRef {
interface PaginatedTableProps<TData> extends Omit<SortableTableProps<TData>, "data"> { interface PaginatedTableProps<TData> extends Omit<SortableTableProps<TData>, "data"> {
prismaModel: keyof PrismaClient; prismaModel: keyof PrismaClient;
stickyHeaders?: boolean;
filter?: Record<string, unknown>; filter?: Record<string, unknown>;
initialRowsPerPage?: number; initialRowsPerPage?: number;
searchFields?: string[]; searchFields?: string[];
@@ -31,6 +32,7 @@ export function PaginatedTable<TData>({
include, include,
ref, ref,
strictQuery = false, strictQuery = false,
stickyHeaders = false,
leftOfSearch, leftOfSearch,
rightOfSearch, rightOfSearch,
leftOfPagination, leftOfPagination,
@@ -110,7 +112,7 @@ export function PaginatedTable<TData>({
// useEffect to show loading spinner // useEffect to show loading spinner
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
}, [searchTerm, page, rowsPerPage, orderBy, filter, setLoading, refreshTableData]); }, [searchTerm, page, rowsPerPage, orderBy, filter, setLoading]);
useDebounce( useDebounce(
() => { () => {
@@ -122,7 +124,13 @@ export function PaginatedTable<TData>({
return ( return (
<div className="space-y-4 m-4"> <div className="space-y-4 m-4">
<div className="flex items-center gap-2"> {(rightOfSearch || leftOfSearch || searchFields.length > 0) && (
<div
className={cn(
"flex items-center gap-2 sticky py-2 z-20",
stickyHeaders && "sticky top-0 bg-base-100/80 backdrop-blur border-b",
)}
>
<div className="flex-1 flex gap-2"> <div className="flex-1 flex gap-2">
<div>{leftOfSearch}</div> <div>{leftOfSearch}</div>
<div>{loading && <span className="loading loading-dots loading-md" />}</div> <div>{loading && <span className="loading loading-dots loading-md" />}</div>
@@ -141,6 +149,7 @@ export function PaginatedTable<TData>({
)} )}
<div className="flex justify-center">{rightOfSearch}</div> <div className="flex justify-center">{rightOfSearch}</div>
</div> </div>
)}
{!hide && ( {!hide && (
<SortableTable <SortableTable
data={data} data={data}

View File

@@ -0,0 +1,25 @@
enum HeliportType {
HELIPAD
POI
MOUNTAIN
}
model Heliport {
id Int @id @default(autoincrement())
type HeliportType
designator String
designatorSub6 String
fir String
state String
stateShort String
siteName String
siteNameSub26 String
siteNameSub21 String
siteElevation Float?
siteElevationUnit String?
info String?
lat Float
lng Float
country Country
hospital String?
}

View File

@@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE "Heliport" (
"id" SERIAL NOT NULL,
"state" TEXT NOT NULL,
"stateShort" TEXT NOT NULL,
"siteName" TEXT NOT NULL,
"siteNameSub26" TEXT NOT NULL,
"siteNameSub21" TEXT NOT NULL,
"siteElevation" DOUBLE PRECISION NOT NULL,
"siteElevationUnit" TEXT NOT NULL,
"info" TEXT NOT NULL,
"lat" DOUBLE PRECISION NOT NULL,
"lng" DOUBLE PRECISION NOT NULL,
"country" TEXT NOT NULL,
"hospital" TEXT NOT NULL,
CONSTRAINT "Heliport_pkey" PRIMARY KEY ("id")
);

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "PERMISSION" ADD VALUE 'ADMIN_HELIPORT';

View File

@@ -0,0 +1,11 @@
/*
Warnings:
- Added the required column `type` to the `Heliport` table without a default value. This is not possible if the table is not empty.
*/
-- CreateEnum
CREATE TYPE "HeliportType" AS ENUM ('HELIPORT', 'POI');
-- AlterTable
ALTER TABLE "Heliport" ADD COLUMN "type" "HeliportType" NOT NULL;

View File

@@ -0,0 +1,10 @@
/*
Warnings:
- Added the required column `designator` to the `Heliport` table without a default value. This is not possible if the table is not empty.
- Added the required column `designator_sub6` to the `Heliport` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Heliport" ADD COLUMN "designator" TEXT NOT NULL,
ADD COLUMN "designator_sub6" TEXT NOT NULL;

View File

@@ -0,0 +1,10 @@
/*
Warnings:
- You are about to drop the column `designator_sub6` on the `Heliport` table. All the data in the column will be lost.
- Added the required column `designatorSub6` to the `Heliport` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Heliport" DROP COLUMN "designator_sub6",
ADD COLUMN "designatorSub6" TEXT NOT NULL;

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- Added the required column `fir` to the `Heliport` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Heliport" ADD COLUMN "fir" TEXT NOT NULL;

View File

@@ -0,0 +1,14 @@
/*
Warnings:
- The values [HELIPORT] on the enum `HeliportType` will be removed. If these variants are still used in the database, this will fail.
*/
-- AlterEnum
BEGIN;
CREATE TYPE "HeliportType_new" AS ENUM ('HELIPAD', 'POI');
ALTER TABLE "Heliport" ALTER COLUMN "type" TYPE "HeliportType_new" USING ("type"::text::"HeliportType_new");
ALTER TYPE "HeliportType" RENAME TO "HeliportType_old";
ALTER TYPE "HeliportType_new" RENAME TO "HeliportType";
DROP TYPE "HeliportType_old";
COMMIT;

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Heliport" ALTER COLUMN "siteElevation" DROP NOT NULL,
ALTER COLUMN "siteElevationUnit" DROP NOT NULL,
ALTER COLUMN "info" DROP NOT NULL,
ALTER COLUMN "hospital" DROP NOT NULL;

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "HeliportType" ADD VALUE 'MOUNTAIN';

View File

@@ -17,6 +17,7 @@ enum PERMISSION {
ADMIN_KEYWORD ADMIN_KEYWORD
ADMIN_MESSAGE ADMIN_MESSAGE
ADMIN_KICK ADMIN_KICK
ADMIN_HELIPORT
AUDIO AUDIO
PILOT PILOT
DISPO DISPO