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 (
<>
<PaginatedTable
stickyHeaders
prismaModel="event"
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 (
<>
<PaginatedTable
stickyHeaders
initialOrderBy={[{ id: "category", desc: true }]}
prismaModel="keyword"
searchFields={["name", "abreviation", "description"]}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -73,6 +73,11 @@ export const VerticalNav = async () => {
<Link href="/admin/keyword">Stichworte</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_HELIPORT") && (
<li>
<Link href="/admin/heliport">Heliports</Link>
</li>
)}
{session.user.permissions.includes("ADMIN_EVENT") && (
<li>
<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 { PrismaClient } from "@repo/db";
import { getData } from "./pagiantedTableActions";
import { useDebounce } from "@repo/shared-components";
import { cn, useDebounce } from "@repo/shared-components";
export interface PaginatedTableRef {
refresh: () => void;
@@ -11,6 +11,7 @@ export interface PaginatedTableRef {
interface PaginatedTableProps<TData> extends Omit<SortableTableProps<TData>, "data"> {
prismaModel: keyof PrismaClient;
stickyHeaders?: boolean;
filter?: Record<string, unknown>;
initialRowsPerPage?: number;
searchFields?: string[];
@@ -31,6 +32,7 @@ export function PaginatedTable<TData>({
include,
ref,
strictQuery = false,
stickyHeaders = false,
leftOfSearch,
rightOfSearch,
leftOfPagination,
@@ -110,7 +112,7 @@ export function PaginatedTable<TData>({
// useEffect to show loading spinner
useEffect(() => {
setLoading(true);
}, [searchTerm, page, rowsPerPage, orderBy, filter, setLoading, refreshTableData]);
}, [searchTerm, page, rowsPerPage, orderBy, filter, setLoading]);
useDebounce(
() => {
@@ -122,25 +124,32 @@ export function PaginatedTable<TData>({
return (
<div className="space-y-4 m-4">
<div className="flex items-center gap-2">
<div className="flex-1 flex gap-2">
<div>{leftOfSearch}</div>
<div>{loading && <span className="loading loading-dots loading-md" />}</div>
{(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>{leftOfSearch}</div>
<div>{loading && <span className="loading loading-dots loading-md" />}</div>
</div>
{searchFields.length > 0 && (
<input
type="text"
placeholder="Suchen..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setPage(0); // Reset to first page on search
}}
className="input input-bordered w-full max-w-xs justify-end"
/>
)}
<div className="flex justify-center">{rightOfSearch}</div>
</div>
{searchFields.length > 0 && (
<input
type="text"
placeholder="Suchen..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setPage(0); // Reset to first page on search
}}
className="input input-bordered w-full max-w-xs justify-end"
/>
)}
<div className="flex justify-center">{rightOfSearch}</div>
</div>
)}
{!hide && (
<SortableTable
data={data}