Sticky headers fix, added Heliports
This commit is contained in:
@@ -9,6 +9,7 @@ export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<PaginatedTable
|
||||
stickyHeaders
|
||||
prismaModel="event"
|
||||
columns={
|
||||
[
|
||||
|
||||
13
apps/hub/app/(app)/admin/heliport/[id]/page.tsx
Normal file
13
apps/hub/app/(app)/admin/heliport/[id]/page.tsx
Normal 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} />;
|
||||
}
|
||||
151
apps/hub/app/(app)/admin/heliport/_components/Form.tsx
Normal file
151
apps/hub/app/(app)/admin/heliport/_components/Form.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
17
apps/hub/app/(app)/admin/heliport/action.ts
Normal file
17
apps/hub/app/(app)/admin/heliport/action.ts
Normal 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 } });
|
||||
};
|
||||
19
apps/hub/app/(app)/admin/heliport/layout.tsx
Normal file
19
apps/hub/app/(app)/admin/heliport/layout.tsx
Normal 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;
|
||||
5
apps/hub/app/(app)/admin/heliport/new/page.tsx
Normal file
5
apps/hub/app/(app)/admin/heliport/new/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { HeliportForm } from "../_components/Form";
|
||||
|
||||
export default () => {
|
||||
return <HeliportForm />;
|
||||
};
|
||||
63
apps/hub/app/(app)/admin/heliport/page.tsx
Normal file
63
apps/hub/app/(app)/admin/heliport/page.tsx
Normal 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;
|
||||
@@ -9,6 +9,7 @@ export default () => {
|
||||
return (
|
||||
<>
|
||||
<PaginatedTable
|
||||
stickyHeaders
|
||||
initialOrderBy={[{ id: "category", desc: true }]}
|
||||
prismaModel="keyword"
|
||||
searchFields={["name", "abreviation", "description"]}
|
||||
|
||||
@@ -5,6 +5,7 @@ export default function ReportPage() {
|
||||
return (
|
||||
<PaginatedTable
|
||||
prismaModel="penalty"
|
||||
stickyHeaders
|
||||
include={{
|
||||
CreatedUser: true,
|
||||
Report: true,
|
||||
|
||||
@@ -7,6 +7,7 @@ export default function ReportPage() {
|
||||
<PaginatedTable
|
||||
initialOrderBy={[{ id: "timestamp", desc: true }]}
|
||||
prismaModel="report"
|
||||
stickyHeaders
|
||||
include={{
|
||||
Sender: true,
|
||||
Reported: true,
|
||||
|
||||
@@ -11,6 +11,7 @@ const page = () => {
|
||||
<PaginatedTable
|
||||
prismaModel="station"
|
||||
searchFields={["bosCallsign", "operator"]}
|
||||
stickyHeaders
|
||||
columns={
|
||||
[
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -11,6 +11,7 @@ const AdminUserPage = () => {
|
||||
return (
|
||||
<>
|
||||
<PaginatedTable
|
||||
stickyHeaders
|
||||
prismaModel="user"
|
||||
searchFields={["publicId", "firstname", "lastname", "email"]}
|
||||
initialOrderBy={[
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,7 +124,13 @@ export function PaginatedTable<TData>({
|
||||
|
||||
return (
|
||||
<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>{leftOfSearch}</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>
|
||||
)}
|
||||
{!hide && (
|
||||
<SortableTable
|
||||
data={data}
|
||||
|
||||
25
packages/database/prisma/schema/heliports.prisma
Normal file
25
packages/database/prisma/schema/heliports.prisma
Normal 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?
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "PERMISSION" ADD VALUE 'ADMIN_HELIPORT';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "HeliportType" ADD VALUE 'MOUNTAIN';
|
||||
@@ -17,6 +17,7 @@ enum PERMISSION {
|
||||
ADMIN_KEYWORD
|
||||
ADMIN_MESSAGE
|
||||
ADMIN_KICK
|
||||
ADMIN_HELIPORT
|
||||
AUDIO
|
||||
PILOT
|
||||
DISPO
|
||||
|
||||
Reference in New Issue
Block a user