chore: update reservation platform to newest codebase

This commit is contained in:
TOHAACK
2024-05-27 11:49:02 +02:00
parent ea9283e167
commit 3fd586caaf
130 changed files with 9395 additions and 3636 deletions

View 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 &mdash; 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>
&copy; 2024{" "}
<a href="https://linkedin.com/in/torben-haack" target="_blank" rel="noreferrer">
Torben Haack
</a>
</p>
</CardContent>
</Card>
);
}

View File

@@ -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>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View File

@@ -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>
);
},
},
];

View 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="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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,5 @@
import fs from "node:fs";
export async function GET() {
return new Response(fs.readFileSync("./db/sqlite.db"));
}

View File

@@ -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>
);
}

View 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}`;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -1,7 +0,0 @@
import NextAuth from "next-auth";
import { authOptions } from "@/server/auth";
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View File

@@ -0,0 +1,34 @@
import { db } from "@/server/db";
import { printJobs } from "@/server/db/schema";
import { eq } from "drizzle-orm";
interface RemainingTimeRouteProps {
params: {
jobId: string;
};
}
export async function GET(request: Request, { params }: RemainingTimeRouteProps) {
// Get the job details
const jobDetails = await db.query.printJobs.findFirst({
where: eq(printJobs.id, params.jobId),
});
// Check if the job exists
if (!jobDetails) {
return Response.json({
id: params.jobId,
error: "Job not found",
});
}
// Calculate the remaining time
const startAt = new Date(jobDetails.startAt).getTime();
const endAt = startAt + jobDetails.durationInMinutes * 60 * 1000;
const remainingTime = Math.max(0, endAt - Date.now());
// Return the remaining time
return Response.json({
id: params.jobId,
remainingTime,
});
}

View File

@@ -0,0 +1,7 @@
import { getPrinters } from "@/server/actions/printers";
export async function GET() {
const printers = await getPrinters();
return Response.json(printers);
}

View File

@@ -0,0 +1,79 @@
import { lucia } from "@/server/auth";
import { type GitHubUserResult, github } from "@/server/auth/oauth";
import { db } from "@/server/db";
import { users } from "@/server/db/schema";
import { OAuth2RequestError } from "arctic";
import { eq } from "drizzle-orm";
import { generateIdFromEntropySize } from "lucia";
import { cookies } from "next/headers";
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const storedState = cookies().get("github_oauth_state")?.value ?? null;
if (!code || !state || !storedState || state !== storedState) {
return new Response(null, {
status: 400,
});
}
try {
const tokens = await github.validateAuthorizationCode(code);
const githubUserResponse = await fetch("https://git.i.mercedes-benz.com/api/v3/user", {
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
},
});
const githubUser: GitHubUserResult = await githubUserResponse.json();
// Replace this with your own DB client.
const existingUser = await db.query.users.findFirst({
where: eq(users.github_id, githubUser.id),
});
if (existingUser) {
const session = await lucia.createSession(existingUser.id, {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return new Response(null, {
status: 302,
headers: {
Location: "/",
},
});
}
const userId = generateIdFromEntropySize(10); // 16 characters long
await db.insert(users).values({
id: userId,
github_id: githubUser.id,
username: githubUser.login,
displayName: githubUser.name,
email: githubUser.email,
});
const session = await lucia.createSession(userId, {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return new Response(null, {
status: 302,
headers: {
Location: "/",
},
});
} catch (e) {
console.log(e);
// the specific error message depends on the provider
if (e instanceof OAuth2RequestError) {
// invalid code
return new Response(null, {
status: 400,
});
}
return new Response(null, {
status: 500,
});
}
}

View File

@@ -0,0 +1,19 @@
import { github } from "@/server/auth/oauth";
import { generateState } from "arctic";
import { cookies } from "next/headers";
export async function GET(): Promise<Response> {
const state = generateState();
const url = await github.createAuthorizationURL(state);
const ONE_HOUR = 60 * 60;
cookies().set("github_oauth_state", state, {
path: "/",
secure: process.env.NODE_ENV === "production",
httpOnly: true,
maxAge: ONE_HOUR,
sameSite: "lax",
});
return Response.redirect(url);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,77 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 90.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,126 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useToast } from "@/components/ui/use-toast";
import { abortPrintJob } from "@/server/actions/printJobs";
import { TriangleAlertIcon } from "lucide-react";
import { useState } from "react";
const formSchema = z.object({
abortReason: z
.string()
.min(1, {
message: "Bitte gebe einen Grund für den Abbruch an.",
})
.max(255, {
message: "Der Grund darf maximal 255 Zeichen lang sein.",
}),
});
interface CancelFormProps {
jobId: string;
}
export function CancelForm(props: CancelFormProps) {
const { jobId } = props;
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
abortReason: "",
},
});
const { toast } = useToast();
const [open, setOpen] = useState(false);
async function onSubmit(values: z.infer<typeof formSchema>) {
toast({
description: "Druckauftrag wird abgebrochen...",
});
try {
await abortPrintJob(jobId, values.abortReason);
setOpen(false);
toast({
description: "Druckauftrag wurde abgebrochen.",
});
} catch (error) {
if (error instanceof Error) {
toast({
description: error.message,
variant: "destructive",
});
} else {
toast({
description: "Ein unbekannter Fehler ist aufgetreten.",
variant: "destructive",
});
}
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant={"ghost"}
className="text-red-500 hover:text-red-600 flex-grow gap-2 items-center flex justify-start"
>
<TriangleAlertIcon className="w-4 h-4" />
<span>Druckauftrag abbrechen</span>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Druckauftrag abbrechen?</DialogTitle>
<DialogDescription>
Du bist dabei, den Druckauftrag abzubrechen. Bitte beachte, dass ein abgebrochener Druckauftrag nicht wieder
aufgenommen werden kann und der Drucker sich automatisch abschaltet.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="abortReason"
render={({ field }) => (
<FormItem>
<FormLabel>Grund für den Abbruch</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
Bitte teile uns den Grund für den Abbruch des Druckauftrags mit. Wenn der Drucker eine Fehlermeldung
anzeigt, gib bitte nur diese Fehlermeldung an.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-row justify-between">
<DialogClose asChild>
<Button variant={"secondary"}>Nein</Button>
</DialogClose>
<Button variant={"destructive"} type="submit">
Ja, Druck abbrechen
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,50 @@
"use client";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/components/ui/use-toast";
import { updatePrintComments } from "@/server/actions/printJobs";
import { useDebouncedCallback } from "use-debounce";
interface EditCommentsProps {
defaultValue: string | null;
jobId: string;
disabled?: boolean;
}
export function EditComments(props: EditCommentsProps) {
const { defaultValue, jobId, disabled } = props;
const { toast } = useToast();
const debounced = useDebouncedCallback(async (value) => {
try {
await updatePrintComments(jobId, value);
toast({
description: "Anmerkungen wurden gespeichert.",
});
} catch (error) {
if (error instanceof Error) {
toast({
description: error.message,
variant: "destructive",
});
} else {
toast({
description: "Ein unbekannter Fehler ist aufgetreten.",
variant: "destructive",
});
}
}
}, 1000);
return (
<div className="flex flex-col gap-2">
<Label>Anmerkungen</Label>
<Textarea
placeholder="Anmerkungen"
disabled={disabled}
defaultValue={defaultValue ?? ""}
onChange={(e) => debounced(e.target.value)}
/>
</div>
);
}

View File

@@ -0,0 +1,144 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useToast } from "@/components/ui/use-toast";
import { extendPrintJob } from "@/server/actions/printJobs";
import { CircleFadingPlusIcon } from "lucide-react";
import { useState } from "react";
import { useSWRConfig } from "swr";
const formSchema = z.object({
minutes: z.coerce.number().int().max(59, {
message: "Die Minuten müssen zwischen 0 und 59 liegen.",
}),
hours: z.coerce.number().int().max(24, {
message: "Die Stunden müssen zwischen 0 und 24 liegen.",
}),
});
interface ExtendFormProps {
jobId: string;
}
export function ExtendForm(props: ExtendFormProps) {
const { jobId } = props;
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
minutes: 0,
hours: 0,
},
});
const { toast } = useToast();
const [open, setOpen] = useState(false);
const { mutate } = useSWRConfig();
async function onSubmit(values: z.infer<typeof formSchema>) {
toast({
description: "Druckauftrag wird verlängert...",
});
try {
await extendPrintJob(jobId, values.minutes, values.hours);
setOpen(false);
form.reset();
mutate(`/api/job/${jobId}/remaining-time`); // Refresh the countdown
toast({
description: "Druckauftrag wurde verlängert.",
});
} catch (error) {
if (error instanceof Error) {
toast({
description: error.message,
variant: "destructive",
});
} else {
toast({
description: "Ein unbekannter Fehler ist aufgetreten.",
variant: "destructive",
});
}
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant={"ghost"} className="flex-grow gap-2 items-center flex justify-start">
<CircleFadingPlusIcon className="w-4 h-4" />
<span>Druckauftrag verlängern</span>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Druckauftrag verlängern</DialogTitle>
<DialogDescription>
Braucht dein Druck mehr Zeit als erwartet? Füge weitere Stunden oder Minuten zur Druckzeit hinzu.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<p className="text-sm px-4 py-2 text-yellow-700 bg-yellow-500/20 rounded-md">
<span className="font-medium">Wichtig:</span> Bitte verlängere die Druckzeit nur, wenn es sich um
denselben Druck handelt. Wenn es ein anderer Druck ist, brich bitte den aktuellen Druckauftrag ab und
starte einen neuen.
</p>
<div className="flex flex-row gap-2">
<FormField
control={form.control}
name="hours"
render={({ field }) => (
<FormItem className="w-1/2">
<FormLabel>Stunden</FormLabel>
<FormControl>
<Input placeholder="0" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="minutes"
render={({ field }) => (
<FormItem className="w-1/2">
<FormLabel>Minuten</FormLabel>
<FormControl>
<Input placeholder="0" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex flex-row justify-between">
<DialogClose asChild>
<Button variant={"secondary"}>Abbrechen</Button>
</DialogClose>
<Button variant={"default"} type="submit">
Verlängern
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,81 @@
"use client";
import { AlertDialogHeader } from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useToast } from "@/components/ui/use-toast";
import { earlyFinishPrintJob } from "@/server/actions/printJobs";
import { CircleCheckBigIcon } from "lucide-react";
interface FinishFormProps {
jobId: string;
}
export function FinishForm(props: FinishFormProps) {
const { jobId } = props;
const { toast } = useToast();
async function onClick() {
toast({
description: "Druckauftrag wird abgeschlossen...",
});
try {
await earlyFinishPrintJob(jobId);
toast({
description: "Druckauftrag wurde abgeschlossen.",
});
} catch (error) {
if (error instanceof Error) {
toast({
description: error.message,
variant: "destructive",
});
} else {
toast({
description: "Ein unbekannter Fehler ist aufgetreten.",
variant: "destructive",
});
}
}
}
return (
<Dialog>
<DialogTrigger asChild>
<Button variant={"ghost"} className="flex-grow gap-2 items-center flex justify-start">
<CircleCheckBigIcon className="w-4 h-4" />
<span>Druckauftrag abschließen</span>
</Button>
</DialogTrigger>
<DialogContent>
<AlertDialogHeader>
<DialogTitle>Druckauftrag abschließen?</DialogTitle>
<DialogDescription>
Du bist dabei, den Druckauftrag als abgeschlossen zu markieren. Dies führt dazu, dass der Drucker
automatisch herunterfährt.
</DialogDescription>
</AlertDialogHeader>
<div className="flex flex-col gap-4">
<p className="text-sm text-red-500 font-medium bg-red-500/20 px-4 py-2 rounded-md">
Bitte bestätige nur, wenn der Druckauftrag tatsächlich erfolgreich abgeschlossen wurde.
</p>
<div className="flex flex-row justify-between">
<DialogClose asChild>
<Button variant={"secondary"}>Abbrechen</Button>
</DialogClose>
<DialogClose asChild onClick={onClick}>
<Button variant={"default"}>Bestätigen</Button>
</DialogClose>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,123 @@
import { CancelForm } from "@/app/job/[jobId]/cancel-form";
import { EditComments } from "@/app/job/[jobId]/edit-comments";
import { ExtendForm } from "@/app/job/[jobId]/extend-form";
import { FinishForm } from "@/app/job/[jobId]/finish-form";
import { Countdown } from "@/components/printer-card/countdown";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { validateRequest } from "@/server/auth";
import { UserRole } from "@/server/auth/permissions";
import { db } from "@/server/db";
import { printJobs } from "@/server/db/schema";
import { eq } from "drizzle-orm";
import { ArchiveIcon } from "lucide-react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Druckauftrag",
};
interface JobDetailsPageProps {
params: {
jobId: string;
};
}
export default async function JobDetailsPage(props: JobDetailsPageProps) {
const { jobId } = props.params;
const { user } = await validateRequest();
const jobDetails = await db.query.printJobs.findFirst({
where: eq(printJobs.id, jobId),
with: {
user: true,
printer: true,
},
});
if (!jobDetails) {
return <div>Job not found</div>;
}
const jobIsOnGoing = new Date(jobDetails.startAt).getTime() + jobDetails.durationInMinutes * 60 * 1000 > Date.now();
const jobIsAborted = jobDetails.aborted;
const userOwnsJob = jobDetails.userId === user?.id;
const userIsAdmin = user?.role === UserRole.ADMIN;
const userMayEditJob = userOwnsJob || userIsAdmin;
return (
<div className="flex flex-col gap-4">
<h1 className="text-3xl font-semibold">
Druckauftrag vom{" "}
{new Date(jobDetails.startAt).toLocaleString("de-DE", {
dateStyle: "medium",
timeStyle: "medium",
})}
</h1>
{!jobIsOnGoing || jobIsAborted ? (
<Alert className="bg-yellow-200 border-yellow-500 text-yellow-700 shadow-sm">
<ArchiveIcon className="h-4 w-4" />
<AlertTitle>Hinweis</AlertTitle>
<AlertDescription>
Dieser Druckauftrag wurde bereits abgeschlossen und kann nicht mehr bearbeitet werden.
</AlertDescription>
</Alert>
) : null}
<div className="flex flex-col lg:flex-row gap-4">
<Card className="w-full">
<CardContent className="p-4 flex flex-col gap-4">
<div className="flex flex-row justify-between">
<div>
<h2 className="font-semibold">Ansprechpartner</h2>
<p className="text-sm">{jobDetails.user.displayName}</p>
<p className="text-sm">{jobDetails.user.email}</p>
</div>
<div className="text-right">
{jobIsAborted && (
<>
<h2 className="font-semibold text-red-500">Abbruchsgrund</h2>
<p className="text-sm text-red-500">{jobDetails.abortReason}</p>
</>
)}
{jobIsOnGoing && (
<>
<h2 className="font-semibold">Verbleibende Zeit</h2>
<p className="text-sm">
<Countdown jobId={jobDetails.id} />
</p>
</>
)}
</div>
</div>
<EditComments
defaultValue={jobDetails.comments}
jobId={jobDetails.id}
disabled={!userMayEditJob || jobIsAborted || !jobIsOnGoing}
/>
</CardContent>
</Card>
{userMayEditJob && jobIsOnGoing && (
<Card className="w-full lg:w-96 ml-auto">
<CardHeader>
<CardTitle>Aktionen</CardTitle>
</CardHeader>
<CardContent>
<div className="flex w-full flex-col -ml-4 -mt-2">
<FinishForm jobId={jobDetails.id} />
<ExtendForm jobId={jobDetails.id} />
<CancelForm jobId={jobDetails.id} />
</div>
</CardContent>
</Card>
)}
</div>
</div>
);
}
/**
* durationInMinutes: integer("durationInMinutes").notNull(),
comments: text("comments"),
aborted: integer("aborted", { mode: "boolean" }).notNull().default(false),
abortReason: text("abortReason"),
*/

View File

@@ -1,42 +1,41 @@
import { Metadata } from "next";
import { cn } from "@/lib/utils";
import { Header } from "@/components/header";
import { Toaster } from "@/components/ui/toaster";
import { cn } from "@/utils/styles";
import type { Metadata } from "next";
import "@/styles/globals.css";
import "@/app/globals.css";
import { Inter as FontSans } from "next/font/google";
import Header from "@/components/Header";
export const metadata: Metadata = {
title: {
template: "%s | MYP",
default: "MYP",
},
description:
"MYP (Manage your Printer) ist eine 3D-Drucker Reservierungsplattform entwickelt für die TBA Berlin (W040).",
icons: [{ rel: "icon", url: "/favicon.ico" }],
};
const fontSans = FontSans({
subsets: ["latin"],
variable: "--font-sans",
subsets: ["latin"],
variable: "--font-sans",
});
export const metadata: Metadata = {
title: {
default: "MYP",
template: "%s | MYP",
},
description: "Generated by create next app",
};
interface RootLayoutProps {
children: React.ReactNode;
children: React.ReactNode;
}
export default function RootLayout({ children }: RootLayoutProps) {
return (
<html lang="en" suppressHydrationWarning>
<head />
<body
className={cn(
"min-h-screen bg-background font-sans antialiased",
fontSans.variable,
)}
>
<Header />
<main className="flex justify-center px-96 py-8">{children}</main>
</body>
</html>
);
export default function RootLayout(props: RootLayoutProps) {
const { children } = props;
return (
<html lang="de" suppressHydrationWarning>
<head />
<body className={cn("min-h-dvh bg-muted font-sans antialiased", fontSans.variable)}>
<Header />
<main className="flex-grow max-w-screen-2xl w-full mx-auto flex flex-col p-8 gap-4 text-foreground">
{children}
</main>
<Toaster />
</body>
</html>
);
}

View File

@@ -0,0 +1,141 @@
"use client";
import type { InferResultType } from "@/utils/drizzle";
import type { ColumnDef } from "@tanstack/react-table";
import { BadgeCheckIcon, EyeIcon, HourglassIcon, MoreHorizontal, OctagonXIcon, ShareIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/components/ui/use-toast";
import type { printers } from "@/server/db/schema";
import type { InferSelectModel } from "drizzle-orm";
import Link from "next/link";
export const columns: ColumnDef<
InferResultType<
"printJobs",
{
printer: true;
}
>
>[] = [
{
accessorKey: "printer",
header: "Drucker",
cell: ({ row }) => {
const printer: InferSelectModel<typeof printers> = row.getValue("printer");
return printer.name;
},
},
{
accessorKey: "startAt",
header: "Startzeitpunkt",
cell: ({ row }) => {
const startAt = new Date(row.original.startAt);
return `${startAt.toLocaleDateString("de-DE", {
dateStyle: "medium",
})} ${startAt.toLocaleTimeString("de-DE")}`;
},
},
{
accessorKey: "durationInMinutes",
header: "Dauer (Minuten)",
},
{
accessorKey: "comments",
header: "Anmerkungen",
cell: ({ row }) => {
const comments = row.original.comments;
if (comments) {
return <span className="text-sm">{comments.slice(0, 50)}</span>;
}
return <span className="text-muted-foreground text-sm">Keine Anmerkungen</span>;
},
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => {
const aborted = row.original.aborted;
if (aborted) {
return (
<div className="flex items-center gap-2">
<OctagonXIcon className="w-4 h-4 text-red-500" /> <span className="text-red-600">Abgebrochen</span>
</div>
);
}
const startAt = new Date(row.original.startAt).getTime();
const endAt = startAt + row.original.durationInMinutes * 60 * 1000;
if (Date.now() < endAt) {
return (
<div className="flex items-center gap-2">
<HourglassIcon className="w-4 h-4 text-yellow-500" />
<span className="text-yellow-600">Läuft...</span>
</div>
);
}
return (
<div className="flex items-center gap-2">
<BadgeCheckIcon className="w-4 h-4 text-green-500" />
<span className="text-green-600">Abgeschlossen</span>
</div>
);
},
},
{
id: "actions",
cell: ({ row }) => {
const job = row.original;
const { toast } = useToast();
return (
<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
className="flex items-center gap-2"
onClick={() => {
const baseUrl = new URL(window.location.href);
baseUrl.pathname = `/job/${job.id}`;
navigator.clipboard.writeText(baseUrl.toString());
toast({
description: "URL zum Druckauftrag in die Zwischenablage kopiert.",
});
}}
>
<ShareIcon className="w-4 h-4" />
<span>Teilen</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Link href={`/job/${job.id}`} className="flex items-center gap-2">
<EyeIcon className="w-4 h-4" />
<span>Details anzeigen</span>
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];

View File

@@ -0,0 +1,73 @@
"use client";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from "@tanstack/react-table";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
export function JobsTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<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 select-none">
<Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
Vorherige Seite
</Button>
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
Nächste Seite
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { validateRequest } from "@/server/auth";
import { UserRole, translateUserRole } from "@/server/auth/permissions";
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Dein Profil",
};
export default async function ProfilePage() {
const { user } = await validateRequest();
if (!user) {
redirect("/");
}
const badgeVariant = {
[UserRole.ADMIN]: "destructive" as const,
[UserRole.USER]: "default" as const,
[UserRole.GUEST]: "secondary" as const,
};
return (
<Card>
<CardHeader className="flex flex-row justify-between items-center">
<div>
<CardTitle>{user?.displayName}</CardTitle>
<CardDescription>
{user?.username} &mdash; {user?.email}
</CardDescription>
</div>
<Badge variant={badgeVariant[user?.role]}>{translateUserRole(user?.role)}</Badge>
</CardHeader>
<CardContent>
<p>
Deine Daten wurden vom <abbr>GitHub Enterprise Server</abbr> importiert und können hier nur angezeigt werden.
</p>
<p>
Solltest Du Änderungen oder eine Löschung deiner Daten von unserem Dienst beantragen wollen, so wende dich
bitte an einen Administrator.
</p>
</CardContent>
</Card>
);
}

View File

@@ -1,48 +1,66 @@
import { Metadata } from "next";
import { DollarSign } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import DashboardCard from "@/components/DashboardCard";
import { columns } from "@/app/my/jobs/columns";
import { JobsTable } from "@/app/my/jobs/data-table";
import { DynamicPrinterCards } from "@/components/dynamic-printer-cards";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { validateRequest } from "@/server/auth";
import { db } from "@/server/db";
import { printJobs } from "@/server/db/schema";
import { desc, eq } from "drizzle-orm";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Startseite | MYP",
title: "Dashboard | MYP",
};
export default function HomePage() {
return (
<div className="w-full">
<div className="flex flex-col justify-evenly gap-8 lg:flex-row">
<DashboardCard
title="Druckstunden"
data="200"
icon="Clock"
trend="none"
/>
<DashboardCard
title="Lieblingsdrucker"
data="200"
icon="Heart"
trend="none"
/>
<DashboardCard
title="Am häfigsten gedruckt am"
data="200"
icon="CalendarDays"
trend="none"
/>
<DashboardCard
title="Erfolgreiche Drucke"
data="200"
icon="PackageCheck"
trend="none"
/>
<DashboardCard
title="Gemeldete Fehler"
data="200"
icon="ShieldAlert"
trend="none"
/>
</div>
</div>
);
export default async function HomePage() {
const { user } = await validateRequest();
const userIsLoggedIn = Boolean(user);
const printers = await db.query.printers.findMany({
with: {
printJobs: {
limit: 1,
orderBy: (printJobs, { desc }) => [desc(printJobs.startAt)],
},
},
});
// biome-ignore lint/suspicious/noExplicitAny: temp. fix for jobs
let jobs: any[] = [];
if (userIsLoggedIn) {
jobs = await db.query.printJobs.findMany({
// biome-ignore lint/style/noNonNullAssertion: User exists if userIsLoggedIn is true
where: eq(printJobs.userId, user!.id),
orderBy: [desc(printJobs.startAt)],
with: {
printer: true,
},
});
}
return (
<>
{/* NEEDS TO BE FIXED FOR A NEW / EMPTY USER {isLoggedIn && <PersonalizedCards />} */}
<Card>
<CardHeader>
<CardTitle>Druckerbelegung</CardTitle>
<CardDescription>({printers.length} Verfügbar)</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<DynamicPrinterCards user={user} />
</CardContent>
</Card>
{userIsLoggedIn && (
<Card>
<CardHeader>
<CardTitle>Druckaufträge</CardTitle>
<CardDescription>Deine aktuellen Druckaufträge</CardDescription>
</CardHeader>
<CardContent>
<JobsTable columns={columns} data={jobs} />
</CardContent>
</Card>
)}
</>
);
}

View File

@@ -0,0 +1,161 @@
"use client";
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 { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/components/ui/use-toast";
import { createPrintJob } from "@/server/actions/printJobs";
import { zodResolver } from "@hookform/resolvers/zod";
import { CalendarPlusIcon, XCircleIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { If, Then } from "react-if";
import { z } from "zod";
export const formSchema = z.object({
hours: z.coerce.number().int().min(0).max(96, {
message: "Die Stunden müssen zwischen 0 und 96 liegen.",
}),
minutes: z.coerce.number().int().min(0).max(59, {
message: "Die Minuten müssen zwischen 0 und 59 liegen.",
}),
comments: z.string().optional(),
});
interface PrinterReserveFormProps {
userId: string;
printerId: string;
isDialog?: boolean;
}
export function PrinterReserveForm(props: PrinterReserveFormProps) {
const { userId, printerId, isDialog } = props;
const router = useRouter();
const { toast } = useToast();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
hours: 0,
minutes: 0,
comments: "",
},
});
async function onSubmit(values: z.infer<typeof formSchema>) {
if (values.hours === 0 && values.minutes === 0) {
form.setError("hours", {
message: "",
});
form.setError("minutes", {
message:
"Die Dauer des Druckauftrags muss mindestens 1 Minute betragen.",
});
return;
}
try {
const jobId = await createPrintJob({
durationInMinutes: values.hours * 60 + values.minutes,
comments: values.comments,
userId: userId,
printerId: printerId,
});
router.push(`/job/${jobId}`);
} catch (error) {
if (error instanceof Error) {
toast({ variant: "destructive", description: error.message });
} else {
toast({
variant: "destructive",
description: "Ein unbekannter Fehler ist aufgetreten.",
});
}
return;
}
toast({ description: "Druckauftrag wurde erfolgreich erstellt." });
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<div className="flex flex-row gap-2">
<FormField
control={form.control}
name="hours"
render={({ field }) => (
<FormItem className="w-1/2">
<FormLabel>Stunden</FormLabel>
<FormControl>
<Input placeholder="0" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="minutes"
render={({ field }) => (
<FormItem className="w-1/2">
<FormLabel>Minuten</FormLabel>
<FormControl>
<Input placeholder="0" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="comments"
render={({ field }) => (
<FormItem>
<FormLabel>Anmerkungen</FormLabel>
<FormControl>
<Textarea placeholder="" {...field} />
</FormControl>
<FormDescription>
In dieses Feld kannst du Anmerkungen zu deinem Druckauftrag
hinzufügen. Sie können beispielsweise Informationen über das
Druckmaterial, die Druckqualität oder die Farbe enthalten.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-between items-center">
<If condition={isDialog}>
<Then>
<DialogClose asChild>
<Button
variant={"secondary"}
className="gap-2 flex items-center"
>
<XCircleIcon className="w-4 h-4" />
<span>Abbrechen</span>
</Button>
</DialogClose>
</Then>
</If>
<Button type="submit" className="gap-2 flex items-center">
<CalendarPlusIcon className="w-4 h-4" />
<span>Reservieren</span>
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,36 @@
import { PrinterReserveForm } from "@/app/printer/[printerId]/reserve/form";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { validateRequest } from "@/server/auth";
import { redirect } from "next/navigation";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Drucker reservieren",
};
interface PrinterReservePageProps {
params: {
printerId: string;
};
}
export default async function PrinterReservePage(props: PrinterReservePageProps) {
const { user } = await validateRequest();
const { printerId } = props.params;
if (!user) {
return redirect("/");
}
return (
<Card>
<CardHeader>
<CardTitle>Drucker reservieren</CardTitle>
</CardHeader>
<CardContent>
<PrinterReserveForm userId={user?.id} printerId={printerId} />
</CardContent>
</Card>
);
}