chore: update reservation platform to newest codebase
This commit is contained in:
32
packages/reservation-platform/src/app/admin/about/page.tsx
Normal file
32
packages/reservation-platform/src/app/admin/about/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Über MYP",
|
||||
};
|
||||
|
||||
export default async function AdminPage() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Über MYP</CardTitle>
|
||||
<CardDescription>
|
||||
<i className="italic">MYP — Manage Your Printer</i>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="gap-y-2 flex flex-col">
|
||||
<p className="max-w-[80ch]">
|
||||
<strong>MYP</strong> ist eine Webanwendung zur Reservierung von 3D-Druckern. Sie wurde im Rahmen des
|
||||
Abschlussprojektes der Fachinformatiker Ausbildung für Daten- und Prozessanalyse für die Technische
|
||||
Berufsausbildung des Mercedes-Benz Werkes Berlin-Marienfelde entwickelt.
|
||||
</p>
|
||||
<p>
|
||||
© 2024{" "}
|
||||
<a href="https://linkedin.com/in/torben-haack" target="_blank" rel="noreferrer">
|
||||
Torben Haack
|
||||
</a>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/utils/styles";
|
||||
import { FileIcon, HeartIcon, LayoutDashboardIcon, PrinterIcon, UsersIcon, WrenchIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
interface AdminSite {
|
||||
name: string;
|
||||
path: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AdminSidebar() {
|
||||
const pathname = usePathname();
|
||||
const adminSites: AdminSite[] = [
|
||||
{
|
||||
name: "Dashboard",
|
||||
path: "/admin",
|
||||
icon: <LayoutDashboardIcon className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
name: "Benutzer",
|
||||
path: "/admin/users",
|
||||
icon: <UsersIcon className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
name: "Drucker",
|
||||
path: "/admin/printers",
|
||||
icon: <PrinterIcon className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
name: "Druckaufträge",
|
||||
path: "/admin/jobs",
|
||||
icon: <FileIcon className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
name: "Einstellungen",
|
||||
path: "/admin/settings",
|
||||
icon: <WrenchIcon className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
name: "Über MYP",
|
||||
path: "/admin/about",
|
||||
icon: <HeartIcon className="w-4 h-4" />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ul className="w-full">
|
||||
{adminSites.map((site) => (
|
||||
<li key={site.path}>
|
||||
<Link
|
||||
href={site.path}
|
||||
className={cn("flex items-center gap-2 p-2 rounded hover:bg-muted", {
|
||||
"font-semibold": pathname === site.path,
|
||||
})}
|
||||
>
|
||||
{site.icon}
|
||||
<span>{site.name}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart } from "@tremor/react";
|
||||
|
||||
interface AbortReasonsBarChartProps {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: temporary fix
|
||||
data: any[];
|
||||
}
|
||||
|
||||
export function AbortReasonsBarChart(props: AbortReasonsBarChartProps) {
|
||||
const { data } = props;
|
||||
|
||||
const dataFormatter = (number: number) => Intl.NumberFormat("de-DE").format(number).toString();
|
||||
|
||||
return (
|
||||
<BarChart
|
||||
className="mt-6"
|
||||
data={data}
|
||||
index="name"
|
||||
categories={["Anzahl"]}
|
||||
colors={["blue"]}
|
||||
valueFormatter={dataFormatter}
|
||||
yAxisWidth={48}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { DonutChart, Legend } from "@tremor/react";
|
||||
|
||||
const dataFormatter = (number: number) => Intl.NumberFormat("de-DE").format(number).toString();
|
||||
|
||||
interface LoadFactorChartProps {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: temp. fix
|
||||
data: any[];
|
||||
}
|
||||
export function LoadFactorChart(props: LoadFactorChartProps) {
|
||||
const { data } = props;
|
||||
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<DonutChart data={data} variant="donut" colors={["green", "yellow"]} valueFormatter={dataFormatter} />
|
||||
<Legend categories={["Frei", "Belegt"]} colors={["green", "yellow"]} className="max-w-xs" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { DonutChart, Legend } from "@tremor/react";
|
||||
|
||||
const dataFormatter = (number: number) => Intl.NumberFormat("de-DE").format(number).toString();
|
||||
|
||||
interface PrintJobsDonutProps {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: temp. fix
|
||||
data: any[];
|
||||
}
|
||||
export function PrintJobsDonut(props: PrintJobsDonutProps) {
|
||||
const { data } = props;
|
||||
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<DonutChart data={data} variant="donut" colors={["green", "red", "yellow"]} valueFormatter={dataFormatter} />
|
||||
<Legend
|
||||
categories={["Abgeschlossen", "Abgebrochen", "Ausstehend"]}
|
||||
colors={["green", "red", "yellow"]}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
packages/reservation-platform/src/app/admin/jobs/page.tsx
Normal file
35
packages/reservation-platform/src/app/admin/jobs/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { columns } from "@/app/my/jobs/columns";
|
||||
import { JobsTable } from "@/app/my/jobs/data-table";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { db } from "@/server/db";
|
||||
import { printJobs } from "@/server/db/schema";
|
||||
import { desc } from "drizzle-orm";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Alle Druckaufträge",
|
||||
};
|
||||
|
||||
export default async function AdminJobsPage() {
|
||||
const allJobs = await db.query.printJobs.findMany({
|
||||
orderBy: [desc(printJobs.startAt)],
|
||||
with: {
|
||||
user: true,
|
||||
printer: true,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row justify-between items-center">
|
||||
<div>
|
||||
<CardTitle>Druckaufträge</CardTitle>
|
||||
<CardDescription>Alle Druckaufträge</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<JobsTable columns={columns} data={allJobs} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
32
packages/reservation-platform/src/app/admin/layout.tsx
Normal file
32
packages/reservation-platform/src/app/admin/layout.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { AdminSidebar } from "@/app/admin/admin-sidebar";
|
||||
import { validateRequest } from "@/server/auth";
|
||||
import { UserRole } from "@/server/auth/permissions";
|
||||
import { guard, is_not } from "@/utils/heimdall";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default async function AdminLayout(props: AdminLayoutProps) {
|
||||
const { children } = props;
|
||||
const { user } = await validateRequest();
|
||||
|
||||
if (guard(user, is_not, UserRole.ADMIN)) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex flex-1 flex-col gap-4">
|
||||
<div className="mx-auto grid w-full gap-2">
|
||||
<h1 className="text-3xl font-semibold">Admin</h1>
|
||||
</div>
|
||||
<div className="mx-auto grid w-full items-start gap-4 md:gap-6 md:grid-cols-[180px_1fr] lg:grid-cols-[250px_1fr]">
|
||||
<nav className="grid gap-4 text-sm">
|
||||
<AdminSidebar />
|
||||
</nav>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
128
packages/reservation-platform/src/app/admin/page.tsx
Normal file
128
packages/reservation-platform/src/app/admin/page.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { AbortReasonsBarChart } from "@/app/admin/charts/abort-reasons";
|
||||
import { LoadFactorChart } from "@/app/admin/charts/load-factor";
|
||||
import { PrintJobsDonut } from "@/app/admin/charts/printjobs-donut";
|
||||
import { DataCard } from "@/components/data-card";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { db } from "@/server/db";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Admin Dashboard",
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminPage() {
|
||||
const allPrintJobs = await db.query.printJobs.findMany({
|
||||
with: {
|
||||
printer: true,
|
||||
},
|
||||
});
|
||||
|
||||
const totalAmountOfPrintJobs = allPrintJobs.length;
|
||||
|
||||
const now = new Date();
|
||||
const completedPrintJobs = allPrintJobs.filter((job) => {
|
||||
if (job.aborted) return false;
|
||||
const endAt = new Date(job.startAt).getTime() + job.durationInMinutes * 1000 * 60;
|
||||
return endAt < now.getTime();
|
||||
}).length;
|
||||
const abortedPrintJobs = allPrintJobs.filter((job) => job.aborted).length;
|
||||
const pendingPrintJobs = totalAmountOfPrintJobs - completedPrintJobs - abortedPrintJobs;
|
||||
|
||||
const abortedPrintJobsReasons = Object.entries(
|
||||
allPrintJobs.reduce((accumulator: Record<string, number>, job) => {
|
||||
if (job.aborted && job.abortReason) {
|
||||
if (!accumulator[job.abortReason]) {
|
||||
accumulator[job.abortReason] = 1;
|
||||
} else {
|
||||
accumulator[job.abortReason]++;
|
||||
}
|
||||
}
|
||||
return accumulator;
|
||||
}, {}),
|
||||
).map(([name, count]) => ({ name, Anzahl: count }));
|
||||
|
||||
const mostAbortedPrinter = allPrintJobs.reduce((prev, current) => (prev.aborted > current.aborted ? prev : current));
|
||||
|
||||
const mostUsedPrinter = allPrintJobs.reduce((prev, current) =>
|
||||
prev.durationInMinutes > current.durationInMinutes ? prev : current,
|
||||
);
|
||||
|
||||
const allPrinters = await db.query.printers.findMany();
|
||||
|
||||
const freePrinters = allPrinters.filter((printer) => {
|
||||
const jobs = allPrintJobs.filter((job) => job.printerId === printer.id);
|
||||
const now = new Date();
|
||||
const inUse = jobs.some((job) => {
|
||||
const endAt = new Date(job.startAt).getTime() + job.durationInMinutes * 1000 * 60;
|
||||
return endAt > now.getTime();
|
||||
});
|
||||
return !inUse;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabs defaultValue={"@general"} className="flex flex-col gap-4 items-start">
|
||||
<TabsList className="bg-neutral-100 w-full py-6">
|
||||
<TabsTrigger value="@general">Allgemein</TabsTrigger>
|
||||
{allPrinters.map((printer) => (
|
||||
<TabsTrigger key={printer.id} value={printer.id}>
|
||||
{printer.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<TabsContent value="@general" className="w-full">
|
||||
<div className="flex flex-col lg:grid lg:grid-cols-2 gap-4">
|
||||
<DataCard title="Drucker mit meisten Reservierungen" value={mostUsedPrinter.printer.name} icon="Printer" />
|
||||
<DataCard title="Drucker mit meisten Abbrüchen" value={mostAbortedPrinter.printer.name} icon="Printer" />
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Druckaufträge</CardTitle>
|
||||
<CardDescription>nach Status</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PrintJobsDonut
|
||||
data={[
|
||||
{ name: "Abgeschlossen", value: completedPrintJobs },
|
||||
{ name: "Abgebrochen", value: abortedPrintJobs },
|
||||
{ name: "Ausstehend", value: pendingPrintJobs },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="w-full ">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Auslastung: <span>{((1 - freePrinters.length / allPrinters.length) * 100).toFixed(2)}%</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<LoadFactorChart
|
||||
data={[
|
||||
{ name: "Frei", value: freePrinters.length },
|
||||
{ name: "Belegt", value: allPrinters.length - freePrinters.length },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="w-full col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Abgebrochene Druckaufträge nach Abbruchgrund</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AbortReasonsBarChart data={abortedPrintJobsReasons} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
{allPrinters.map((printer) => (
|
||||
<TabsContent key={printer.id} value={printer.id}>
|
||||
{printer.description}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
import type { printers } from "@/server/db/schema";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import { ArrowUpDown, MoreHorizontal, PencilIcon } from "lucide-react";
|
||||
|
||||
import { EditPrinterDialogContent, EditPrinterDialogTrigger } from "@/app/admin/printers/dialogs/edit-printer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { type PrinterStatus, translatePrinterStatus } from "@/utils/printers";
|
||||
import { useState } from "react";
|
||||
|
||||
// This type is used to define the shape of our data.
|
||||
// You can use a Zod schema here if you want.
|
||||
|
||||
export const columns: ColumnDef<InferSelectModel<typeof printers>>[] = [
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
|
||||
ID
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Name",
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: "Beschreibung",
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue("status");
|
||||
const translated = translatePrinterStatus(status as PrinterStatus);
|
||||
|
||||
return translated;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const printer = row.original;
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Menu öffnen</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Aktionen</DropdownMenuLabel>
|
||||
<DropdownMenuItem asChild>ABC</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<EditPrinterDialogTrigger>
|
||||
<div className="flex items-center gap-2">
|
||||
<PencilIcon className="w-4 h-4" />
|
||||
<span>Bearbeiten</span>
|
||||
</div>
|
||||
</EditPrinterDialogTrigger>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<EditPrinterDialogContent setOpen={setOpen} printer={printer} />
|
||||
</Dialog>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type ColumnDef,
|
||||
type ColumnFiltersState,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { SlidersHorizontalIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center py-4">
|
||||
<Input
|
||||
placeholder="Name filtern..."
|
||||
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
|
||||
onChange={(event) => table.getColumn("name")?.setFilterValue(event.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="ml-auto flex items-center gap-2">
|
||||
<SlidersHorizontalIcon className="h-4 w-4" />
|
||||
<span>Spalten</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
Keine Ergebnisse gefunden.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-2 py-4">
|
||||
<Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
|
||||
Zurück
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
|
||||
Nächste Seite
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { PrinterForm } from "@/app/admin/printers/form";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { useState } from "react";
|
||||
|
||||
interface CreatePrinterDialogProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function CreatePrinterDialog(props: CreatePrinterDialogProps) {
|
||||
const { children } = props;
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Drucker erstellen</DialogTitle>
|
||||
</DialogHeader>
|
||||
<PrinterForm setOpen={setOpen} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { deletePrinter } from "@/server/actions/printers";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
|
||||
interface DeletePrinterDialogProps {
|
||||
printerId: string;
|
||||
setOpen: (state: boolean) => void;
|
||||
}
|
||||
export function DeletePrinterDialog(props: DeletePrinterDialogProps) {
|
||||
const { printerId, setOpen } = props;
|
||||
const { toast } = useToast();
|
||||
|
||||
async function onSubmit() {
|
||||
toast({
|
||||
description: "Drucker wird gelöscht...",
|
||||
});
|
||||
try {
|
||||
await deletePrinter(printerId);
|
||||
toast({
|
||||
description: "Drucker wurde gelöscht.",
|
||||
});
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
toast({
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
description: "Ein unbekannter Fehler ist aufgetreten.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" className="gap-2 flex items-center">
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
<span>Drucker löschen</span>
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Bist Du dir sicher?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Diese Aktion kann nicht rückgängig gemacht werden. Der Drucker und die damit verbundenen Daten werden
|
||||
unwiderruflich gelöscht.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||
<AlertDialogAction className="bg-red-500" onClick={onSubmit}>
|
||||
Ja, löschen
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { PrinterForm } from "@/app/admin/printers/form";
|
||||
import { DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import type { InferResultType } from "@/utils/drizzle";
|
||||
|
||||
interface EditPrinterDialogTriggerProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EditPrinterDialogTrigger(props: EditPrinterDialogTriggerProps) {
|
||||
const { children } = props;
|
||||
|
||||
return <DialogTrigger asChild>{children}</DialogTrigger>;
|
||||
}
|
||||
|
||||
interface EditPrinterDialogContentProps {
|
||||
printer: InferResultType<"printers">;
|
||||
setOpen: (open: boolean) => void;
|
||||
}
|
||||
export function EditPrinterDialogContent(props: EditPrinterDialogContentProps) {
|
||||
const { printer, setOpen } = props;
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Drucker bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
<PrinterForm setOpen={setOpen} printer={printer} />
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
192
packages/reservation-platform/src/app/admin/printers/form.tsx
Normal file
192
packages/reservation-platform/src/app/admin/printers/form.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
import { DeletePrinterDialog } from "@/app/admin/printers/dialogs/delete-printer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DialogClose } from "@/components/ui/dialog";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { createPrinter, updatePrinter } from "@/server/actions/printers";
|
||||
import type { InferResultType } from "@/utils/drizzle";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { SaveIcon, XCircleIcon } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
export const formSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, {
|
||||
message: "Der Name muss mindestens 2 Zeichen lang sein.",
|
||||
})
|
||||
.max(50),
|
||||
description: z
|
||||
.string()
|
||||
.min(2, {
|
||||
message: "Die Beschreibung muss mindestens 2 Zeichen lang sein.",
|
||||
})
|
||||
.max(50),
|
||||
status: z.coerce.number().int().min(0).max(1),
|
||||
});
|
||||
|
||||
interface PrinterFormProps {
|
||||
printer?: InferResultType<"printers">;
|
||||
setOpen: (state: boolean) => void;
|
||||
}
|
||||
|
||||
export function PrinterForm(props: PrinterFormProps) {
|
||||
const { printer, setOpen } = props;
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: printer?.name ?? "",
|
||||
description: printer?.description ?? "",
|
||||
status: printer?.status ?? 0,
|
||||
},
|
||||
});
|
||||
|
||||
// 2. Define a submit handler.
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
// TODO: create or update
|
||||
if (printer) {
|
||||
toast({
|
||||
description: "Drucker wird aktualisiert...",
|
||||
});
|
||||
|
||||
// Update
|
||||
try {
|
||||
await updatePrinter(printer.id, {
|
||||
description: values.description,
|
||||
name: values.name,
|
||||
status: values.status,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
|
||||
toast({
|
||||
description: "Drucker wurde aktualisiert.",
|
||||
variant: "default",
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
toast({
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
description: "Ein unbekannter Fehler ist aufgetreten.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
toast({
|
||||
description: "Drucker wird erstellt...",
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
// Create
|
||||
try {
|
||||
await createPrinter({
|
||||
description: values.description,
|
||||
name: values.name,
|
||||
status: values.status,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
|
||||
toast({
|
||||
description: "Drucker wurde erstellt.",
|
||||
variant: "default",
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
toast({
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
description: "Ein unbekannter Fehler ist aufgetreten.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Anycubic Kobra 2 Pro" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Bitte gib einen eindeutigen Namen für den Drucker ein.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Beschreibung</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="80x80x80 Druckfläche, langsam" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Füge eine kurze Beschreibung des Druckers hinzu.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Status</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value.toString()}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a verified email to display" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={"0"}>Verfügbar</SelectItem>
|
||||
<SelectItem value={"1"}>Außer Betrieb</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>Wähle den aktuellen Status des Druckers.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-between items-center">
|
||||
{printer && <DeletePrinterDialog setOpen={setOpen} printerId={printer?.id} />}
|
||||
{!printer && (
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary" className="gap-2 flex items-center">
|
||||
<XCircleIcon className="w-4 h-4" />
|
||||
<span>Abbrechen</span>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
)}
|
||||
<Button type="submit" className="gap-2 flex items-center">
|
||||
<SaveIcon className="w-4 h-4" />
|
||||
<span>Speichern</span>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { columns } from "@/app/admin/printers/columns";
|
||||
import { DataTable } from "@/app/admin/printers/data-table";
|
||||
import { CreatePrinterDialog } from "@/app/admin/printers/dialogs/create-printer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { db } from "@/server/db";
|
||||
import { PlusCircleIcon } from "lucide-react";
|
||||
|
||||
export default async function AdminPage() {
|
||||
const data = await db.query.printers.findMany();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row justify-between items-center">
|
||||
<div>
|
||||
<CardTitle>Druckerverwaltung</CardTitle>
|
||||
<CardDescription>Suche, Bearbeite, Lösche und Erstelle Drucker</CardDescription>
|
||||
</div>
|
||||
<CreatePrinterDialog>
|
||||
<Button variant={"default"} className="flex gap-2 items-center">
|
||||
<PlusCircleIcon className="w-4 h-4" />
|
||||
<span>Drucker erstellen</span>
|
||||
</Button>
|
||||
</CreatePrinterDialog>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable columns={columns} data={data} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import fs from "node:fs";
|
||||
|
||||
export async function GET() {
|
||||
return new Response(fs.readFileSync("./db/sqlite.db"));
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import Link from "next/link";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Systemeinstellungen",
|
||||
};
|
||||
|
||||
export default function AdminPage() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Einstellungen</CardTitle>
|
||||
<CardDescription>Systemeinstellungen</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex gap-8 items-center">
|
||||
<p>Datenbank herunterladen</p>
|
||||
<Button variant="default" asChild>
|
||||
<Link href="/admin/settings/download" target="_blank">
|
||||
Herunterladen
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
137
packages/reservation-platform/src/app/admin/users/columns.tsx
Normal file
137
packages/reservation-platform/src/app/admin/users/columns.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { type UserRole, translateUserRole } from "@/server/auth/permissions";
|
||||
import type { users } from "@/server/db/schema";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
MailIcon,
|
||||
MessageCircleIcon,
|
||||
MoreHorizontal,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
EditUserDialogContent,
|
||||
EditUserDialogRoot,
|
||||
EditUserDialogTrigger,
|
||||
} from "@/app/admin/users/dialog";
|
||||
|
||||
// This type is used to define the shape of our data.
|
||||
// You can use a Zod schema here if you want.
|
||||
export type User = {
|
||||
id: string;
|
||||
github_id: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
role: string;
|
||||
};
|
||||
|
||||
export const columns: ColumnDef<InferSelectModel<typeof users>>[] = [
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
ID
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "github_id",
|
||||
header: "GitHub ID",
|
||||
},
|
||||
{
|
||||
accessorKey: "username",
|
||||
header: "Username",
|
||||
},
|
||||
{
|
||||
accessorKey: "displayName",
|
||||
header: "Name",
|
||||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
header: "E-Mail",
|
||||
},
|
||||
{
|
||||
accessorKey: "role",
|
||||
header: "Rolle",
|
||||
cell: ({ row }) => {
|
||||
const role = row.getValue("role");
|
||||
const translated = translateUserRole(role as UserRole);
|
||||
|
||||
return translated;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const user = row.original;
|
||||
|
||||
return (
|
||||
<EditUserDialogRoot>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Menu öffnen</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Aktionen</DropdownMenuLabel>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
target="_blank"
|
||||
href={generateTeamsChatURL(user.email)}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<MessageCircleIcon className="w-4 h-4" />
|
||||
<span>Teams-Chat öffnen</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
target="_blank"
|
||||
href={generateEMailURL(user.email)}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<MailIcon className="w-4 h-4" />
|
||||
<span>E-Mail schicken</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<EditUserDialogTrigger />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<EditUserDialogContent user={user as User} />
|
||||
</EditUserDialogRoot>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function generateTeamsChatURL(email: string) {
|
||||
return `https://teams.microsoft.com/l/chat/0/0?users=${email}`;
|
||||
}
|
||||
|
||||
function generateEMailURL(email: string) {
|
||||
return `mailto:${email}`;
|
||||
}
|
||||
135
packages/reservation-platform/src/app/admin/users/data-table.tsx
Normal file
135
packages/reservation-platform/src/app/admin/users/data-table.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type ColumnDef,
|
||||
type ColumnFiltersState,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { SlidersHorizontalIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center py-4">
|
||||
<Input
|
||||
placeholder="E-Mails filtern..."
|
||||
value={(table.getColumn("email")?.getFilterValue() as string) ?? ""}
|
||||
onChange={(event) => table.getColumn("email")?.setFilterValue(event.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="ml-auto flex items-center gap-2">
|
||||
<SlidersHorizontalIcon className="h-4 w-4" />
|
||||
<span>Spalten</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
Keine Ergebnisse gefunden.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-2 py-4">
|
||||
<Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
|
||||
Zurück
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
|
||||
Nächste Seite
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
packages/reservation-platform/src/app/admin/users/dialog.tsx
Normal file
56
packages/reservation-platform/src/app/admin/users/dialog.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { User } from "@/app/admin/users/columns";
|
||||
import { ProfileForm } from "@/app/admin/users/form";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { PencilIcon } from "lucide-react";
|
||||
|
||||
interface EditUserDialogRootProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EditUserDialogRoot(props: EditUserDialogRootProps) {
|
||||
const { children } = props;
|
||||
|
||||
return <Dialog>{children}</Dialog>;
|
||||
}
|
||||
|
||||
export function EditUserDialogTrigger() {
|
||||
return (
|
||||
<DialogTrigger className="flex gap-2 items-center">
|
||||
<PencilIcon className="w-4 h-4" />
|
||||
<span>Benutzer bearbeiten</span>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
interface EditUserDialogContentProps {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export function EditUserDialogContent(props: EditUserDialogContentProps) {
|
||||
const { user } = props;
|
||||
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Benutzer bearbeiten</DialogTitle>
|
||||
<DialogDescription>
|
||||
<strong>Hinweis:</strong> In den seltensten Fällen sollten die Daten
|
||||
eines Benutzers geändert werden. Dies kann zu unerwarteten Problemen
|
||||
führen.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ProfileForm user={user} />
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
212
packages/reservation-platform/src/app/admin/users/form.tsx
Normal file
212
packages/reservation-platform/src/app/admin/users/form.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import type { User } from "@/app/admin/users/columns";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DialogClose } from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { deleteUser, updateUser } from "@/server/actions/users";
|
||||
import type { UserRole } from "@/server/auth/permissions";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { SaveIcon, TrashIcon } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
export const formSchema = z.object({
|
||||
username: z
|
||||
.string()
|
||||
.min(2, {
|
||||
message: "Der Benutzername muss mindestens 2 Zeichen lang sein.",
|
||||
})
|
||||
.max(50),
|
||||
displayName: z
|
||||
.string()
|
||||
.min(2, {
|
||||
message: "Der Anzeigename muss mindestens 2 Zeichen lang sein.",
|
||||
})
|
||||
.max(50),
|
||||
email: z.string().email(),
|
||||
role: z.enum(["admin", "user", "guest"]),
|
||||
});
|
||||
|
||||
interface ProfileFormProps {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export function ProfileForm(props: ProfileFormProps) {
|
||||
const { user } = props;
|
||||
const { toast } = useToast();
|
||||
|
||||
// 1. Define your form.
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
email: user.email,
|
||||
role: user.role as UserRole,
|
||||
},
|
||||
});
|
||||
|
||||
// 2. Define a submit handler.
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
toast({ description: "Benutzerprofil wird aktualisiert..." });
|
||||
|
||||
await updateUser(user.id, values);
|
||||
|
||||
toast({ description: "Benutzerprofil wurde aktualisiert." });
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Benutzername</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="MAXMUS" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Nur in Ausnahmefällen sollte der Benutzername geändert werden.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="displayName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Anzeigename</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Max Mustermann" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Der Anzeigename darf frei verändert werden.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>E-Mail Adresse</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="max.mustermann@mercedes-benz.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Nur in Ausnahmefällen sollte die E-Mail Adresse geändert werden.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="role"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Benutzerrolle</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a verified email to display" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">Administrator</SelectItem>
|
||||
<SelectItem value="user">Benutzer</SelectItem>
|
||||
<SelectItem value="guest">Gast</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Die Benutzerrolle bestimmt die Berechtigungen des Benutzers.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-between items-center">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
className="gap-2 flex items-center"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
<span>Benutzer löschen</span>
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Bist du dir sicher?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Diese Aktion kann nicht rückgängig gemacht werden. Das
|
||||
Benutzerprofil und die damit verbundenen Daten werden
|
||||
unwiderruflich gelöscht.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-red-500"
|
||||
onClick={() => {
|
||||
toast({ description: "Benutzerprofil wird gelöscht..." });
|
||||
deleteUser(user.id);
|
||||
toast({ description: "Benutzerprofil wurde gelöscht." });
|
||||
}}
|
||||
>
|
||||
Ja, löschen
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<DialogClose asChild>
|
||||
<Button type="submit" className="gap-2 flex items-center">
|
||||
<SaveIcon className="w-4 h-4" />
|
||||
<span>Speichern</span>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
26
packages/reservation-platform/src/app/admin/users/page.tsx
Normal file
26
packages/reservation-platform/src/app/admin/users/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { columns } from "@/app/admin/users/columns";
|
||||
import { DataTable } from "@/app/admin/users/data-table";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { db } from "@/server/db";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Alle Benutzer",
|
||||
};
|
||||
|
||||
export default async function AdminPage() {
|
||||
const data = await db.query.users.findMany();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Benutzerverwaltung</CardTitle>
|
||||
<CardDescription>Suche, Bearbeite und Lösche Benutzer</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable columns={columns} data={data} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user