v2.0.5 #141

Merged
PxlLoewe merged 5 commits from staging into release 2025-12-27 15:23:33 +00:00
16 changed files with 209 additions and 178 deletions
Showing only changes of commit b16b719c74 - Show all commits

View File

@@ -1,5 +1,5 @@
"use client";
import { Mission, MissionAlertLog, MissionLog, Station } from "@repo/db";
import { Mission, MissionAlertLog, MissionLog, Prisma, Station } from "@repo/db";
import { ColumnDef } from "@tanstack/react-table";
import { PaginatedTable } from "_components/PaginatedTable";
import { ArrowRight, NotebookText } from "lucide-react";
@@ -12,20 +12,22 @@ export const RecentFlights = () => {
<div className="card-body">
<h2 className="card-title justify-between">
<span className="card-title">
<NotebookText className="w-4 h-4" /> Logbook
<NotebookText className="h-4 w-4" /> Logbook
</span>
<Link className="badge badge-sm badge-info badge-outline" href="/logbook">
Zum vollständigen Logbook <ArrowRight className="w-4 h-4" />
Zum vollständigen Logbook <ArrowRight className="h-4 w-4" />
</Link>
</h2>
<PaginatedTable
prismaModel={"missionOnStationUsers"}
filter={{
userId: session.data?.user?.id ?? "",
Mission: {
state: "finished",
},
}}
getFilter={() =>
({
User: { id: session.data?.user.id },
Mission: {
state: { in: ["finished", "archived"] },
},
}) as Prisma.MissionOnStationUsersWhereInput
}
include={{
Station: true,
User: true,

View File

@@ -3,7 +3,7 @@ import { DatabaseBackupIcon } from "lucide-react";
import { PaginatedTable } from "../../../_components/PaginatedTable";
import Link from "next/link";
import { ColumnDef } from "@tanstack/react-table";
import { Keyword } from "@repo/db";
import { Keyword, Prisma } from "@repo/db";
export default () => {
return (
@@ -12,7 +12,15 @@ export default () => {
stickyHeaders
initialOrderBy={[{ id: "title", desc: true }]}
prismaModel="changelog"
searchFields={["title"]}
showSearch
getFilter={(search) =>
({
OR: [
{ title: { contains: search, mode: "insensitive" } },
{ description: { contains: search, mode: "insensitive" } },
],
}) as Prisma.ChangelogWhereInput
}
columns={
[
{

View File

@@ -1,4 +1,4 @@
import { Event, Participant } from "@repo/db";
import { Event, Participant, Prisma } from "@repo/db";
import { EventAppointmentOptionalDefaults, InputJsonValueType } from "@repo/db/zod";
import { ColumnDef } from "@tanstack/react-table";
import { useSession } from "next-auth/react";
@@ -167,9 +167,11 @@ export const AppointmentModal = ({
] as ColumnDef<Participant>[]
}
prismaModel={"participant"}
filter={{
eventAppointmentId: appointmentForm.watch("id"),
}}
getFilter={() =>
({
eventAppointmentId: appointmentForm.watch("id")!,
}) as Prisma.ParticipantWhereInput
}
include={{ User: true }}
leftOfPagination={
<div className="flex gap-2">

View File

@@ -1,6 +1,6 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { BADGES, Event, EVENT_TYPE, Participant, PERMISSION, User } from "@repo/db";
import { BADGES, Event, EVENT_TYPE, Participant, PERMISSION, Prisma, User } from "@repo/db";
import {
EventAppointmentOptionalDefaults,
EventAppointmentOptionalDefaultsSchema,
@@ -159,9 +159,11 @@ export const Form = ({ event }: { event?: Event }) => {
<PaginatedTable
ref={appointmentsTableRef}
prismaModel={"eventAppointment"}
filter={{
eventId: event?.id,
}}
getFilter={() =>
({
eventId: event?.id,
}) as Prisma.EventAppointmentWhereInput
}
include={{
Presenter: true,
Participants: true,
@@ -257,12 +259,24 @@ export const Form = ({ event }: { event?: Event }) => {
<UserIcon className="h-5 w-5" /> Teilnehmer
</h2>
}
searchFields={["User.firstname", "User.lastname", "User.publicId"]}
ref={appointmentsTableRef}
prismaModel={"participant"}
filter={{
eventId: event?.id,
}}
showSearch
getFilter={(searchTerm) =>
({
OR: [
{
User: {
OR: [
{ firstname: { contains: searchTerm, mode: "insensitive" } },
{ lastname: { contains: searchTerm, mode: "insensitive" } },
{ publicId: { contains: searchTerm, mode: "insensitive" } },
],
},
},
],
}) as Prisma.ParticipantWhereInput
}
include={{
User: true,
}}

View File

@@ -3,7 +3,7 @@ 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";
import { Heliport, Prisma } from "@repo/db";
const page = () => {
return (
@@ -11,7 +11,17 @@ const page = () => {
<PaginatedTable
stickyHeaders
prismaModel="heliport"
searchFields={["siteName", "info", "hospital", "designator"]}
getFilter={(searchTerm) =>
({
OR: [
{ siteName: { contains: searchTerm, mode: "insensitive" } },
{ info: { contains: searchTerm, mode: "insensitive" } },
{ hospital: { contains: searchTerm, mode: "insensitive" } },
{ designator: { contains: searchTerm, mode: "insensitive" } },
],
}) as Prisma.HeliportWhereInput
}
showSearch
columns={
[
{

View File

@@ -3,7 +3,7 @@ import { DatabaseBackupIcon } from "lucide-react";
import { PaginatedTable } from "../../../_components/PaginatedTable";
import Link from "next/link";
import { ColumnDef } from "@tanstack/react-table";
import { Keyword } from "@repo/db";
import { Keyword, Prisma } from "@repo/db";
export default () => {
return (
@@ -12,7 +12,16 @@ export default () => {
stickyHeaders
initialOrderBy={[{ id: "category", desc: true }]}
prismaModel="keyword"
searchFields={["name", "abreviation", "description"]}
showSearch
getFilter={(searchTerm) =>
({
OR: [
{ name: { contains: searchTerm, mode: "insensitive" } },
{ abreviation: { contains: searchTerm, mode: "insensitive" } },
{ category: { contains: searchTerm, mode: "insensitive" } },
],
}) as Prisma.KeywordWhereInput
}
columns={
[
{
@@ -41,11 +50,11 @@ export default () => {
}
leftOfSearch={
<span className="flex items-center gap-2">
<DatabaseBackupIcon className="w-5 h-5" /> Stichwörter
<DatabaseBackupIcon className="h-5 w-5" /> Stichwörter
</span>
}
rightOfSearch={
<p className="text-2xl font-semibold text-left flex items-center gap-2 justify-between">
<p className="flex items-center justify-between gap-2 text-left text-2xl font-semibold">
<Link href={"/admin/keyword/new"}>
<button className="btn btn-sm btn-outline btn-primary">Erstellen</button>
</Link>

View File

@@ -2,7 +2,7 @@
import { penaltyColumns as penaltyColumns } from "(app)/admin/penalty/columns";
import { editReport } from "(app)/admin/report/actions";
import { zodResolver } from "@hookform/resolvers/zod";
import { Report as IReport, User } from "@repo/db";
import { Report as IReport, Prisma, User } from "@repo/db";
import { ReportSchema, Report as IReportZod } from "@repo/db/zod";
import { PaginatedTable } from "_components/PaginatedTable";
import { Button } from "_components/ui/Button";
@@ -149,9 +149,11 @@ export const ReportPenalties = ({
CreatedUser: true,
Report: true,
}}
filter={{
reportId: report.id,
}}
getFilter={() =>
({
reportId: report.id,
}) as Prisma.PenaltyWhereInput
}
columns={penaltyColumns}
/>
</div>

View File

@@ -2,7 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { StationOptionalDefaultsSchema } from "@repo/db/zod";
import { useForm } from "react-hook-form";
import { BosUse, ConnectedAircraft, Country, Station, User } from "@repo/db";
import { BosUse, ConnectedAircraft, Country, Prisma, Station, User } from "@repo/db";
import { FileText, LocateIcon, PlaneIcon, UserIcon } from "lucide-react";
import { Input } from "../../../../_components/ui/Input";
import { deleteStation, upsertStation } from "../action";
@@ -198,10 +198,17 @@ export const StationForm = ({ station }: { station?: Station }) => {
Verbundene Piloten
</div>
}
filter={{
stationId: station?.id,
}}
searchFields={["User.firstname", "User.lastname", "User.publicId"]}
getFilter={(searchField) =>
({
stationId: station?.id,
OR: [
{ User: { firstname: { contains: searchField, mode: "insensitive" } } },
{ User: { lastname: { contains: searchField, mode: "insensitive" } } },
{ User: { publicId: { contains: searchField, mode: "insensitive" } } },
],
}) as Prisma.ConnectedAircraftWhereInput
}
showSearch
prismaModel={"connectedAircraft"}
include={{ Station: true, User: true }}
columns={

View File

@@ -3,14 +3,22 @@ import { DatabaseBackupIcon } from "lucide-react";
import { PaginatedTable } from "../../../_components/PaginatedTable";
import Link from "next/link";
import { ColumnDef } from "@tanstack/react-table";
import { Station } from "@repo/db";
import { Prisma, Station } from "@repo/db";
const page = () => {
return (
<>
<PaginatedTable
prismaModel="station"
searchFields={["bosCallsign", "operator"]}
showSearch
getFilter={(searchField) =>
({
OR: [
{ bosCallsign: { contains: searchField, mode: "insensitive" } },
{ operator: { contains: searchField, mode: "insensitive" } },
],
}) as Prisma.StationWhereInput
}
stickyHeaders
columns={
[
@@ -44,11 +52,11 @@ const page = () => {
}
leftOfSearch={
<span className="flex items-center gap-2">
<DatabaseBackupIcon className="w-5 h-5" /> Stationen
<DatabaseBackupIcon className="h-5 w-5" /> Stationen
</span>
}
rightOfSearch={
<p className="text-2xl font-semibold text-left flex items-center gap-2 justify-between">
<p className="flex items-center justify-between gap-2 text-left text-2xl font-semibold">
<Link href={"/admin/station/new"}>
<button className="btn btn-sm btn-outline btn-primary">Erstellen</button>
</Link>

View File

@@ -8,6 +8,7 @@ import {
DiscordAccount,
Penalty,
PERMISSION,
Prisma,
Station,
User,
} from "@repo/db";
@@ -281,9 +282,11 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: Us
</h2>
<PaginatedTable
ref={dispoTableRef}
filter={{
userId: user.id,
}}
getFilter={() =>
({
userId: user.id,
}) as Prisma.ConnectedDispatcherWhereInput
}
prismaModel={"connectedDispatcher"}
initialOrderBy={[
{
@@ -349,9 +352,11 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: Us
</h2>
<PaginatedTable
ref={pilotTableRef}
filter={{
userId: user.id,
}}
getFilter={() =>
({
userId: user.id,
}) as Prisma.ConnectedAircraftWhereInput
}
prismaModel={"connectedAircraft"}
include={{ Station: true }}
initialOrderBy={[
@@ -505,9 +510,7 @@ export const UserPenalties = ({ user }: { user: User }) => {
CreatedUser: true,
Report: true,
}}
filter={{
userId: user.id,
}}
getFilter={() => ({ userId: user.id }) as Prisma.PenaltyWhereInput}
columns={penaltyColumns}
/>
</div>
@@ -529,9 +532,11 @@ export const UserReports = ({ user }: { user: User }) => {
</div>
<PaginatedTable
prismaModel="report"
filter={{
reportedUserId: user.id,
}}
getFilter={() =>
({
reportedUserId: user.id,
}) as Prisma.ReportWhereInput
}
initialOrderBy={[
{
id: "timestamp",
@@ -720,9 +725,7 @@ export const AdminForm = ({
)}
</div>
</div>
<p className="text-sm text-gray-400">
Achtung! Dieser Account ist als Duplikat markiert oder hat Duplikate!
</p>
<p className="text-sm text-gray-400">{user.duplicateReason || "Keine Grund angegeben"}</p>
</div>
)}
{(!!openBans.length || !!openTimebans.length) && (

View File

@@ -3,7 +3,7 @@ import { User2 } from "lucide-react";
import { PaginatedTable } from "../../../_components/PaginatedTable";
import Link from "next/link";
import { ColumnDef } from "@tanstack/react-table";
import { DiscordAccount, User } from "@repo/db";
import { DiscordAccount, Prisma, User } from "@repo/db";
import { useSession } from "next-auth/react";
const AdminUserPage = () => {
@@ -14,7 +14,21 @@ const AdminUserPage = () => {
<PaginatedTable
stickyHeaders
prismaModel="user"
searchFields={["publicId", "firstname", "lastname", "email"]}
showSearch
getFilter={(searchTerm) => {
return {
OR: [
{ firstname: { contains: searchTerm, mode: "insensitive" } },
{ lastname: { contains: searchTerm, mode: "insensitive" } },
{ email: { contains: searchTerm, mode: "insensitive" } },
{
discordAccounts: {
some: { username: { contains: searchTerm, mode: "insensitive" } },
},
},
],
} as Prisma.UserWhereInput;
}}
include={{
discordAccounts: true,
}}

View File

@@ -1,5 +1,5 @@
"use client";
import { Mission, MissionAlertLog, MissionLog, Station } from "@repo/db";
import { Mission, MissionAlertLog, MissionLog, Prisma, Station } from "@repo/db";
import { ColumnDef } from "@tanstack/react-table";
import { Error } from "_components/Error";
import { PaginatedTable } from "_components/PaginatedTable";
@@ -14,19 +14,21 @@ const Page = () => {
return (
<div className="grid grid-cols-6 gap-4">
<div className="col-span-full">
<p className="text-2xl font-semibold text-left flex items-center gap-2">
<NotebookText className="w-5 h-5" /> Einsatzhistorie
<p className="flex items-center gap-2 text-left text-2xl font-semibold">
<NotebookText className="h-5 w-5" /> Einsatzhistorie
</p>
</div>
<div className="card bg-base-200 shadow-xl mb-4 col-span-6">
<div className="card bg-base-200 col-span-6 mb-4 shadow-xl">
<PaginatedTable
prismaModel={"missionOnStationUsers"}
filter={{
userId: session.data?.user?.id ?? "",
Mission: {
state: "finished",
},
}}
getFilter={() =>
({
userId: session.data?.user?.id ?? "",
Mission: {
state: "finished",
},
}) as Prisma.MissionOnStationUsersWhereInput
}
include={{
Station: true,
User: true,

View File

@@ -9,12 +9,13 @@ export interface PaginatedTableRef {
refresh: () => void;
}
interface PaginatedTableProps<TData> extends Omit<SortableTableProps<TData>, "data"> {
interface PaginatedTableProps<TData, TWhere extends object>
extends Omit<SortableTableProps<TData>, "data"> {
prismaModel: keyof PrismaClient;
stickyHeaders?: boolean;
filter?: Record<string, unknown>;
initialRowsPerPage?: number;
searchFields?: string[];
showSearch?: boolean;
getFilter?: (searchTerm: string) => TWhere;
include?: Record<string, boolean>;
strictQuery?: boolean;
leftOfSearch?: React.ReactNode;
@@ -24,11 +25,11 @@ interface PaginatedTableProps<TData> extends Omit<SortableTableProps<TData>, "da
ref?: Ref<PaginatedTableRef>;
}
export function PaginatedTable<TData>({
export function PaginatedTable<TData, TWhere extends object>({
prismaModel,
initialRowsPerPage = 30,
searchFields = [],
filter,
getFilter,
showSearch = false,
include,
ref,
strictQuery = false,
@@ -38,7 +39,7 @@ export function PaginatedTable<TData>({
leftOfPagination,
supressQuery,
...restProps
}: PaginatedTableProps<TData>) {
}: PaginatedTableProps<TData, TWhere>) {
const [data, setData] = useState<TData[]>([]);
const [rowsPerPage, setRowsPerPage] = useState(initialRowsPerPage);
const [page, setPage] = useState(0);
@@ -63,16 +64,14 @@ export function PaginatedTable<TData>({
return;
}
setLoading(true);
getData(
prismaModel,
rowsPerPage,
page * rowsPerPage,
searchTerm,
searchFields,
filter,
getData({
model: prismaModel,
limit: rowsPerPage,
offset: page * rowsPerPage,
where: getFilter ? getFilter(searchTerm) : undefined,
include,
orderBy,
strictQuery
select: strictQuery
? restProps.columns
.filter(
(col): col is { accessorKey: string } =>
@@ -84,7 +83,7 @@ export function PaginatedTable<TData>({
return acc;
}, {})
: undefined,
)
})
.then((result) => {
if (result) {
setData(result.data);
@@ -100,8 +99,7 @@ export function PaginatedTable<TData>({
rowsPerPage,
page,
searchTerm,
searchFields,
filter,
getFilter,
include,
orderBy,
strictQuery,
@@ -119,19 +117,19 @@ export function PaginatedTable<TData>({
if (supressQuery) return;
setLoading(true);
}, [searchTerm, page, rowsPerPage, orderBy, filter, setLoading, supressQuery]);
}, [searchTerm, page, rowsPerPage, orderBy, getFilter, setLoading, supressQuery]);
useDebounce(
() => {
refreshTableData();
},
500,
[searchTerm, page, rowsPerPage, orderBy, filter],
[searchTerm, page, rowsPerPage, orderBy, getFilter],
);
return (
<div className="m-4 space-y-4">
{(rightOfSearch || leftOfSearch || searchFields.length > 0) && (
{(rightOfSearch || leftOfSearch || showSearch) && (
<div
className={cn(
"sticky z-20 flex items-center gap-2 py-2",
@@ -142,7 +140,7 @@ export function PaginatedTable<TData>({
<div>{leftOfSearch}</div>
<div>{loading && <span className="loading loading-dots loading-md" />}</div>
</div>
{searchFields.length > 0 && (
{showSearch && (
<input
type="text"
placeholder="Suchen..."

View File

@@ -2,56 +2,30 @@
"use server";
import { prisma, PrismaClient } from "@repo/db";
export async function getData(
model: keyof PrismaClient,
limit: number,
offset: number,
searchTerm: string,
searchFields: string[],
filter?: Record<string, any>,
include?: Record<string, boolean>,
orderBy?: Record<string, "asc" | "desc">,
select?: Record<string, any>,
) {
if (!model || !prisma[model]) {
export async function getData<Twhere>({
model,
limit,
offset,
where,
include,
orderBy,
select,
}: {
model: keyof PrismaClient;
limit: number;
offset: number;
where: Twhere;
include?: Record<string, boolean>;
orderBy?: Record<string, "asc" | "desc">;
select?: Record<string, any>;
}) {
if (!model || !(prisma as any)[model]) {
return { data: [], total: 0 };
}
const formattedId = searchTerm.match(/^VAR(\d+)$/)?.[1];
const delegate = (prisma as any)[model];
const where = searchTerm
? {
OR: [
formattedId ? { id: formattedId } : undefined,
...searchFields.map((field) => {
if (field.includes(".")) {
const parts: string[] = field.split(".");
// Helper function to build nested object
const buildNestedFilter = (parts: string[], index = 0): any => {
if (index === parts.length - 1) {
// Reached the last part - add the contains filter
return { [parts[index] as string]: { contains: searchTerm } };
}
// For intermediate levels, nest the next level
return { [parts[index] as string]: buildNestedFilter(parts, index + 1) };
};
return buildNestedFilter(parts);
}
return { [field]: { contains: searchTerm } };
}),
].filter(Boolean),
...filter,
}
: { ...filter };
if (!prisma[model]) {
return { data: [], total: 0 };
}
const data = await (prisma[model] as any).findMany({
const data = await delegate.findMany({
where,
orderBy,
take: limit,
@@ -60,7 +34,7 @@ export async function getData(
select,
});
const total = await (prisma[model] as any).count({ where });
const total = await delegate.count({ where });
return { data, total };
}

View File

@@ -1,33 +0,0 @@
import { NextResponse } from "next/server";
import { prisma } from "@repo/db";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const publicId = searchParams.get("publicId")?.trim();
const email = searchParams.get("email")?.trim()?.toLowerCase();
if (!publicId && !email) {
return NextResponse.json({ error: "Missing query" }, { status: 400 });
}
try {
const user = await prisma.user.findFirst({
where: {
OR: [publicId ? { publicId } : undefined, email ? { email } : undefined].filter(
Boolean,
) as any,
},
select: {
id: true,
publicId: true,
firstname: true,
lastname: true,
isBanned: true,
},
});
if (!user) return NextResponse.json({ user: null }, { status: 200 });
return NextResponse.json({ user }, { status: 200 });
} catch (e) {
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -1,5 +1,5 @@
"use client";
import { User } from "@repo/db";
import { Prisma, User } from "@repo/db";
import { ColumnDef } from "@tanstack/react-table";
import { PaginatedTable } from "_components/PaginatedTable";
@@ -7,13 +7,24 @@ export default function () {
return (
<PaginatedTable
strictQuery
searchFields={["firstname", "lastname", "vatsimCid"]}
showSearch
prismaModel={"user"}
filter={{
vatsimCid: {
not: "",
},
}}
getFilter={(searchTerm) =>
({
AND: [
{
vatsimCid: {
not: "",
},
OR: [
{ firstname: { contains: searchTerm, mode: "insensitive" } },
{ lastname: { contains: searchTerm, mode: "insensitive" } },
{ vatsimCid: { contains: searchTerm, mode: "insensitive" } },
],
},
],
}) as Prisma.UserWhereInput
}
leftOfSearch={<h1 className="text-2xl font-bold">Vatsim-Nutzer</h1>}
columns={
[