v2.0.5 #141

Merged
PxlLoewe merged 5 commits from staging into release 2025-12-27 15:23:33 +00:00
27 changed files with 684 additions and 275 deletions

View File

@@ -1,7 +1,5 @@
{ {
"recommendations": [ "recommendations": [
"EthanSK.restore-terminals",
"dbaeumer.vscode-eslint", "dbaeumer.vscode-eslint",
"VisualStudioExptTeam.vscodeintellicode"
] ]
} }

View File

@@ -2,7 +2,7 @@ import { Marker, Polyline, useMap } from "react-leaflet";
import { DivIcon, Marker as LMarker, Popup as LPopup } from "leaflet"; import { DivIcon, Marker as LMarker, Popup as LPopup } from "leaflet";
import { useMapStore } from "_store/mapStore"; import { useMapStore } from "_store/mapStore";
import { Fragment, useCallback, useEffect, useRef, useState, useMemo } from "react"; import { Fragment, useCallback, useEffect, useRef, useState, useMemo } from "react";
import { cn } from "@repo/shared-components"; import { checkSimulatorConnected, cn } from "@repo/shared-components";
import { ChevronsRightLeft, House, MessageSquareText, Minimize2 } from "lucide-react"; import { ChevronsRightLeft, House, MessageSquareText, Minimize2 } from "lucide-react";
import { SmartPopup, calculateAnchor, useSmartPopup } from "_components/SmartPopup"; import { SmartPopup, calculateAnchor, useSmartPopup } from "_components/SmartPopup";
import FMSStatusHistory, { import FMSStatusHistory, {
@@ -396,11 +396,27 @@ const AircraftMarker = ({ aircraft }: { aircraft: ConnectedAircraft & { Station:
}; };
export const AircraftLayer = () => { export const AircraftLayer = () => {
const { data: aircrafts } = useQuery({ const [aircrafts, setAircrafts] = useState<(ConnectedAircraft & { Station: Station })[]>([]);
queryKey: ["aircrafts-map"],
queryFn: () => getConnectedAircraftsAPI(), useEffect(() => {
refetchInterval: 10_000, const fetchAircrafts = async () => {
}); try {
const res = await fetch("/api/aircrafts");
if (!res.ok) {
throw new Error("Failed to fetch aircrafts");
}
const data: (ConnectedAircraft & { Station: Station })[] = await res.json();
setAircrafts(data.filter((a) => checkSimulatorConnected(a)));
} catch (error) {
console.error("Failed to fetch aircrafts:", error);
}
};
fetchAircrafts();
const interval = setInterval(fetchAircrafts, 10_000);
return () => clearInterval(interval);
}, []);
const { setMap } = useMapStore((state) => state); const { setMap } = useMapStore((state) => state);
const map = useMap(); const map = useMap();
const { const {

View File

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

View File

@@ -3,7 +3,7 @@ import { DatabaseBackupIcon } from "lucide-react";
import { PaginatedTable } from "../../../_components/PaginatedTable"; import { PaginatedTable } from "../../../_components/PaginatedTable";
import Link from "next/link"; import Link from "next/link";
import { ColumnDef } from "@tanstack/react-table"; import { ColumnDef } from "@tanstack/react-table";
import { Keyword } from "@repo/db"; import { Keyword, Prisma } from "@repo/db";
export default () => { export default () => {
return ( return (
@@ -12,7 +12,15 @@ export default () => {
stickyHeaders stickyHeaders
initialOrderBy={[{ id: "title", desc: true }]} initialOrderBy={[{ id: "title", desc: true }]}
prismaModel="changelog" prismaModel="changelog"
searchFields={["title"]} showSearch
getFilter={(search) =>
({
OR: [
{ title: { contains: search, mode: "insensitive" } },
{ description: { contains: search, mode: "insensitive" } },
],
}) as Prisma.ChangelogWhereInput
}
columns={ 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 { EventAppointmentOptionalDefaults, InputJsonValueType } from "@repo/db/zod";
import { ColumnDef } from "@tanstack/react-table"; import { ColumnDef } from "@tanstack/react-table";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
@@ -64,7 +64,7 @@ export const AppointmentModal = ({
</div> </div>
<div> <div>
<PaginatedTable <PaginatedTable
hide={appointmentForm.watch("id") === undefined} supressQuery={appointmentForm.watch("id") === undefined}
ref={participantTableRef} ref={participantTableRef}
columns={ columns={
[ [
@@ -167,9 +167,11 @@ export const AppointmentModal = ({
] as ColumnDef<Participant>[] ] as ColumnDef<Participant>[]
} }
prismaModel={"participant"} prismaModel={"participant"}
filter={{ getFilter={() =>
eventAppointmentId: appointmentForm.watch("id"), ({
}} eventAppointmentId: appointmentForm.watch("id")!,
}) as Prisma.ParticipantWhereInput
}
include={{ User: true }} include={{ User: true }}
leftOfPagination={ leftOfPagination={
<div className="flex gap-2"> <div className="flex gap-2">

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { zodResolver } from "@hookform/resolvers/zod"; 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 { import {
EventAppointmentOptionalDefaults, EventAppointmentOptionalDefaults,
EventAppointmentOptionalDefaultsSchema, EventAppointmentOptionalDefaultsSchema,
@@ -159,9 +159,11 @@ export const Form = ({ event }: { event?: Event }) => {
<PaginatedTable <PaginatedTable
ref={appointmentsTableRef} ref={appointmentsTableRef}
prismaModel={"eventAppointment"} prismaModel={"eventAppointment"}
filter={{ getFilter={() =>
eventId: event?.id, ({
}} eventId: event?.id,
}) as Prisma.EventAppointmentWhereInput
}
include={{ include={{
Presenter: true, Presenter: true,
Participants: true, Participants: true,
@@ -250,92 +252,107 @@ export const Form = ({ event }: { event?: Event }) => {
{!form.watch("hasPresenceEvents") ? ( {!form.watch("hasPresenceEvents") ? (
<div className="card bg-base-200 col-span-6 shadow-xl"> <div className="card bg-base-200 col-span-6 shadow-xl">
<div className="card-body"> <div className="card-body">
<PaginatedTable {
leftOfSearch={ <PaginatedTable
<h2 className="card-title"> leftOfSearch={
<UserIcon className="h-5 w-5" /> Teilnehmer <h2 className="card-title">
</h2> <UserIcon className="h-5 w-5" /> Teilnehmer
} </h2>
searchFields={["User.firstname", "User.lastname", "User.publicId"]} }
ref={appointmentsTableRef} ref={appointmentsTableRef}
prismaModel={"participant"} prismaModel={"participant"}
filter={{ showSearch
eventId: event?.id, getFilter={(searchTerm) =>
}} ({
include={{ OR: [
User: true, {
}} User: {
columns={ OR: [
[ { firstname: { contains: searchTerm, mode: "insensitive" } },
{ { lastname: { contains: searchTerm, mode: "insensitive" } },
header: "Vorname", { publicId: { contains: searchTerm, mode: "insensitive" } },
accessorKey: "User.firstname", ],
cell: ({ row }) => { },
return ( },
<Link ],
className="hover:underline" }) as Prisma.ParticipantWhereInput
href={`/admin/user/${row.original.User.id}`} }
> include={{
{row.original.User.firstname} User: true,
</Link> }}
); supressQuery={!event}
}, columns={
}, [
{ {
header: "Nachname", header: "Vorname",
accessorKey: "User.lastname", accessorKey: "User.firstname",
cell: ({ row }) => { cell: ({ row }) => {
return ( return (
<Link <Link
className="hover:underline" className="hover:underline"
href={`/admin/user/${row.original.User.id}`} href={`/admin/user/${row.original.User.id}`}
>
{row.original.User.lastname}
</Link>
);
},
},
{
header: "VAR-Nummer",
accessorKey: "User.publicId",
cell: ({ row }) => {
return (
<Link
className="hover:underline"
href={`/admin/user/${row.original.User.id}`}
>
{row.original.User.publicId}
</Link>
);
},
},
{
header: "Moodle Kurs abgeschlossen",
accessorKey: "finisherMoodleCurseCompleted",
},
{
header: "Aktionen",
cell: ({ row }) => {
return (
<div className="flex gap-2">
<button
onSubmit={() => false}
type="button"
onClick={() => {
participantForm.reset(row.original);
participantModal.current?.showModal();
}}
className="btn btn-sm btn-outline"
> >
Bearbeiten {row.original.User.firstname}
</button> </Link>
</div> );
); },
}, },
}, {
] as ColumnDef<Participant & { User: User }>[] header: "Nachname",
} accessorKey: "User.lastname",
/> cell: ({ row }) => {
return (
<Link
className="hover:underline"
href={`/admin/user/${row.original.User.id}`}
>
{row.original.User.lastname}
</Link>
);
},
},
{
header: "VAR-Nummer",
accessorKey: "User.publicId",
cell: ({ row }) => {
return (
<Link
className="hover:underline"
href={`/admin/user/${row.original.User.id}`}
>
{row.original.User.publicId}
</Link>
);
},
},
{
header: "Moodle Kurs abgeschlossen",
accessorKey: "finisherMoodleCurseCompleted",
},
{
header: "Aktionen",
cell: ({ row }) => {
return (
<div className="flex gap-2">
<button
onSubmit={() => false}
type="button"
onClick={() => {
participantForm.reset(row.original);
participantModal.current?.showModal();
}}
className="btn btn-sm btn-outline"
>
Bearbeiten
</button>
</div>
);
},
},
] as ColumnDef<Participant & { User: User }>[]
}
/>
}
</div> </div>
</div> </div>
) : null} ) : null}

View File

@@ -3,7 +3,7 @@ import { DatabaseBackupIcon } from "lucide-react";
import { PaginatedTable } from "../../../_components/PaginatedTable"; import { PaginatedTable } from "../../../_components/PaginatedTable";
import Link from "next/link"; import Link from "next/link";
import { ColumnDef } from "@tanstack/react-table"; import { ColumnDef } from "@tanstack/react-table";
import { Heliport } from "@repo/db"; import { Heliport, Prisma } from "@repo/db";
const page = () => { const page = () => {
return ( return (
@@ -11,7 +11,17 @@ const page = () => {
<PaginatedTable <PaginatedTable
stickyHeaders stickyHeaders
prismaModel="heliport" 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={ columns={
[ [
{ {

View File

@@ -3,7 +3,7 @@ import { DatabaseBackupIcon } from "lucide-react";
import { PaginatedTable } from "../../../_components/PaginatedTable"; import { PaginatedTable } from "../../../_components/PaginatedTable";
import Link from "next/link"; import Link from "next/link";
import { ColumnDef } from "@tanstack/react-table"; import { ColumnDef } from "@tanstack/react-table";
import { Keyword } from "@repo/db"; import { Keyword, Prisma } from "@repo/db";
export default () => { export default () => {
return ( return (
@@ -12,7 +12,16 @@ export default () => {
stickyHeaders stickyHeaders
initialOrderBy={[{ id: "category", desc: true }]} initialOrderBy={[{ id: "category", desc: true }]}
prismaModel="keyword" 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={ columns={
[ [
{ {
@@ -41,11 +50,11 @@ export default () => {
} }
leftOfSearch={ leftOfSearch={
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<DatabaseBackupIcon className="w-5 h-5" /> Stichwörter <DatabaseBackupIcon className="h-5 w-5" /> Stichwörter
</span> </span>
} }
rightOfSearch={ 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"}> <Link href={"/admin/keyword/new"}>
<button className="btn btn-sm btn-outline btn-primary">Erstellen</button> <button className="btn btn-sm btn-outline btn-primary">Erstellen</button>
</Link> </Link>

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import {
DiscordAccount, DiscordAccount,
Penalty, Penalty,
PERMISSION, PERMISSION,
Prisma,
Station, Station,
User, User,
} from "@repo/db"; } from "@repo/db";
@@ -76,6 +77,21 @@ export const ProfileForm: React.FC<ProfileFormProps> = ({ user }: ProfileFormPro
className="card-body" className="card-body"
onSubmit={form.handleSubmit(async (values) => { onSubmit={form.handleSubmit(async (values) => {
if (!values.id) return; if (!values.id) return;
if (values.id === session.data?.user.id && values.permissions !== user.permissions) {
toast.error("Du kannst deine eigenen Berechtigungen nicht ändern.");
return;
}
if (values.permissions?.some((perm) => !session.data?.user.permissions.includes(perm))) {
toast.error("Du kannst Berechtigungen nicht hinzufügen, die du selbst nicht besitzt.");
return;
}
const removedPermissions =
user.permissions?.filter((perm) => !values.permissions?.includes(perm)) || [];
if (removedPermissions.some((perm) => !session.data?.user.permissions.includes(perm))) {
toast.error("Du kannst Berechtigungen nicht entfernen, die du selbst nicht besitzt.");
return;
}
await editUser(values.id, { await editUser(values.id, {
...values, ...values,
email: values.email.toLowerCase(), email: values.email.toLowerCase(),
@@ -266,10 +282,18 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: Us
</h2> </h2>
<PaginatedTable <PaginatedTable
ref={dispoTableRef} ref={dispoTableRef}
filter={{ getFilter={() =>
userId: user.id, ({
}} userId: user.id,
}) as Prisma.ConnectedDispatcherWhereInput
}
prismaModel={"connectedDispatcher"} prismaModel={"connectedDispatcher"}
initialOrderBy={[
{
id: "loginTime",
desc: true,
},
]}
columns={ columns={
[ [
{ {
@@ -328,11 +352,19 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: Us
</h2> </h2>
<PaginatedTable <PaginatedTable
ref={pilotTableRef} ref={pilotTableRef}
filter={{ getFilter={() =>
userId: user.id, ({
}} userId: user.id,
}) as Prisma.ConnectedAircraftWhereInput
}
prismaModel={"connectedAircraft"} prismaModel={"connectedAircraft"}
include={{ Station: true }} include={{ Station: true }}
initialOrderBy={[
{
id: "loginTime",
desc: true,
},
]}
columns={ columns={
[ [
{ {
@@ -478,9 +510,7 @@ export const UserPenalties = ({ user }: { user: User }) => {
CreatedUser: true, CreatedUser: true,
Report: true, Report: true,
}} }}
filter={{ getFilter={() => ({ userId: user.id }) as Prisma.PenaltyWhereInput}
userId: user.id,
}}
columns={penaltyColumns} columns={penaltyColumns}
/> />
</div> </div>
@@ -502,9 +532,17 @@ export const UserReports = ({ user }: { user: User }) => {
</div> </div>
<PaginatedTable <PaginatedTable
prismaModel="report" prismaModel="report"
filter={{ getFilter={() =>
reportedUserId: user.id, ({
}} reportedUserId: user.id,
}) as Prisma.ReportWhereInput
}
initialOrderBy={[
{
id: "timestamp",
desc: true,
},
]}
include={{ include={{
Sender: true, Sender: true,
Reported: true, Reported: true,
@@ -517,7 +555,7 @@ export const UserReports = ({ user }: { user: User }) => {
interface AdminFormProps { interface AdminFormProps {
discordAccount?: DiscordAccount; discordAccount?: DiscordAccount;
user: User; user: User & { CanonicalUser?: User | null; Duplicates?: User[] | null };
dispoTime: { dispoTime: {
hours: number; hours: number;
minutes: number; minutes: number;
@@ -639,7 +677,57 @@ export const AdminForm = ({
</div> </div>
)} )}
</div> </div>
{session?.user.permissions.includes("ADMIN_USER_ADVANCED") && (
<div className="mt-2 space-y-1">
<Link href={`/admin/user/${user.id}/duplicate`}>
<Button className="btn-sm btn-outline w-full">
<LockKeyhole className="h-4 w-4" /> Duplikat markieren & sperren
</Button>
</Link>
</div>
)}
</div> </div>
{(user.CanonicalUser || (user.Duplicates && user.Duplicates.length > 0)) && (
<div role="alert" className="alert alert-error alert-outline flex flex-col">
<div className="flex items-center gap-2">
<TriangleAlert />
<div>
{user.CanonicalUser && (
<div>
<h3 className="text-lg font-semibold">Als Duplikat markiert</h3>
<p>
Dieser Account wurde als Duplikat von{" "}
<Link
href={`/admin/user/${user.CanonicalUser.id}`}
className="link link-hover font-semibold"
>
{user.CanonicalUser.firstname} {user.CanonicalUser.lastname} (
{user.CanonicalUser.publicId})
</Link>{" "}
markiert.
</p>
</div>
)}
{user.Duplicates && user.Duplicates.length > 0 && (
<div className="mt-2">
<h3 className="text-lg font-semibold">Duplikate erkannt</h3>
<p>Folgende Accounts wurden als Duplikate dieses Accounts markiert:</p>
<ul className="ml-4 mt-1 list-inside list-disc">
{user.Duplicates.map((duplicate) => (
<li key={duplicate.id}>
<Link href={`/admin/user/${duplicate.id}`} className="link link-hover">
{duplicate.firstname} {duplicate.lastname} ({duplicate.publicId})
</Link>
</li>
))}
</ul>
</div>
)}
</div>
</div>
<p className="text-sm text-gray-400">{user.duplicateReason || "Keine Grund angegeben"}</p>
</div>
)}
{(!!openBans.length || !!openTimebans.length) && ( {(!!openBans.length || !!openTimebans.length) && (
<div role="alert" className="alert alert-warning alert-outline flex flex-col"> <div role="alert" className="alert alert-warning alert-outline flex flex-col">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -667,6 +755,7 @@ export const AdminForm = ({
</p> </p>
</div> </div>
)} )}
<h2 className="card-title"> <h2 className="card-title">
<ChartBarBigIcon className="h-5 w-5" /> Aktivität <ChartBarBigIcon className="h-5 w-5" /> Aktivität
</h2> </h2>

View File

@@ -0,0 +1,109 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useQuery } from "@tanstack/react-query";
import { getUser, markDuplicate } from "(app)/admin/user/action";
import { Button } from "@repo/shared-components";
import { Select } from "_components/ui/Select";
import toast from "react-hot-toast";
import { TriangleAlert } from "lucide-react";
import { useState } from "react";
const DuplicateSchema = z.object({
canonicalUserId: z.string().min(1, "Bitte Nutzer auswählen"),
reason: z.string().max(500).optional(),
});
export const DuplicateForm = ({ duplicateUserId }: { duplicateUserId: string }) => {
const form = useForm<z.infer<typeof DuplicateSchema>>({
resolver: zodResolver(DuplicateSchema),
defaultValues: { canonicalUserId: "", reason: "" },
});
const [search, setSearch] = useState("");
const { data: users } = useQuery({
queryKey: ["duplicate-search"],
queryFn: async () =>
getUser({
OR: [
{ firstname: { contains: search, mode: "insensitive" } },
{ lastname: { contains: search, mode: "insensitive" } },
{ publicId: { contains: search, mode: "insensitive" } },
{ email: { contains: search, mode: "insensitive" } },
],
}),
enabled: search.length > 0,
refetchOnWindowFocus: false,
});
return (
<form
className="flex flex-wrap gap-3"
onSubmit={form.handleSubmit(async (values) => {
try {
// find selected canonical user by id to obtain publicId
const canonical = (users || []).find((u) => u.id === values.canonicalUserId);
if (!canonical) {
toast.error("Bitte wähle einen Original-Account aus.");
return;
}
await markDuplicate({
duplicateUserId,
canonicalPublicId: canonical.publicId,
reason: values.reason,
});
toast.success("Duplikat verknüpft und Nutzer gesperrt.");
} catch (e: unknown) {
const message =
typeof e === "object" && e && "message" in e
? (e as { message?: string }).message || "Fehler beim Verknüpfen"
: "Fehler beim Verknüpfen";
toast.error(message);
}
})}
>
<div className="card bg-base-200 flex-1 basis-[800px] shadow-xl">
<div className="card-body">
<h2 className="card-title">
<TriangleAlert /> Duplikat markieren & sperren
</h2>
<Select
form={form}
name="canonicalUserId"
label="Original-Nutzer suchen & auswählen"
onInputChange={(v) => setSearch(String(v))}
options={
users?.map((u) => ({
label: `${u.firstname} ${u.lastname} (${u.publicId})`,
value: u.id,
})) || [{ label: "Kein Nutzer gefunden", value: "", disabled: true }]
}
/>
<label className="floating-label w-full">
<span className="flex items-center gap-2 text-lg">Grund (optional)</span>
<input
{...form.register("reason")}
type="text"
className="input input-bordered w-full"
placeholder="Begründung/Audit-Hinweis"
/>
</label>
</div>
</div>
<div className="card bg-base-200 flex-1 basis-[800px] shadow-xl">
<div className="card-body">
<div className="flex w-full gap-4">
<Button
isLoading={form.formState.isSubmitting}
type="submit"
className="btn btn-primary flex-1"
>
Als Duplikat verknüpfen & sperren
</Button>
</div>
</div>
</div>
</form>
);
};

View File

@@ -0,0 +1,37 @@
import { prisma } from "@repo/db";
import { DuplicateForm } from "./_components/DuplicateForm";
import { PersonIcon } from "@radix-ui/react-icons";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const user = await prisma.user.findUnique({
where: { id },
select: { id: true, firstname: true, lastname: true, publicId: true },
});
if (!user) {
return (
<div className="card bg-base-200 shadow-xl">
<div className="card-body">Nutzer nicht gefunden</div>
</div>
);
}
return (
<>
<div className="my-3">
<div className="text-left">
<Link href={`/admin/user/${user.id}`} className="link-hover l-0 text-gray-500">
<ArrowLeft className="mb-1 mr-1 inline h-4 w-4" />
Zurück zum Nutzer
</Link>
</div>
<p className="text-left text-2xl font-semibold">
<PersonIcon className="mr-2 inline h-5 w-5" /> Duplikat für {user.firstname}{" "}
{user.lastname} #{user.publicId}
</p>
</div>
<DuplicateForm duplicateUserId={user.id} />
</>
);
}

View File

@@ -18,6 +18,8 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
}, },
include: { include: {
discordAccounts: true, discordAccounts: true,
CanonicalUser: true,
Duplicates: true,
}, },
}); });
if (!user) return <Error statusCode={404} title="User not found" />; if (!user) return <Error statusCode={404} title="User not found" />;

View File

@@ -2,6 +2,7 @@
import { prisma, Prisma } from "@repo/db"; import { prisma, Prisma } from "@repo/db";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { sendMailByTemplate } from "../../../../helper/mail"; import { sendMailByTemplate } from "../../../../helper/mail";
import { getServerSession } from "api/auth/[...nextauth]/auth";
export const getUser = async (where: Prisma.UserWhereInput) => { export const getUser = async (where: Prisma.UserWhereInput) => {
return await prisma.user.findMany({ return await prisma.user.findMany({
@@ -82,3 +83,52 @@ export const sendVerificationLink = async (userId: string) => {
code, code,
}); });
}; };
export const markDuplicate = async (params: {
duplicateUserId: string;
canonicalPublicId: string;
reason?: string;
}) => {
// Then in your function:
const session = await getServerSession();
if (!session?.user) throw new Error("Nicht authentifiziert");
const canonical = await prisma.user.findUnique({
where: { publicId: params.canonicalPublicId },
select: { id: true },
});
if (!canonical) throw new Error("Original-Account (canonical) nicht gefunden");
if (canonical.id === params.duplicateUserId)
throw new Error("Duplikat und Original dürfen nicht identisch sein");
const updated = await prisma.user.update({
where: { id: params.duplicateUserId },
data: {
canonicalUserId: canonical.id,
isBanned: true,
duplicateDetectedAt: new Date(),
duplicateReason: params.reason ?? undefined,
},
});
await prisma.penalty.create({
data: {
userId: params.duplicateUserId,
type: "BAN",
reason: `Account als Duplikat von #${params.canonicalPublicId} markiert.`,
createdUserId: session.user.id,
},
});
return updated;
};
export const clearDuplicateLink = async (duplicateUserId: string) => {
const updated = await prisma.user.update({
where: { id: duplicateUserId },
data: {
canonicalUserId: null,
duplicateDetectedAt: null,
duplicateReason: null,
},
});
return updated;
};

View File

@@ -3,17 +3,35 @@ import { User2 } from "lucide-react";
import { PaginatedTable } from "../../../_components/PaginatedTable"; import { PaginatedTable } from "../../../_components/PaginatedTable";
import Link from "next/link"; import Link from "next/link";
import { ColumnDef } from "@tanstack/react-table"; import { ColumnDef } from "@tanstack/react-table";
import { User } from "@repo/db"; import { DiscordAccount, Prisma, User } from "@repo/db";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
const AdminUserPage = () => { const AdminUserPage = () => {
const { data: session } = useSession(); const { data: session } = useSession();
return ( return (
<> <>
<PaginatedTable <PaginatedTable
stickyHeaders stickyHeaders
prismaModel="user" 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,
}}
initialOrderBy={[ initialOrderBy={[
{ {
id: "publicId", id: "publicId",
@@ -51,6 +69,16 @@ const AdminUserPage = () => {
); );
}, },
}, },
{
header: "Discord",
cell(props) {
const discord = props.row.original.discordAccounts;
if (discord.length === 0) {
return <span className="text-gray-700">Nicht verbunden</span>;
}
return <span>{discord.map((d) => d.username).join(", ")}</span>;
},
},
...(session?.user.permissions.includes("ADMIN_USER_ADVANCED") ...(session?.user.permissions.includes("ADMIN_USER_ADVANCED")
? [ ? [
{ {
@@ -69,7 +97,7 @@ const AdminUserPage = () => {
</div> </div>
), ),
}, },
] as ColumnDef<User>[] ] as ColumnDef<User & { discordAccounts: DiscordAccount[] }>[]
} // Define the columns for the user table } // Define the columns for the user table
leftOfSearch={ leftOfSearch={
<p className="flex items-center gap-2 text-left text-2xl font-semibold"> <p className="flex items-center gap-2 text-left text-2xl font-semibold">

View File

@@ -27,6 +27,10 @@ const page = async () => {
}, },
}, },
}, },
orderBy: {
id: "desc",
},
}); });
const appointments = await prisma.eventAppointment.findMany({ const appointments = await prisma.eventAppointment.findMany({
where: { where: {

View File

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

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { DiscordAccount, Penalty, User } from "@repo/db"; import { DiscordAccount, Penalty, Report, User } from "@repo/db";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
@@ -326,9 +326,17 @@ export const SocialForm = ({
); );
}; };
export const DeleteForm = ({ user, penaltys }: { user: User; penaltys: Penalty[] }) => { export const DeleteForm = ({
user,
penaltys,
reports,
}: {
user: User;
penaltys: Penalty[];
reports: Report[];
}) => {
const router = useRouter(); const router = useRouter();
const userCanDelete = penaltys.length === 0 && !user.isBanned; const userCanDelete = penaltys.length === 0 && !user.isBanned && reports.length === 0;
return ( return (
<div className="card-body"> <div className="card-body">
<h2 className="card-title mb-5"> <h2 className="card-title mb-5">
@@ -338,8 +346,9 @@ export const DeleteForm = ({ user, penaltys }: { user: User; penaltys: Penalty[]
<div className="text-left"> <div className="text-left">
<h2 className="text-warning text-lg">Du kannst dein Konto zurzeit nicht löschen!</h2> <h2 className="text-warning text-lg">Du kannst dein Konto zurzeit nicht löschen!</h2>
<p className="text-sm text-gray-400"> <p className="text-sm text-gray-400">
Scheinbar hast du aktuell zurzeit aktive Strafen. Um unsere Community zu schützen kannst Scheinbar hast du Strafen oder Reports in deinem Profil hinterlegt. Um unsere Community
du einen Account erst löschen wenn deine Strafe nicht mehr aktiv ist zu schützen kannst du deinen Account nicht löschen. Bitte erstelle ein Support-Ticket,
wenn du Fragen dazu hast.
</p> </p>
</div> </div>
)} )}

View File

@@ -20,36 +20,35 @@ export default async function Page() {
const userPenaltys = await prisma.penalty.findMany({ const userPenaltys = await prisma.penalty.findMany({
where: { where: {
userId: session.user.id, userId: session.user.id,
until: {
gte: new Date(),
},
type: {
in: ["TIME_BAN", "BAN"],
},
suspended: false,
}, },
}); });
const userReports = await prisma.report.findMany({
where: {
reportedUserId: session.user.id,
},
});
if (!user) return <Error statusCode={401} title="Dein Account wurde nicht gefunden" />; if (!user) return <Error statusCode={401} title="Dein Account wurde nicht gefunden" />;
const discordAccount = user?.discordAccounts[0]; const discordAccount = user?.discordAccounts[0];
return ( return (
<div className="grid grid-cols-6 gap-4"> <div className="grid grid-cols-6 gap-4">
<div className="col-span-full"> <div className="col-span-full">
<p className="text-2xl font-semibold text-left flex items-center gap-2"> <p className="flex items-center gap-2 text-left text-2xl font-semibold">
<GearIcon className="w-5 h-5" /> Einstellungen <GearIcon className="h-5 w-5" /> Einstellungen
</p> </p>
</div> </div>
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3"> <div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<ProfileForm user={user} penaltys={userPenaltys} discordAccount={discordAccount} /> <ProfileForm user={user} penaltys={userPenaltys} discordAccount={discordAccount} />
</div> </div>
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3"> <div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<SocialForm discordAccount={discordAccount} user={user} /> <SocialForm discordAccount={discordAccount} user={user} />
</div> </div>
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3"> <div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<PasswordForm /> <PasswordForm />
</div> </div>
<div className="card bg-base-200 shadow-xl mb-4 col-span-6 xl:col-span-3"> <div className="card bg-base-200 col-span-6 mb-4 shadow-xl xl:col-span-3">
<DeleteForm user={user} penaltys={userPenaltys} /> <DeleteForm user={user} penaltys={userPenaltys} reports={userReports} />
</div> </div>
</div> </div>
); );

View File

@@ -9,26 +9,27 @@ export interface PaginatedTableRef {
refresh: () => void; refresh: () => void;
} }
interface PaginatedTableProps<TData> extends Omit<SortableTableProps<TData>, "data"> { interface PaginatedTableProps<TData, TWhere extends object>
extends Omit<SortableTableProps<TData>, "data"> {
prismaModel: keyof PrismaClient; prismaModel: keyof PrismaClient;
stickyHeaders?: boolean; stickyHeaders?: boolean;
filter?: Record<string, unknown>;
initialRowsPerPage?: number; initialRowsPerPage?: number;
searchFields?: string[]; showSearch?: boolean;
getFilter?: (searchTerm: string) => TWhere;
include?: Record<string, boolean>; include?: Record<string, boolean>;
strictQuery?: boolean; strictQuery?: boolean;
leftOfSearch?: React.ReactNode; leftOfSearch?: React.ReactNode;
rightOfSearch?: React.ReactNode; rightOfSearch?: React.ReactNode;
leftOfPagination?: React.ReactNode; leftOfPagination?: React.ReactNode;
hide?: boolean; supressQuery?: boolean;
ref?: Ref<PaginatedTableRef>; ref?: Ref<PaginatedTableRef>;
} }
export function PaginatedTable<TData>({ export function PaginatedTable<TData, TWhere extends object>({
prismaModel, prismaModel,
initialRowsPerPage = 30, initialRowsPerPage = 30,
searchFields = [], getFilter,
filter, showSearch = false,
include, include,
ref, ref,
strictQuery = false, strictQuery = false,
@@ -36,9 +37,9 @@ export function PaginatedTable<TData>({
leftOfSearch, leftOfSearch,
rightOfSearch, rightOfSearch,
leftOfPagination, leftOfPagination,
hide, supressQuery,
...restProps ...restProps
}: PaginatedTableProps<TData>) { }: PaginatedTableProps<TData, TWhere>) {
const [data, setData] = useState<TData[]>([]); const [data, setData] = useState<TData[]>([]);
const [rowsPerPage, setRowsPerPage] = useState(initialRowsPerPage); const [rowsPerPage, setRowsPerPage] = useState(initialRowsPerPage);
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
@@ -58,17 +59,19 @@ export function PaginatedTable<TData>({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const refreshTableData = useCallback(async () => { const refreshTableData = useCallback(async () => {
if (supressQuery) {
setLoading(false);
return;
}
setLoading(true); setLoading(true);
getData( getData({
prismaModel, model: prismaModel,
rowsPerPage, limit: rowsPerPage,
page * rowsPerPage, offset: page * rowsPerPage,
searchTerm, where: getFilter ? getFilter(searchTerm) : undefined,
searchFields,
filter,
include, include,
orderBy, orderBy,
strictQuery select: strictQuery
? restProps.columns ? restProps.columns
.filter( .filter(
(col): col is { accessorKey: string } => (col): col is { accessorKey: string } =>
@@ -80,7 +83,7 @@ export function PaginatedTable<TData>({
return acc; return acc;
}, {}) }, {})
: undefined, : undefined,
) })
.then((result) => { .then((result) => {
if (result) { if (result) {
setData(result.data); setData(result.data);
@@ -91,12 +94,12 @@ export function PaginatedTable<TData>({
setLoading(false); setLoading(false);
}); });
}, [ }, [
supressQuery,
prismaModel, prismaModel,
rowsPerPage, rowsPerPage,
page, page,
searchTerm, searchTerm,
searchFields, getFilter,
filter,
include, include,
orderBy, orderBy,
strictQuery, strictQuery,
@@ -111,31 +114,33 @@ export function PaginatedTable<TData>({
// useEffect to show loading spinner // useEffect to show loading spinner
useEffect(() => { useEffect(() => {
if (supressQuery) return;
setLoading(true); setLoading(true);
}, [searchTerm, page, rowsPerPage, orderBy, filter, setLoading]); }, [searchTerm, page, rowsPerPage, orderBy, getFilter, setLoading, supressQuery]);
useDebounce( useDebounce(
() => { () => {
refreshTableData(); refreshTableData();
}, },
500, 500,
[searchTerm, page, rowsPerPage, orderBy, filter], [searchTerm, page, rowsPerPage, orderBy, getFilter],
); );
return ( return (
<div className="space-y-4 m-4"> <div className="m-4 space-y-4">
{(rightOfSearch || leftOfSearch || searchFields.length > 0) && ( {(rightOfSearch || leftOfSearch || showSearch) && (
<div <div
className={cn( className={cn(
"flex items-center gap-2 sticky py-2 z-20", "sticky z-20 flex items-center gap-2 py-2",
stickyHeaders && "sticky top-0 bg-base-100/80 backdrop-blur border-b", stickyHeaders && "bg-base-100/80 sticky top-0 border-b backdrop-blur",
)} )}
> >
<div className="flex-1 flex gap-2"> <div className="flex flex-1 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>
</div> </div>
{searchFields.length > 0 && ( {showSearch && (
<input <input
type="text" type="text"
placeholder="Suchen..." placeholder="Suchen..."
@@ -150,22 +155,14 @@ export function PaginatedTable<TData>({
<div className="flex justify-center">{rightOfSearch}</div> <div className="flex justify-center">{rightOfSearch}</div>
</div> </div>
)} )}
{!hide && (
<SortableTable <SortableTable data={data} prismaModel={prismaModel} setOrderBy={setOrderBy} {...restProps} />
data={data} <div className="items-between flex">
prismaModel={prismaModel}
setOrderBy={setOrderBy}
{...restProps}
/>
)}
<div className="flex items-between">
{leftOfPagination} {leftOfPagination}
{!hide && ( <>
<> <RowsPerPage rowsPerPage={rowsPerPage} setRowsPerPage={setRowsPerPage} />
<RowsPerPage rowsPerPage={rowsPerPage} setRowsPerPage={setRowsPerPage} /> <Pagination totalPages={Math.ceil(total / rowsPerPage)} page={page} setPage={setPage} />
<Pagination totalPages={Math.ceil(total / rowsPerPage)} page={page} setPage={setPage} /> </>
</>
)}
</div> </div>
</div> </div>
); );

View File

@@ -2,56 +2,30 @@
"use server"; "use server";
import { prisma, PrismaClient } from "@repo/db"; import { prisma, PrismaClient } from "@repo/db";
export async function getData( export async function getData<Twhere>({
model: keyof PrismaClient, model,
limit: number, limit,
offset: number, offset,
searchTerm: string, where,
searchFields: string[], include,
filter?: Record<string, any>, orderBy,
include?: Record<string, boolean>, select,
orderBy?: Record<string, "asc" | "desc">, }: {
select?: Record<string, any>, model: keyof PrismaClient;
) { limit: number;
if (!model || !prisma[model]) { 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 }; return { data: [], total: 0 };
} }
const formattedId = searchTerm.match(/^VAR(\d+)$/)?.[1]; const delegate = (prisma as any)[model];
const where = searchTerm const data = await delegate.findMany({
? {
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({
where, where,
orderBy, orderBy,
take: limit, take: limit,
@@ -60,7 +34,7 @@ export async function getData(
select, select,
}); });
const total = await (prisma[model] as any).count({ where }); const total = await delegate.count({ where });
return { data, total }; return { data, total };
} }

View File

@@ -31,6 +31,7 @@ const customStyles: StylesConfig<OptionType, false> = {
backgroundColor: state.isSelected ? "hsl(var(--p))" : "hsl(var(--b1))", backgroundColor: state.isSelected ? "hsl(var(--p))" : "hsl(var(--b1))",
color: "var(--color-primary-content)", color: "var(--color-primary-content)",
"&:hover": { backgroundColor: "var(--color-base-200)" }, // DaisyUI secondary color "&:hover": { backgroundColor: "var(--color-base-200)" }, // DaisyUI secondary color
cursor: "pointer",
}), }),
multiValueLabel: (provided) => ({ multiValueLabel: (provided) => ({
...provided, ...provided,
@@ -49,6 +50,11 @@ const customStyles: StylesConfig<OptionType, false> = {
backgroundColor: "var(--color-base-100)", backgroundColor: "var(--color-base-100)",
borderRadius: "0.5rem", borderRadius: "0.5rem",
}), }),
input: (provided) => ({
...provided,
color: "var(--color-primary-content)",
cursor: "text",
}),
}; };
const SelectCom = <T extends FieldValues>({ const SelectCom = <T extends FieldValues>({
@@ -61,7 +67,7 @@ const SelectCom = <T extends FieldValues>({
}: SelectProps<T>) => { }: SelectProps<T>) => {
return ( return (
<div> <div>
<span className="label-text text-lg flex items-center gap-2">{label}</span> <span className="label-text flex items-center gap-2 text-lg">{label}</span>
<SelectTemplate <SelectTemplate
onChange={(newValue: any) => { onChange={(newValue: any) => {
if (Array.isArray(newValue)) { if (Array.isArray(newValue)) {

View File

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

View File

@@ -0,0 +1,7 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "canonical_user_id" TEXT,
ADD COLUMN "duplicate_detected_at" TIMESTAMP(3),
ADD COLUMN "duplicate_reason" TEXT;
-- AddForeignKey
ALTER TABLE "users" ADD CONSTRAINT "users_canonical_user_id_fkey" FOREIGN KEY ("canonical_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -60,6 +60,12 @@ model User {
createdAt DateTime @default(now()) @map(name: "created_at") createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @default(now()) @map(name: "updated_at") updatedAt DateTime @default(now()) @map(name: "updated_at")
isBanned Boolean @default(false) @map(name: "is_banned") isBanned Boolean @default(false) @map(name: "is_banned")
// Duplicate handling:
canonicalUserId String? @map(name: "canonical_user_id")
CanonicalUser User? @relation("CanonicalUser", fields: [canonicalUserId], references: [id])
Duplicates User[] @relation("CanonicalUser")
duplicateDetectedAt DateTime? @map(name: "duplicate_detected_at")
duplicateReason String? @map(name: "duplicate_reason")
// relations: // relations:
oauthTokens OAuthToken[] oauthTokens OAuthToken[]
discordAccounts DiscordAccount[] discordAccounts DiscordAccount[]