v2.0.5 #141
2
.vscode/extensions.json
vendored
2
.vscode/extensions.json
vendored
@@ -1,7 +1,5 @@
|
|||||||
{
|
{
|
||||||
"recommendations": [
|
"recommendations": [
|
||||||
"EthanSK.restore-terminals",
|
|
||||||
"dbaeumer.vscode-eslint",
|
"dbaeumer.vscode-eslint",
|
||||||
"VisualStudioExptTeam.vscodeintellicode"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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={
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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={
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
37
apps/hub/app/(app)/admin/user/[id]/duplicate/page.tsx
Normal file
37
apps/hub/app/(app)/admin/user/[id]/duplicate/page.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" />;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ const page = async () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
orderBy: {
|
||||||
|
id: "desc",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const appointments = await prisma.eventAppointment.findMany({
|
const appointments = await prisma.eventAppointment.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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={
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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[]
|
||||||
|
|||||||
Reference in New Issue
Block a user