"feat: Added debug server and related components for improved development experience"

This commit is contained in:
2025-05-23 07:24:51 +02:00
parent d457a8d86b
commit 9f6219832c
189 changed files with 35730 additions and 133 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,68 @@
"use client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
import { Bar, BarChart, CartesianGrid, LabelList, XAxis, YAxis } from "recharts";
export const description = "Ein Säulendiagramm zur Darstellung der Abbruchgründe und ihrer Häufigkeit";
interface AbortReasonCountChartProps {
abortReasonCount: {
abortReason: string;
count: number;
}[];
}
const chartConfig = {
abortReason: {
label: "Abbruchgrund",
},
} satisfies ChartConfig;
export function AbortReasonCountChart({ abortReasonCount }: AbortReasonCountChartProps) {
// Transform data to fit the chart structure
const chartData = abortReasonCount.map((reason) => ({
abortReason: reason.abortReason,
count: reason.count,
}));
return (
<Card>
<CardHeader>
<CardTitle>Abbruchgründe</CardTitle>
<CardDescription>Häufigkeit der Abbruchgründe für Druckaufträge</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig}>
<BarChart
accessibilityLayer
data={chartData}
margin={{
top: 20,
}}
>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
dataKey="abortReason"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={(value) => value}
/>
<YAxis tickFormatter={(value) => `${value}`} />
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} />
<Bar dataKey="count" fill="hsl(var(--chart-1))" radius={8}>
<LabelList
position="top"
offset={12}
className="fill-foreground"
fontSize={12}
formatter={(value: number) => `${value}`}
/>
</Bar>
</BarChart>
</ChartContainer>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,66 @@
"use client";
import { Bar, BarChart, CartesianGrid, LabelList, XAxis, YAxis } from "recharts";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
import type { PrinterErrorRate } from "@/utils/analytics/error-rate";
export const description = "Ein Säulendiagramm zur Darstellung der Fehlerrate";
interface PrinterErrorRateChartProps {
printerErrorRate: PrinterErrorRate[];
}
const chartConfig = {
errorRate: {
label: "Fehlerrate",
},
} satisfies ChartConfig;
export function PrinterErrorRateChart({ printerErrorRate }: PrinterErrorRateChartProps) {
// Transform data to fit the chart structure
const chartData = printerErrorRate.map((printer) => ({
printer: printer.name,
errorRate: printer.errorRate,
}));
return (
<Card>
<CardHeader>
<CardTitle>Fehlerrate</CardTitle>
<CardDescription>Fehlerrate der Drucker in Prozent</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig}>
<BarChart
accessibilityLayer
data={chartData}
margin={{
top: 20,
}}
>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
dataKey="printer"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={(value) => value}
/>
<YAxis tickFormatter={(value) => `${value}%`} />
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} />
<Bar dataKey="errorRate" fill="hsl(var(--chart-1))" radius={8}>
<LabelList
position="top"
offset={12}
className="fill-foreground"
fontSize={12}
formatter={(value: number) => `${value}%`}
/>
</Bar>
</BarChart>
</ChartContainer>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,83 @@
"use client";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
export const description = "Ein Bereichsdiagramm zur Darstellung der prognostizierten Nutzung pro Wochentag";
interface ForecastData {
day: number; // 0 for Sunday, 1 for Monday, ..., 6 for Saturday
usageMinutes: number;
}
interface ForecastChartProps {
forecastData: ForecastData[];
}
const chartConfig = {
usage: {
label: "Prognostizierte Nutzung",
color: "hsl(var(--chart-1))",
},
} satisfies ChartConfig;
const daysOfWeek = ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"];
export function ForecastPrinterUsageChart({ forecastData }: ForecastChartProps) {
// Transform and slice data to fit the chart structure
const chartData = forecastData.map((data) => ({
//slice(1, forecastData.length - 1).
day: daysOfWeek[data.day], // Map day number to weekday name
usage: data.usageMinutes,
}));
return (
<Card>
<CardHeader>
<CardTitle>Prognostizierte Nutzung pro Wochentag</CardTitle>
</CardHeader>
<CardContent>
<ChartContainer className="h-64 w-full" config={chartConfig}>
<AreaChart accessibilityLayer data={chartData} margin={{ left: 12, right: 12, top: 12 }}>
<CartesianGrid vertical={true} />
<XAxis dataKey="day" type="category" tickLine={true} tickMargin={10} axisLine={false} />
<YAxis type="number" dataKey="usage" tickLine={false} tickMargin={10} axisLine={false} />
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} />
<Area
dataKey="usage"
type="step"
fill="hsl(var(--chart-1))"
fillOpacity={0.4}
stroke="hsl(var(--chart-1))"
/>
</AreaChart>
</ChartContainer>
</CardContent>
<CardFooter className="flex-col items-start gap-2 text-sm">
<div className="flex items-center gap-2 font-medium leading-none">
Zeigt die prognostizierte Nutzungszeit pro Wochentag in Minuten.
</div>
<div className="leading-none text-muted-foreground">
Besten Tage zur Wartung: {bestMaintenanceDays(forecastData)}
</div>
</CardFooter>
</Card>
);
}
function bestMaintenanceDays(forecastData: ForecastData[]) {
const sortedData = forecastData.map((a) => a).sort((a, b) => a.usageMinutes - b.usageMinutes); // Sort ascending
const q1Index = Math.floor(sortedData.length * 0.33);
const q1 = sortedData[q1Index].usageMinutes; // First quartile (Q1) value
const filteredData = sortedData.filter((data) => data.usageMinutes <= q1);
return filteredData
.map((data) => {
const days = ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"];
return days[data.day];
})
.join(", ");
}

View File

@@ -0,0 +1,80 @@
"use client";
import { TrendingUp } from "lucide-react";
import * as React from "react";
import { Label, Pie, PieChart } from "recharts";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
export const description = "Nutzung des Druckers";
interface ComponentProps {
data: {
printerId: string;
utilizationPercentage: number;
name: string;
};
}
const chartConfig = {} satisfies ChartConfig;
export function PrinterUtilizationChart({ data }: ComponentProps) {
const totalUtilization = React.useMemo(() => data.utilizationPercentage, [data]);
const dataWithColor = {
...data,
fill: "rgb(34 197 94)",
};
const free = {
printerId: "-",
utilizationPercentage: 1 - data.utilizationPercentage,
name: "(Frei)",
fill: "rgb(212 212 212)",
};
return (
<Card className="flex flex-col">
<CardHeader className="items-center pb-0">
<CardTitle>{data.name}</CardTitle>
<CardDescription>Nutzung des ausgewählten Druckers</CardDescription>
</CardHeader>
<CardContent className="flex-1 pb-0">
<ChartContainer config={chartConfig} className="mx-auto aspect-square max-h-[250px]">
<PieChart>
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} />
<Pie
data={[dataWithColor, free]}
dataKey="utilizationPercentage"
nameKey="name"
innerRadius={60}
strokeWidth={5}
>
<Label
content={({ viewBox }) => {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
return (
<text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle">
<tspan x={viewBox.cx} y={viewBox.cy} className="fill-foreground text-3xl font-bold">
{(totalUtilization * 100).toFixed(2)}%
</tspan>
<tspan x={viewBox.cx} y={(viewBox.cy || 0) + 24} className="fill-muted-foreground">
Gesamt-Nutzung
</tspan>
</text>
);
}
}}
/>
</Pie>
</PieChart>
</ChartContainer>
</CardContent>
<CardFooter className="flex-col gap-2 text-sm">
<div className="flex items-center gap-2 font-medium leading-none">
Übersicht der Nutzung <TrendingUp className="h-4 w-4" />
</div>
<div className="leading-none text-muted-foreground">Aktuelle Auslastung des Druckers</div>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,69 @@
"use client";
import { Bar, BarChart, CartesianGrid, LabelList, XAxis } from "recharts";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
export const description = "Ein Balkendiagramm mit Beschriftung";
interface PrintVolumes {
today: number;
thisWeek: number;
thisMonth: number;
}
const chartConfig = {
volume: {
label: "Volumen",
},
} satisfies ChartConfig;
interface PrinterVolumeChartProps {
printerVolume: PrintVolumes;
}
export function PrinterVolumeChart({ printerVolume }: PrinterVolumeChartProps) {
const chartData = [
{ period: "Heute", volume: printerVolume.today, color: "hsl(var(--chart-1))" },
{ period: "Diese Woche", volume: printerVolume.thisWeek, color: "hsl(var(--chart-2))" },
{ period: "Diesen Monat", volume: printerVolume.thisMonth, color: "hsl(var(--chart-3))" },
];
return (
<Card>
<CardHeader>
<CardTitle>Druckvolumen</CardTitle>
<CardDescription>Vergleich: Heute, Diese Woche, Diesen Monat</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer className="h-64 w-full" config={chartConfig}>
<BarChart
accessibilityLayer
data={chartData}
margin={{
top: 20,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="period"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={(value) => value}
/>
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} />
<Bar dataKey="volume" fill="var(--color-volume)" radius={8}>
<LabelList position="top" offset={12} className="fill-foreground" fontSize={12} />
</Bar>
</BarChart>
</ChartContainer>
</CardContent>
<CardFooter className="flex-col items-start gap-2 text-sm">
<div className="leading-none text-muted-foreground">
Zeigt das Druckvolumen für heute, diese Woche und diesen Monat
</div>
</CardFooter>
</Card>
);
}

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,34 @@
import { AdminSidebar } from "@/app/admin/admin-sidebar";
import { validateRequest } from "@/server/auth";
import { UserRole } from "@/server/auth/permissions";
import { IS_NOT, guard } from "@/utils/guard";
import { redirect } from "next/navigation";
interface AdminLayoutProps {
children: React.ReactNode;
}
export const dynamic = "force-dynamic";
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,121 @@
import { AbortReasonCountChart } from "@/app/admin/charts/printer-error-chart";
import { PrinterErrorRateChart } from "@/app/admin/charts/printer-error-rate";
import { ForecastPrinterUsageChart } from "@/app/admin/charts/printer-forecast";
import { PrinterUtilizationChart } from "@/app/admin/charts/printer-utilization";
import { PrinterVolumeChart } from "@/app/admin/charts/printer-volume";
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 { calculatePrinterErrorRate } from "@/utils/analytics/error-rate";
import { calculateAbortReasonsCount } from "@/utils/analytics/errors";
import { forecastPrinterUsage } from "@/utils/analytics/forecast";
import { calculatePrinterUtilization } from "@/utils/analytics/utilization";
import { calculatePrintVolumes } from "@/utils/analytics/volume";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Admin Dashboard",
};
export const dynamic = "force-dynamic";
export default async function AdminPage() {
const currentDate = new Date();
const lastMonth = new Date();
lastMonth.setDate(currentDate.getDate() - 31);
const printers = await db.query.printers.findMany({});
const printJobs = await db.query.printJobs.findMany({
where: (job, { gte }) => gte(job.startAt, lastMonth),
with: {
printer: true,
},
});
if (printJobs.length < 1) {
return (
<Card className="w-full">
<CardHeader>
<CardTitle>Druckaufträge</CardTitle>
<CardDescription>Zurzeit sind keine Druckaufträge verfügbar.</CardDescription>
</CardHeader>
<CardContent>
<p>Aktualisieren Sie die Seite oder prüfen Sie später erneut, ob neue Druckaufträge verfügbar sind.</p>
</CardContent>
</Card>
);
}
const currentPrintJobs = printJobs.filter((job) => {
if (job.aborted) return false;
const endAt = job.startAt.getTime() + job.durationInMinutes * 1000 * 60;
return endAt > currentDate.getTime();
});
const occupiedPrinters = currentPrintJobs.map((job) => job.printer.id);
const freePrinters = printers.filter((printer) => !occupiedPrinters.includes(printer.id));
const printerUtilization = calculatePrinterUtilization(printJobs);
const printerVolume = calculatePrintVolumes(printJobs);
const printerAbortReasons = calculateAbortReasonsCount(printJobs);
const printerErrorRate = calculatePrinterErrorRate(printJobs);
const printerForecast = forecastPrinterUsage(printJobs);
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>
<TabsTrigger value="@capacity">Druckerauslastung</TabsTrigger>
<TabsTrigger value="@report">Fehlerberichte</TabsTrigger>
<TabsTrigger value="@forecasts">Prognosen</TabsTrigger>
</TabsList>
<TabsContent value="@general" className="w-full">
<div className="flex flex-col lg:grid lg:grid-cols-2 gap-4">
<div className="w-full col-span-2">
<DataCard
title="Aktuelle Auslastung"
value={`${Math.round((occupiedPrinters.length / (freePrinters.length + occupiedPrinters.length)) * 100)}%`}
icon={"Percent"}
/>
</div>
<DataCard title="Aktive Drucker" value={occupiedPrinters.length} icon={"Rotate3d"} />
<DataCard title="Freie Drucker" value={freePrinters.length} icon={"PowerOff"} />
</div>
</TabsContent>
<TabsContent value="@capacity" className="w-full">
<div className="flex flex-col lg:grid lg:grid-cols-2 gap-4">
<div className="w-full col-span-2">
<PrinterVolumeChart printerVolume={printerVolume} />
</div>
{printerUtilization.map((data) => (
<PrinterUtilizationChart key={data.printerId} data={data} />
))}
</div>
</TabsContent>
<TabsContent value="@report" className="w-full">
<div className="flex flex-col lg:grid lg:grid-cols-2 gap-4">
<div className="w-full col-span-2">
<PrinterErrorRateChart printerErrorRate={printerErrorRate} />
</div>
<div className="w-full col-span-2">
<AbortReasonCountChart abortReasonCount={printerAbortReasons} />
</div>
</div>
</TabsContent>
<TabsContent value="@forecasts" className="w-full">
<div className="flex flex-col lg:grid lg:grid-cols-2 gap-4">
<div className="w-full col-span-2">
<ForecastPrinterUsageChart
forecastData={printerForecast.map((usageMinutes, index) => ({
day: index,
usageMinutes,
}))}
/>
</div>
</div>
</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,83 @@
"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 {
const result = await deletePrinter(printerId);
if (result?.error) {
toast({
description: result.error,
variant: "destructive",
});
}
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,204 @@
"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 {
const result = await updatePrinter(printer.id, {
description: values.description,
name: values.name,
status: values.status,
});
if (result?.error) {
toast({
description: result.error,
variant: "destructive",
});
}
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 {
const result = await createPrinter({
description: values.description,
name: values.name,
status: values.status,
});
if (result?.error) {
toast({
description: result.error,
variant: "destructive",
});
}
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,7 @@
import fs from "node:fs";
export const dynamic = 'force-dynamic';
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

@@ -0,0 +1,41 @@
import { db } from "@/server/db";
import { printJobs } from "@/server/db/schema";
import { eq } from "drizzle-orm";
export const dynamic = "force-dynamic";
interface RemainingTimeRouteProps {
params: {
jobId: string;
};
}
export async function GET(request: Request, { params }: RemainingTimeRouteProps) {
// Trying to fix build error in container...
if (params.jobId === undefined) {
return Response.json({});
}
// 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,99 @@
import { API_ENDPOINTS } from "@/utils/api-config";
export const dynamic = "force-dynamic";
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const id = params.id;
// Rufe einzelnen Job vom externen Backend ab
const response = await fetch(`${API_ENDPOINTS.JOBS}/${id}`);
if (!response.ok) {
console.error(`Backend-Fehler: ${response.status} ${response.statusText}`);
return new Response(JSON.stringify({ error: 'Backend nicht erreichbar' }), {
status: 502,
headers: { 'Content-Type': 'application/json' }
});
}
const job = await response.json();
return Response.json(job);
} catch (error) {
console.error('Fehler beim Abrufen des Jobs vom Backend:', error);
return new Response(JSON.stringify({ error: 'Backend nicht erreichbar' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
export async function PUT(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const id = params.id;
const body = await request.json();
// Sende Job-Aktualisierung an das externe Backend
const response = await fetch(`${API_ENDPOINTS.JOBS}/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (!response.ok) {
console.error(`Backend-Fehler: ${response.status} ${response.statusText}`);
return new Response(JSON.stringify({ error: 'Backend nicht erreichbar' }), {
status: 502,
headers: { 'Content-Type': 'application/json' }
});
}
const result = await response.json();
return Response.json(result);
} catch (error) {
console.error('Fehler beim Aktualisieren des Jobs:', error);
return new Response(JSON.stringify({ error: 'Fehler beim Aktualisieren des Jobs' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
export async function DELETE(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const id = params.id;
// Sende Job-Löschung an das externe Backend
const response = await fetch(`${API_ENDPOINTS.JOBS}/${id}`, {
method: 'DELETE'
});
if (!response.ok) {
console.error(`Backend-Fehler: ${response.status} ${response.statusText}`);
return new Response(JSON.stringify({ error: 'Backend nicht erreichbar' }), {
status: 502,
headers: { 'Content-Type': 'application/json' }
});
}
const result = await response.json();
return Response.json(result);
} catch (error) {
console.error('Fehler beim Löschen des Jobs:', error);
return new Response(JSON.stringify({ error: 'Fehler beim Löschen des Jobs' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}

View File

@@ -0,0 +1,59 @@
import { API_ENDPOINTS } from "@/utils/api-config";
export const dynamic = "force-dynamic";
export async function GET() {
try {
// Rufe Jobs vom externen Backend ab
const response = await fetch(API_ENDPOINTS.JOBS);
if (!response.ok) {
console.error(`Backend-Fehler: ${response.status} ${response.statusText}`);
return new Response(JSON.stringify({ error: 'Backend nicht erreichbar' }), {
status: 502,
headers: { 'Content-Type': 'application/json' }
});
}
const jobs = await response.json();
return Response.json(jobs);
} catch (error) {
console.error('Fehler beim Abrufen der Jobs vom Backend:', error);
return new Response(JSON.stringify({ error: 'Backend nicht erreichbar' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
export async function POST(request: Request) {
try {
const body = await request.json();
// Sende Job-Erstellung an das externe Backend
const response = await fetch(API_ENDPOINTS.JOBS, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (!response.ok) {
console.error(`Backend-Fehler: ${response.status} ${response.statusText}`);
return new Response(JSON.stringify({ error: 'Backend nicht erreichbar' }), {
status: 502,
headers: { 'Content-Type': 'application/json' }
});
}
const result = await response.json();
return Response.json(result);
} catch (error) {
console.error('Fehler beim Erstellen des Jobs:', error);
return new Response(JSON.stringify({ error: 'Fehler beim Erstellen des Jobs' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}

View File

@@ -0,0 +1,27 @@
import { API_ENDPOINTS } from "@/utils/api-config";
export const dynamic = "force-dynamic";
export async function GET() {
try {
// Rufe Drucker vom externen Backend ab statt von der lokalen Datenbank
const response = await fetch(API_ENDPOINTS.PRINTERS);
if (!response.ok) {
console.error(`Backend-Fehler: ${response.status} ${response.statusText}`);
return new Response(JSON.stringify({ error: 'Backend nicht erreichbar' }), {
status: 502,
headers: { 'Content-Type': 'application/json' }
});
}
const printers = await response.json();
return Response.json(printers);
} catch (error) {
console.error('Fehler beim Abrufen der Drucker vom Backend:', error);
return new Response(JSON.stringify({ error: 'Backend nicht erreichbar' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}

View File

@@ -0,0 +1,123 @@
import { lucia } from "@/server/auth";
import { type GitHubUserResult, github, isValidCallbackHost, USED_CALLBACK_URL } from "@/server/auth/oauth";
import { ALLOWED_CALLBACK_HOSTS } from "@/utils/api-config";
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 const dynamic = "force-dynamic";
interface GithubEmailResponse {
email: string;
primary: boolean;
verified: boolean;
visibility: string;
}
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;
// Log für Debugging
console.log("OAuth Callback erhalten:", url.toString());
console.log("Callback URL Validierung:", isValidCallbackHost(url.toString()));
console.log("Erlaubte Hosts:", ALLOWED_CALLBACK_HOSTS);
if (!code || !state || !storedState || state !== storedState) {
return new Response(
JSON.stringify({
status_text: "Ungültiger OAuth-Callback",
data: { code, state, storedState, url: url.toString() },
}),
{
status: 400,
},
);
}
try {
// GitHub OAuth Code validieren - die redirectURI ist bereits im GitHub Client konfiguriert
const tokens = await github.validateAuthorizationCode(code);
// Log zur Fehlersuche
console.log(`GitHub OAuth Token-Validierung erfolgreich, verwendete Callback-URL: ${USED_CALLBACK_URL}`);
const githubUserResponse = await fetch("https://git.i.mercedes-benz.com/api/v3/user", {
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
},
});
const githubUser: GitHubUserResult = await githubUserResponse.json();
// Sometimes email can be null in the user query.
if (githubUser.email === null || githubUser.email === undefined) {
const githubEmailResponse = await fetch("https://git.i.mercedes-benz.com/api/v3/user/emails", {
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
},
});
const githubUserEmail: GithubEmailResponse[] = await githubEmailResponse.json();
githubUser.email = githubUserEmail[0].email;
}
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,
secure: false, // Else cookie does not get set cause IT has not provided us an SSL certificate yet
});
return new Response(null, {
status: 302,
headers: {
Location: "/",
},
});
} catch (e) {
// the specific error message depends on the provider
if (e instanceof OAuth2RequestError) {
// invalid code
return new Response(
JSON.stringify({
status_text: "Invalid code",
error: JSON.stringify(e),
}),
{
status: 400,
},
);
}
return new Response(null, {
status: 500,
});
}
}

View File

@@ -0,0 +1,30 @@
import { github, USED_CALLBACK_URL } from "@/server/auth/oauth";
import { generateState } from "arctic";
import { cookies } from "next/headers";
export const dynamic = "force-dynamic";
export async function GET(): Promise<Response> {
const state = generateState();
// Verwende die zentral definierte Callback-URL
// Die redirectURI ist bereits im GitHub-Client konfiguriert
const url = await github.createAuthorizationURL(state, {
scopes: ["user"],
});
const ONE_HOUR = 60 * 60;
cookies().set("github_oauth_state", state, {
path: "/",
secure: false, //process.env.NODE_ENV === "production", -- can't be used until SSL certificate is provided by IT
httpOnly: true,
maxAge: ONE_HOUR,
sameSite: "lax",
});
// Log zur Fehlersuche
console.log(`GitHub OAuth redirect zu: ${url.toString()}`);
console.log(`Verwendete Callback-URL: ${USED_CALLBACK_URL}`);
return Response.redirect(url);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

View File

@@ -0,0 +1,61 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.75rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}

View File

@@ -0,0 +1,132 @@
"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 {
const result = await abortPrintJob(jobId, values.abortReason);
if (result?.error) {
toast({
description: result.error,
variant: "destructive",
});
}
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,56 @@
"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 {
const result = await updatePrintComments(jobId, value);
if (result?.error) {
toast({
description: result.error,
variant: "destructive",
});
}
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,151 @@
"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 {
const result = await extendPrintJob(jobId, values.minutes, values.hours);
if (result?.error) {
toast({
description: result.error,
variant: "destructive",
});
}
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,87 @@
"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 {
const result = await earlyFinishPrintJob(jobId);
if (result?.error) {
toast({
description: result.error,
variant: "destructive",
});
}
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>Druckauftrag wurde nicht gefunden.</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

@@ -0,0 +1,36 @@
import { Header } from "@/components/header";
import { Toaster } from "@/components/ui/toaster";
import type { Metadata } from "next";
import "@/app/globals.css";
export const metadata: Metadata = {
title: {
default: "MYP",
template: "%s | MYP",
},
description: "Generated by create next app",
};
interface RootLayoutProps {
children: React.ReactNode;
}
export const dynamic = "force-dynamic";
export default function RootLayout(props: RootLayoutProps) {
const { children } = props;
return (
<html lang="de" suppressHydrationWarning>
<head />
<body className={"min-h-dvh bg-neutral-200 font-sans antialiased"}>
<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

@@ -0,0 +1,11 @@
import Link from "next/link";
export default function NotFound() {
return (
<div>
<h2>Nicht gefunden</h2>
<p>Die angefragte Seite konnte nicht gefunden werden.</p>
<Link href="/">Zurück zur Startseite</Link>
</div>
);
}

71
frontend/src/app/page.tsx Normal file
View File

@@ -0,0 +1,71 @@
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, 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 { BoxesIcon, NewspaperIcon } from "lucide-react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Dashboard | MYP",
};
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 className="flex flex-row items-center gap-x-1">
<BoxesIcon className="w-5 h-5" />
<span className="text-lg">Druckerbelegung</span>
</CardTitle>
</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 className="flex flex-row items-center gap-x-1">
<NewspaperIcon className="w-5 h-5" />
<span className="text-lg">Druckaufträge</span>
</CardTitle>
</CardHeader>
<CardContent>
<JobsTable columns={columns} data={jobs} />
</CardContent>
</Card>
)}
</>
);
}

View File

@@ -0,0 +1,169 @@
"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 { useState } from "react";
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 [isLocked, setLocked] = useState(false);
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 (!isLocked) {
setLocked(true);
setTimeout(() => {
setLocked(false);
}, 1000 * 5);
} else {
toast({
description: "Bitte warte ein wenig, bevor du eine weitere Reservierung tätigst...",
variant: "default",
});
return;
}
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,
});
if (typeof jobId === "object") {
toast({
description: jobId.error,
variant: "destructive",
});
}
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" disabled={isLocked}>
<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>
);
}

View File

@@ -0,0 +1,38 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { icons } from "lucide-react";
interface GenericIconProps {
name: keyof typeof icons;
className: string;
}
function GenericIcon(props: GenericIconProps) {
const { name, className } = props;
const LucideIcon = icons[name];
return <LucideIcon className={className} />;
}
interface DataCardProps {
title: string;
description?: string;
value: string | number;
icon: keyof typeof icons;
}
export function DataCard(props: DataCardProps) {
const { title, description, value, icon } = props;
return (
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<GenericIcon name={icon} className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
<p className="text-xs text-muted-foreground">&nbsp;</p>
</CardContent>
</Card>
);
}

View File

View File

@@ -0,0 +1,38 @@
"use client";
import { PrinterCard } from "@/components/printer-card";
import { Skeleton } from "@/components/ui/skeleton";
import type { InferResultType } from "@/utils/drizzle";
import { fetcher } from "@/utils/fetch";
import type { RegisteredDatabaseUserAttributes } from "lucia";
import useSWR from "swr";
interface DynamicPrinterCardsProps {
user: RegisteredDatabaseUserAttributes | null;
}
export function DynamicPrinterCards(props: DynamicPrinterCardsProps) {
const { user } = props;
const { data, error, isLoading } = useSWR("/api/printers", fetcher, {
refreshInterval: 1000 * 15,
});
if (error) {
return <div>Ein Fehler ist aufgetreten.</div>;
}
if (isLoading) {
return (
<>
{new Array(6).fill(null).map((_, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
<Skeleton key={index} className="w-auto h-36 animate-pulse" />
))}
</>
);
}
return data.map((printer: InferResultType<"printers", { printJobs: true }>) => {
return <PrinterCard key={printer.id} printer={printer} user={user} />;
});
}

View File

@@ -0,0 +1,85 @@
import { HeaderNavigation } from "@/components/header/navigation";
import { LoginButton } from "@/components/login-button";
import { LogoutButton } from "@/components/logout-button";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { validateRequest } from "@/server/auth";
import { UserRole, hasRole } from "@/server/auth/permissions";
import { StickerIcon, UserIcon, WrenchIcon } from "lucide-react";
import Link from "next/link";
import { If, Then } from "react-if";
function getInitials(name: string | undefined) {
if (!name) return "";
const parts = name.split(" ");
if (parts.length === 1) return parts[0].slice(0, 2);
return parts[0].charAt(0) + parts[parts.length - 1].charAt(0);
}
export async function Header() {
const { user } = await validateRequest();
return (
<header className="h-16 bg-neutral-900 border-b-4 border-neutral-600 text-white select-none shadow-md">
<div className="px-8 h-full max-w-screen-2xl w-full mx-auto flex items-center justify-between">
<div className="flex flex-row items-center gap-8">
<Link href="/" className="flex items-center gap-2">
<StickerIcon size={20} />
<h1 className="text-lg font-mono">MYP</h1>
</Link>
<HeaderNavigation />
</div>
{user != null && (
<DropdownMenu>
<DropdownMenuTrigger>
<Avatar>
<AvatarFallback className="bg-neutral-700">
<span className="font-semibold">{getInitials(user?.displayName)}</span>
</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuGroup>
<DropdownMenuLabel>Mein Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/my/profile/" className="flex items-center gap-2">
<UserIcon className="w-4 h-4" />
<span>Mein Profil</span>
</Link>
</DropdownMenuItem>
<If condition={hasRole(user, UserRole.ADMIN)}>
<Then>
<DropdownMenuItem asChild>
<Link href="/admin/" className="flex items-center gap-2">
<WrenchIcon className="w-4 h-4" />
<span>Adminbereich</span>
</Link>
</DropdownMenuItem>
</Then>
</If>
<DropdownMenuSeparator />
<DropdownMenuItem>
<LogoutButton />
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
{user == null && <LoginButton />}
</div>
</header>
);
}

View File

@@ -0,0 +1,49 @@
"use client";
import { cn } from "@/utils/styles";
import { ContactRoundIcon, LayersIcon } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
interface Site {
name: string;
icon: JSX.Element;
path: string;
}
export function HeaderNavigation() {
const pathname = usePathname();
const sites: Site[] = [
{
name: "Dashboard",
icon: <LayersIcon className="w-4 h-4" />,
path: "/",
},
/* {
name: "Meine Druckaufträge",
path: "/my/jobs",
}, */
{
name: "Mein Profil",
icon: <ContactRoundIcon className="w-4 h-4" />,
path: "/my/profile",
},
];
return (
<nav className="font-medium text-sm flex items-center gap-4 flex-row">
{sites.map((site) => (
<Link
key={site.path}
href={site.path}
className={cn("transition-colors hover:text-neutral-50 flex items-center gap-x-1", {
"text-primary-foreground font-semibold": pathname === site.path,
"text-neutral-500": pathname !== site.path,
})}
>
{site.icon}
<span>{site.name}</span>
</Link>
))}
</nav>
);
}

View File

@@ -0,0 +1,37 @@
"use client";
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast";
import { ScanFaceIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
export function LoginButton() {
const { toast } = useToast();
const [isLocked, setLocked] = useState(false);
function onClick() {
if (!isLocked) {
toast({
description: "Du wirst angemeldet...",
});
// Prevent multiple clicks because of login delay...
setLocked(true);
setTimeout(() => {
setLocked(false);
}, 1000 * 5);
}
toast({
description: "Bitte warte einen Moment...",
});
}
return (
<Button onClick={onClick} variant={"ghost"} className="gap-2 flex items-center" asChild disabled={isLocked}>
<Link href="/auth/login">
<ScanFaceIcon className="w-4 h-4" />
<span>Anmelden</span>
</Link>
</Button>
);
}

View File

@@ -0,0 +1,23 @@
"use client";
import { useToast } from "@/components/ui/use-toast";
import { logout } from "@/server/actions/authentication/logout";
import { LogOutIcon } from "lucide-react";
import Link from "next/link";
export function LogoutButton() {
const { toast } = useToast();
function onClick() {
toast({
description: "Du wirst nun abgemeldet...",
});
logout();
}
return (
<Link href="/" onClick={onClick} className="flex items-center gap-2">
<LogOutIcon className="w-4 h-4" />
<span>Abmelden</span>
</Link>
);
}

View File

@@ -0,0 +1,71 @@
import { DataCard } from "@/components/data-card";
import { validateRequest } from "@/server/auth";
import { db } from "@/server/db";
import { eq } from "drizzle-orm";
export default async function PersonalizedCards() {
const { user } = await validateRequest();
if (!user) {
return null;
}
const allPrintJobs = await db.query.printJobs.findMany({
with: {
printer: true,
},
where: (printJobs) => eq(printJobs.userId, user.id),
});
const totalPrintingMinutes = allPrintJobs
.filter((job) => !job.aborted)
.reduce((acc, curr) => acc + curr.durationInMinutes, 0);
const averagePrintingHoursPerWeek = totalPrintingMinutes / 60 / 52;
const mostUsedPrinters = {printer:{name:'-'}}; /*allPrintJobs
.map((job) => job.printer.name)
.reduce((acc, curr) => {
acc[curr] = (acc[curr] || 0) + 1;
return acc;
}, {});*/
const mostUsedPrinter = 0; /*Object.keys(mostUsedPrinters).reduce((a, b) =>
mostUsedPrinters[a] > mostUsedPrinters[b] ? a : b,
);*/
const printerSuccessRate = (allPrintJobs.filter((job) => job.aborted).length / allPrintJobs.length) * 100;
const mostUsedWeekday = {printer:{name:'-'}}; /*allPrintJobs
.map((job) => job.startAt.getDay())
.reduce((acc, curr) => {
acc[curr] = (acc[curr] || 0) + 1;
return acc;
}, {});*/
const mostUsedWeekdayIndex = ""; /*Object.keys(mostUsedWeekday).reduce((a, b) =>
mostUsedWeekday[a] > mostUsedWeekday[b] ? a : b,
);*/
const mostUsedWeekdayName = new Intl.DateTimeFormat("de-DE", {
weekday: "long",
}).format(new Date(0, 0, Number.parseInt(mostUsedWeekdayIndex)));
return (
<div className="flex flex-col lg:flex-row gap-4">
<DataCard
icon="Clock10"
title="Druckstunden"
description="insgesamt"
value={`${(totalPrintingMinutes / 60).toFixed(2)}h`}
/>
<DataCard
icon="Calendar"
title="Aktivster Tag"
description="(nach Anzahl der Aufträgen)"
value={mostUsedWeekdayName}
/>
<DataCard icon="Heart" title="Lieblingsdrucker" description="" value={mostUsedPrinter} />
<DataCard icon="Check" title="Druckerfolgsquote" description="" value={`${printerSuccessRate.toFixed(2)}%`} />
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { Badge } from "@/components/ui/badge";
import { PrinterStatus, translatePrinterStatus } from "@/utils/printers";
import { cn } from "@/utils/styles";
interface PrinterAvailabilityBadgeProps {
status: PrinterStatus;
}
export function PrinterAvailabilityBadge(props: PrinterAvailabilityBadgeProps) {
const { status } = props;
return (
<Badge
className={cn("pointer-events-none select-none", {
"bg-green-500 hover:bg-green-500 animate-pulse":
status === PrinterStatus.IDLE,
"bg-red-500 hover:bg-red-500 opacity-50":
status === PrinterStatus.OUT_OF_ORDER,
"bg-orange-500 hover:bg-orange-500": status === PrinterStatus.RESERVED,
})}
>
{translatePrinterStatus(status)}
</Badge>
);
}

View File

@@ -0,0 +1,44 @@
"use client";
import { revalidate } from "@/server/actions/timer";
import { fetcher } from "@/utils/fetch";
import useSWR from "swr";
interface CountdownProps {
jobId: string;
}
export function Countdown(props: CountdownProps) {
const { jobId } = props;
const { data, error, isLoading } = useSWR(`/api/job/${jobId}/remaining-time`, fetcher, {
refreshInterval: 1000 * 30,
});
if (error) {
return <span className="text-red-500">Ein Fehler ist aufgetreten.</span>;
}
if (isLoading) {
return <>...</>;
}
const days = Math.floor(data.remainingTime / (1000 * 60 * 60 * 24));
const hours = Math.floor((data.remainingTime % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((data.remainingTime % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((data.remainingTime % (1000 * 60)) / 1000);
if (days <= 0 && hours <= 0 && minutes <= 0 && seconds <= 0) {
revalidate();
}
return (
<span className="tabular-nums" suppressHydrationWarning>
{days > 0 && <>{`${days}`.padStart(2, "0")}d </>}
{hours === 0 && minutes === 0 ? (
<>{`${seconds}`.padStart(2, "0")}s</>
) : (
<>
{`${hours}`.padStart(2, "0")}h {`${minutes}`.padStart(2, "0")}min
</>
)}
</span>
);
}

View File

@@ -0,0 +1,87 @@
"use client";
import { PrinterReserveForm } from "@/app/printer/[printerId]/reserve/form";
import { Countdown } from "@/components/printer-card/countdown";
import { AlertDialogHeader } from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { UserRole, hasRole } from "@/server/auth/permissions";
import type { InferResultType } from "@/utils/drizzle";
import { PrinterStatus, derivePrinterStatus, translatePrinterStatus } from "@/utils/printers";
import { cn } from "@/utils/styles";
import type { RegisteredDatabaseUserAttributes } from "lucia";
import { CalendarPlusIcon, ChevronRightIcon } from "lucide-react";
import Link from "next/link";
import { Else, If, Then } from "react-if";
interface PrinterCardProps {
printer: InferResultType<"printers", { printJobs: true }>;
user?: RegisteredDatabaseUserAttributes | null;
}
export function PrinterCard(props: PrinterCardProps) {
const { printer, user } = props;
const status = derivePrinterStatus(printer);
const userIsLoggedIn = Boolean(user);
return (
<Card
className={cn("w-auto h-36", {
"opacity-50 select-none cursor-not-allowed": status === PrinterStatus.OUT_OF_ORDER,
})}
>
<CardHeader>
<div className="flex flex-row items-start justify-between">
<div>
<CardTitle>{printer.name}</CardTitle>
<CardDescription>{printer.description}</CardDescription>
</div>
<Badge
className={cn({
"bg-green-500 hover:bg-green-400": status === PrinterStatus.IDLE,
"bg-red-500 hover:bg-red-500": status === PrinterStatus.OUT_OF_ORDER,
"bg-yellow-500 hover:bg-yellow-400": status === PrinterStatus.RESERVED,
})}
>
{status === PrinterStatus.RESERVED && <Countdown jobId={printer.printJobs[0].id} />}
<If condition={status === PrinterStatus.RESERVED}>
<Else>{translatePrinterStatus(status)}</Else>
</If>
</Badge>
</div>
</CardHeader>
<CardContent className="flex justify-end">
<If condition={status === PrinterStatus.IDLE && userIsLoggedIn && !hasRole(user, UserRole.GUEST)}>
<Then>
<Dialog>
<DialogTrigger asChild>
<Button variant={"default"} className="flex items-center gap-2 w-full">
<CalendarPlusIcon className="w-4 h-4" />
<span>Reservieren</span>
</Button>
</DialogTrigger>
<DialogContent>
<AlertDialogHeader>
<DialogTitle>{printer.name} reservieren</DialogTitle>
<DialogDescription>Gebe die geschätzte Druckdauer an.</DialogDescription>
</AlertDialogHeader>
<PrinterReserveForm isDialog={true} printerId={printer.id} userId={user?.id ?? ""} />
</DialogContent>
</Dialog>
</Then>
</If>
{status === PrinterStatus.RESERVED && (
<Button asChild variant={"secondary"}>
<Link href={`/job/${printer.printJobs[0].id}`} className="flex items-center gap-2 w-full">
<ChevronRightIcon className="w-4 h-4" />
<span>Details anzeigen</span>
</Link>
</Button>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,141 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/utils/styles"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/utils/styles"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/utils/styles"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/utils/styles"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,115 @@
import * as React from "react"
import { ChevronRightIcon, DotsHorizontalIcon } from "@radix-ui/react-icons"
import { Slot } from "@radix-ui/react-slot"
import { cn } from "@/utils/styles"
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
))
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
))
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRightIcon />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<DotsHorizontalIcon className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/utils/styles"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@/utils/styles"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,370 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import {
NameType,
Payload,
ValueType,
} from "recharts/types/component/DefaultTooltipContent"
import { cn } from "@/utils/styles"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([_, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item.dataKey || item.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
)
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { Cross2Icon } from "@radix-ui/react-icons"
import { cn } from "@/utils/styles"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,205 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import {
CheckIcon,
ChevronRightIcon,
DotFilledIcon,
} from "@radix-ui/react-icons"
import { cn } from "@/utils/styles"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<DotFilledIcon className="h-4 w-4 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -0,0 +1,176 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form"
import { cn } from "@/utils/styles"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-[0.8rem] text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-[0.8rem] font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/utils/styles"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/utils/styles"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/utils/styles"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/utils/styles"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,164 @@
"use client"
import * as React from "react"
import {
CaretSortIcon,
CheckIcon,
ChevronDownIcon,
ChevronUpIcon,
} from "@radix-ui/react-icons"
import * as SelectPrimitive from "@radix-ui/react-select"
import { cn } from "@/utils/styles"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<CaretSortIcon className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,15 @@
import { cn } from "@/utils/styles"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,31 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster }

View File

@@ -0,0 +1,120 @@
import * as React from "react"
import { cn } from "@/utils/styles"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/utils/styles"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/utils/styles"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

View File

@@ -0,0 +1,129 @@
"use client"
import * as React from "react"
import { Cross2Icon } from "@radix-ui/react-icons"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/utils/styles"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<Cross2Icon className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View File

@@ -0,0 +1,35 @@
"use client"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
import { useToast } from "@/components/ui/use-toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@@ -0,0 +1,194 @@
"use client"
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

View File

@@ -0,0 +1,29 @@
"use server";
import { lucia, validateRequest } from "@/server/auth";
import strings from "@/utils/strings";
import { revalidatePath } from "next/cache";
import { cookies } from "next/headers";
export async function logout(path?: string) {
const { session } = await validateRequest();
if (!session) {
return {
error: strings.ERROR.NO_SESSION,
};
}
try {
await lucia.invalidateSession(session.id);
} catch (error) {
return {
error: strings.ERROR.NO_SESSION,
};
}
const sessionCookie = lucia.createBlankSessionCookie();
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
revalidatePath(path ?? "/");
}

View File

@@ -0,0 +1,305 @@
"use server";
import { validateRequest } from "@/server/auth";
import { UserRole } from "@/server/auth/permissions";
import { db } from "@/server/db";
import { printJobs, users } from "@/server/db/schema";
import { IS, guard } from "@/utils/guard";
import strings from "@/utils/strings";
import { type InferInsertModel, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
export async function createPrintJob(printJob: InferInsertModel<typeof printJobs>) {
const { user } = await validateRequest();
if (guard(user, IS, UserRole.GUEST)) {
return {
error: strings.ERROR.PERMISSION,
};
}
const dbUser = await db.query.users.findFirst({
// biome-ignore lint/style/noNonNullAssertion: guard already checks against null
where: eq(users.id, user!.id),
});
if (guard(dbUser, IS, UserRole.GUEST)) {
return {
error: strings.ERROR.PERMISSION,
};
}
try {
const result = await db.insert(printJobs).values(printJob).returning({
jobId: printJobs.id,
});
return result[0].jobId;
} catch (error) {
return {
error: "Druckauftrag konnte nicht hinzugefügt werden.",
};
}
}
/* async function updatePrintJob(jobId: string, printJob: InferInsertModel<typeof printJobs>) {
const { user } = await validateRequest();
if (guard(user, is, UserRole.GUEST)) {
return {
error: strings.ERROR.PERMISSION
}
}
const dbUser = await db.query.users.findFirst({
// biome-ignore lint/style/noNonNullAssertion: guard already checks against null
where: eq(users.id, user!.id),
});
if (guard(dbUser, is, UserRole.GUEST)) {
return {
error: strings.ERROR.PERMISSION
}
}
await db.update(printJobs).set(printJob).where(eq(printJobs.id, jobId));
} */
export async function abortPrintJob(jobId: string, reason: string) {
const { user } = await validateRequest();
if (guard(user, IS, UserRole.GUEST)) {
return {
error: strings.ERROR.PERMISSION,
};
}
const dbUser = await db.query.users.findFirst({
// biome-ignore lint/style/noNonNullAssertion: guard already checks against null
where: eq(users.id, user!.id),
});
if (guard(dbUser, IS, UserRole.GUEST)) {
return {
error: strings.ERROR.PERMISSION,
};
}
// Get the print job
const printJob = await db.query.printJobs.findFirst({
where: eq(printJobs.id, jobId),
});
if (!printJob) {
return {
error: "Druckauftrag nicht gefunden",
};
}
// Check if the print job is already aborted or completed
if (printJob.aborted) {
return { error: "Druckauftrag wurde bereits abgebrochen" };
}
if (new Date(printJob.startAt).getTime() + printJob.durationInMinutes * 60 * 1000 < Date.now()) {
return { error: "Druckauftrag ist bereits abgeschlossen" };
}
// Check if user is the owner of the print job
// biome-ignore lint/style/noNonNullAssertion: guard already checks against null
if (printJob.userId !== dbUser!.id && dbUser!.role !== UserRole.ADMIN) {
return {
error: strings.ERROR.PERMISSION,
};
}
// Get duration in minutes since startAt
const duration = Math.floor((Date.now() - new Date(printJob.startAt).getTime()) / 1000 / 60);
await db
.update(printJobs)
.set({
aborted: true,
abortReason: reason,
durationInMinutes: duration,
comments: `${printJob.comments}\n\n---${dbUser?.username}: Druckauftrag abgebrochen`,
})
.where(eq(printJobs.id, jobId));
revalidatePath("/");
}
export async function earlyFinishPrintJob(jobId: string) {
const { user } = await validateRequest();
if (guard(user, IS, UserRole.GUEST)) {
return {
error: strings.ERROR.PERMISSION,
};
}
const dbUser = await db.query.users.findFirst({
// biome-ignore lint/style/noNonNullAssertion: guard already checks against null
where: eq(users.id, user!.id),
});
if (guard(dbUser, IS, UserRole.GUEST)) {
return {
error: strings.ERROR.PERMISSION,
};
}
// Get the print job
const printJob = await db.query.printJobs.findFirst({
where: eq(printJobs.id, jobId),
});
if (!printJob) {
return { error: "Druckauftrag nicht gefunden" };
}
// Check if the print job is already aborted or completed
if (printJob.aborted) {
return { error: "Druckauftrag wurde bereits abgebrochen" };
}
if (new Date(printJob.startAt).getTime() + printJob.durationInMinutes * 60 * 1000 < Date.now()) {
return { error: "Druckauftrag ist bereits abgeschlossen" };
}
// Check if user is the owner of the print job
// biome-ignore lint/style/noNonNullAssertion: guard already checks against null
if (printJob.userId !== dbUser!.id && dbUser!.role !== UserRole.ADMIN) {
return {
error: strings.ERROR.PERMISSION,
};
}
// Get duration in minutes since startAt
const duration = Math.floor((Date.now() - new Date(printJob.startAt).getTime()) / 1000 / 60);
await db
.update(printJobs)
.set({
durationInMinutes: duration,
comments: `${printJob.comments}\n\n---${dbUser?.username}: Druckauftrag vorzeitig abgeschlossen`,
})
.where(eq(printJobs.id, jobId));
revalidatePath("/");
}
export async function extendPrintJob(jobId: string, minutes: number, hours: number) {
const { user } = await validateRequest();
if (guard(user, IS, UserRole.GUEST)) {
return {
error: strings.ERROR.PERMISSION,
};
}
const dbUser = await db.query.users.findFirst({
// biome-ignore lint/style/noNonNullAssertion: guard already checks against null
where: eq(users.id, user!.id),
});
if (guard(dbUser, IS, UserRole.GUEST)) {
return {
error: strings.ERROR.PERMISSION,
};
}
// Get the print job
const printJob = await db.query.printJobs.findFirst({
where: eq(printJobs.id, jobId),
});
if (!printJob) {
return { error: "Druckauftrag nicht gefunden" };
}
// Check if the print job is already aborted or completed
if (printJob.aborted) {
return { error: "Druckauftrag wurde bereits abgebrochen" };
}
if (new Date(printJob.startAt).getTime() + printJob.durationInMinutes * 60 * 1000 < Date.now()) {
return { error: "Druckauftrag ist bereits abgeschlossen" };
}
// Check if user is the owner of the print job
// biome-ignore lint/style/noNonNullAssertion: guard already checks against null
if (printJob.userId !== dbUser!.id && dbUser!.role !== UserRole.ADMIN) {
return {
error: strings.ERROR.PERMISSION,
};
}
const duration = minutes + hours * 60;
await db
.update(printJobs)
.set({
durationInMinutes: printJob.durationInMinutes + duration,
comments: `${printJob.comments}\n\n---${dbUser?.username}: Verlängert um ${hours} Stunden und ${minutes} Minuten`,
})
.where(eq(printJobs.id, jobId));
revalidatePath("/");
}
export async function updatePrintComments(jobId: string, comments: string) {
const { user } = await validateRequest();
if (guard(user, IS, UserRole.GUEST)) {
return {
error: strings.ERROR.PERMISSION,
};
}
const dbUser = await db.query.users.findFirst({
// biome-ignore lint/style/noNonNullAssertion: guard already checks against null
where: eq(users.id, user!.id),
});
if (guard(dbUser, IS, UserRole.GUEST)) {
return {
error: strings.ERROR.PERMISSION,
};
}
// Get the print job
const printJob = await db.query.printJobs.findFirst({
where: eq(printJobs.id, jobId),
});
if (!printJob) {
return { error: "Druckauftrag nicht gefunden" };
}
// Check if the print job is already aborted or completed
if (printJob.aborted) {
return { error: "Druckauftrag wurde bereits abgebrochen" };
}
if (new Date(printJob.startAt).getTime() + printJob.durationInMinutes * 60 * 1000 < Date.now()) {
return { error: "Druckauftrag ist bereits abgeschlossen" };
}
// Check if user is the owner of the print job
// biome-ignore lint/style/noNonNullAssertion: guard already checks against null
if (printJob.userId !== dbUser!.id && dbUser!.role !== UserRole.ADMIN) {
return {
error: strings.ERROR.PERMISSION,
};
}
await db
.update(printJobs)
.set({
comments,
})
.where(eq(printJobs.id, jobId));
revalidatePath("/");
}

View File

@@ -0,0 +1,130 @@
"use server";
import { validateRequest } from "@/server/auth";
import { UserRole } from "@/server/auth/permissions";
import { db } from "@/server/db";
import { printers, users } from "@/server/db/schema";
import { IS_NOT, guard } from "@/utils/guard";
import strings from "@/utils/strings";
import { type InferInsertModel, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
export async function createPrinter(printer: InferInsertModel<typeof printers>) {
const { user } = await validateRequest();
if (guard(user, IS_NOT, UserRole.ADMIN)) {
return {
error: strings.ERROR.PERMISSION,
};
}
const dbUser = await db.query.users.findFirst({
// biome-ignore lint/style/noNonNullAssertion: guard already checks against null
where: eq(users.id, user!.id),
});
if (guard(dbUser, IS_NOT, UserRole.ADMIN)) {
return {
error: strings.ERROR.PERMISSION,
};
}
if (!printer) {
return {
error: "Druckerdaten sind erforderlich.",
};
}
try {
await db.insert(printers).values(printer);
} catch (error) {
return {
error: "Drucker konnte nicht hinzugefügt werden.",
};
}
revalidatePath("/");
}
export async function updatePrinter(id: string, data: InferInsertModel<typeof printers>) {
const { user } = await validateRequest();
if (guard(user, IS_NOT, UserRole.ADMIN)) {
return {
error: strings.ERROR.PERMISSION,
};
}
const dbUser = await db.query.users.findFirst({
// biome-ignore lint/style/noNonNullAssertion: guard already checks against null
where: eq(users.id, user!.id),
});
if (guard(dbUser, IS_NOT, UserRole.ADMIN)) {
return {
error: strings.ERROR.PERMISSION,
};
}
if (!data) {
return {
error: "Druckerdaten sind erforderlich.",
};
}
try {
await db.update(printers).set(data).where(eq(printers.id, id));
} catch (error) {
return {
error: "Druckerdaten sind erforderlich.",
};
}
revalidatePath("/");
}
export async function deletePrinter(id: string) {
const { user } = await validateRequest();
if (guard(user, IS_NOT, UserRole.ADMIN)) {
return {
error: strings.ERROR.PERMISSION,
};
}
const dbUser = await db.query.users.findFirst({
// biome-ignore lint/style/noNonNullAssertion: guard already checks against null
where: eq(users.id, user!.id),
});
if (guard(dbUser, IS_NOT, UserRole.ADMIN)) {
return {
error: strings.ERROR.PERMISSION,
};
}
try {
await db.delete(printers).where(eq(printers.id, id));
} catch (error) {
if (error instanceof Error) {
return {
error: error.message,
};
}
return {
error: "Ein unbekannter Fehler ist aufgetreten.",
};
}
revalidatePath("/");
}
export async function getPrinters() {
return await db.query.printers.findMany({
with: {
printJobs: {
limit: 1,
orderBy: (printJobs, { desc }) => [desc(printJobs.startAt)],
},
},
});
}

View File

@@ -0,0 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
export async function revalidate() {
revalidatePath("/");
}

View File

@@ -0,0 +1,56 @@
"use server";
import { validateRequest } from "@/server/auth";
import { UserRole } from "@/server/auth/permissions";
import { db } from "@/server/db";
import { users } from "@/server/db/schema";
import { IS, IS_NOT, guard } from "@/utils/guard";
import strings from "@/utils/strings";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
/**
* Deletes a user from the database
* @param userId User ID to delete
* @param path Path to revalidate
*/
export async function deleteUser(userId: string, path?: string) {
const { user } = await validateRequest();
if (guard(user, IS_NOT, UserRole.ADMIN)) {
return {
error: strings.ERROR.PERMISSION,
};
}
const dbUser = await db.query.users.findFirst({
// biome-ignore lint/style/noNonNullAssertion: guard already checks against null
where: eq(users.id, user!.id),
});
if (guard(dbUser, IS_NOT, UserRole.ADMIN)) {
return {
error: strings.ERROR.PERMISSION,
};
}
const targetUser = await db.query.users.findFirst({
where: eq(users.id, userId),
});
if (!targetUser) {
return {
error: "Benutzer nicht gefunden",
};
}
if (guard(targetUser, IS, UserRole.ADMIN)) {
return {
error: "Admins können nicht gelöscht werden.",
};
}
await db.delete(users).where(eq(users.id, userId));
revalidatePath(path ?? "/admin/users");
}

View File

@@ -0,0 +1,41 @@
import type { formSchema } from "@/app/admin/users/form";
import { validateRequest } from "@/server/auth";
import { UserRole } from "@/server/auth/permissions";
import { db } from "@/server/db";
import { users } from "@/server/db/schema";
import { IS_NOT, guard } from "@/utils/guard";
import strings from "@/utils/strings";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import type { z } from "zod";
/**
* Updates a user in the database
* @param userId User ID to update
* @param data Updated user data
* @param path Path to revalidate
*/
export async function updateUser(userId: string, data: z.infer<typeof formSchema>, path?: string) {
const { user } = await validateRequest();
if (guard(user, IS_NOT, UserRole.ADMIN)) {
return {
error: strings.ERROR.PERMISSION,
};
}
const dbUser = await db.query.users.findFirst({
// biome-ignore lint/style/noNonNullAssertion: guard already checks against null
where: eq(users.id, user!.id),
});
if (guard(dbUser, IS_NOT, UserRole.ADMIN)) {
return {
error: strings.ERROR.PERMISSION,
};
}
await db.update(users).set(data).where(eq(users.id, userId));
revalidatePath(path ?? "/admin/users");
}

View File

@@ -0,0 +1,80 @@
"use server";
import type { formSchema } from "@/app/admin/users/form";
import { validateRequest } from "@/server/auth";
import { UserRole } from "@/server/auth/permissions";
import { db } from "@/server/db";
import { users } from "@/server/db/schema";
import { IS, IS_NOT, guard } from "@/utils/guard";
import strings from "@/utils/strings";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import type { z } from "zod";
/**
* @deprecated
*/
export async function updateUser(userId: string, data: z.infer<typeof formSchema>) {
const { user } = await validateRequest();
if (guard(user, IS_NOT, UserRole.ADMIN)) {
return {
error: strings.ERROR.PERMISSION,
};
}
const dbUser = await db.query.users.findFirst({
// biome-ignore lint/style/noNonNullAssertion: guard already checks against null
where: eq(users.id, user!.id),
});
if (guard(dbUser, IS_NOT, UserRole.ADMIN)) {
return {
error: strings.ERROR.PERMISSION,
};
}
await db.update(users).set(data).where(eq(users.id, userId));
revalidatePath("/admin/users");
}
/**
* @deprecated
*/
export async function deleteUser(userId: string) {
const { user } = await validateRequest();
if (guard(user, IS_NOT, UserRole.ADMIN)) {
return {
error: strings.ERROR.PERMISSION,
};
}
const dbUser = await db.query.users.findFirst({
// biome-ignore lint/style/noNonNullAssertion: guard already checks against null
where: eq(users.id, user!.id),
});
if (guard(dbUser, IS_NOT, UserRole.ADMIN)) {
return {
error: strings.ERROR.PERMISSION,
};
}
const targetUser = await db.query.users.findFirst({
where: eq(users.id, userId),
});
if (!targetUser) {
return { error: "Benutzer nicht gefunden" };
}
if (guard(targetUser, IS, UserRole.ADMIN)) {
return { error: "Kann keinen Admin löschen" };
}
await db.delete(users).where(eq(users.id, userId));
revalidatePath("/admin/users");
}

View File

@@ -0,0 +1,72 @@
import type { UserRole } from "@/server/auth/permissions";
import { db } from "@/server/db";
import { sessions, users } from "@/server/db/schema";
import { DrizzleSQLiteAdapter } from "@lucia-auth/adapter-drizzle";
import { Lucia, type RegisteredDatabaseUserAttributes, type Session } from "lucia";
import { cookies } from "next/headers";
import { cache } from "react";
//@ts-ignore
const adapter = new DrizzleSQLiteAdapter(db, sessions, users);
export const lucia = new Lucia(adapter, {
sessionCookie: {
expires: false,
attributes: {
secure: process.env.NODE_ENV === "production",
},
},
getUserAttributes: (attributes) => {
return {
id: attributes.id,
username: attributes.username,
displayName: attributes.displayName,
email: attributes.email,
role: attributes.role,
};
},
});
export const validateRequest = cache(
async (): Promise<{ user: RegisteredDatabaseUserAttributes; session: Session } | { user: null; session: null }> => {
const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? null;
if (!sessionId) {
return {
user: null,
session: null,
};
}
const result = await lucia.validateSession(sessionId);
// next.js throws when you attempt to set cookie when rendering page
try {
if (result.session?.fresh) {
const sessionCookie = lucia.createSessionCookie(result.session.id);
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
}
if (!result.session) {
const sessionCookie = lucia.createBlankSessionCookie();
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
}
} catch {}
return result as {
user: RegisteredDatabaseUserAttributes;
session: Session;
};
},
);
declare module "lucia" {
interface Register {
Lucia: typeof Lucia;
DatabaseUserAttributes: {
id: string;
github_id: number;
username: string;
displayName: string;
email: string;
role: UserRole;
};
}
}

View File

@@ -0,0 +1,54 @@
import { GitHub } from "arctic";
import { ALLOWED_CALLBACK_HOSTS, FRONTEND_URL, OAUTH_CALLBACK_URL } from "@/utils/api-config";
// Helper-Funktion, um die passende Callback-URL zu bestimmen
const getCallbackUrl = () => {
// Wenn eine spezifische OAuth-Callback-URL definiert ist, verwende diese
if (process.env.NEXT_PUBLIC_OAUTH_CALLBACK_URL) {
console.log("Verwende konfigurierte OAuth Callback URL:", process.env.NEXT_PUBLIC_OAUTH_CALLBACK_URL);
return process.env.NEXT_PUBLIC_OAUTH_CALLBACK_URL;
}
// Für spezifischen Unternehmens-Hostname
if (FRONTEND_URL.includes('corpintra.net') || FRONTEND_URL.includes('m040tbaraspi001')) {
const url = `http://m040tbaraspi001.de040.corpintra.net/auth/login/callback`;
console.log("Verwende Unternehmens-Hostname für OAuth Callback:", url);
return url;
}
// Fallback für lokale Entwicklung
console.log("Verwende Standard OAuth Callback URL:", OAUTH_CALLBACK_URL);
return OAUTH_CALLBACK_URL;
};
// Berechne die Callback-URL
export const USED_CALLBACK_URL = getCallbackUrl();
// Erstelle GitHub OAuth-Client mit expliziter Redirect-URI
export const github = new GitHub(
process.env.OAUTH_CLIENT_ID as string,
process.env.OAUTH_CLIENT_SECRET as string,
{
enterpriseDomain: "https://git.i.mercedes-benz.com",
redirectURI: USED_CALLBACK_URL,
}
);
// Hilfsfunktion zur Validierung von OAuth-Callbacks
export function isValidCallbackHost(url: string): boolean {
try {
const parsedUrl = new URL(url);
return ALLOWED_CALLBACK_HOSTS.some(host => parsedUrl.hostname === host ||
parsedUrl.hostname.includes(host));
} catch (e) {
console.error("Ungültige URL beim Validieren des Callback-Hosts:", url, e);
return false;
}
}
export interface GitHubUserResult {
id: number;
login: string;
name: string;
email: string;
}

View File

@@ -0,0 +1,28 @@
import type { RegisteredDatabaseUserAttributes } from "lucia";
export enum UserRole {
ADMIN = "admin",
USER = "user",
GUEST = "guest",
}
/**
* @deprecated
*/
export function hasRole(user: RegisteredDatabaseUserAttributes | null | undefined, role: UserRole) {
return user?.role === role;
}
/**
* @deprecated
*/
export function translateUserRole(role: UserRole) {
switch (role) {
case UserRole.ADMIN:
return "Administrator";
case UserRole.USER:
return "Benutzer";
case UserRole.GUEST:
return "Gast";
}
}

View File

@@ -0,0 +1,54 @@
import type { printJobs } from "@/server/db/schema";
import type { InferResultType } from "@/utils/drizzle";
import type { InferSelectModel } from "drizzle-orm";
export interface PrinterErrorRate {
printerId: string;
name: string;
errorRate: number; // Error rate as a percentage (0-100)
}
/**
* Calculates the error rate for print jobs aggregated by printer as a percentage.
*
* @param pJobs - Array of print job objects.
* @returns An array of PrinterErrorRate objects, each containing the printer ID and its error rate.
*/
export function calculatePrinterErrorRate(
pJobs: InferResultType<"printJobs", { printer: true }>[],
): PrinterErrorRate[] {
if (pJobs.length === 0) {
return []; // No jobs, no data.
}
const printers = pJobs.map((job) => job.printer);
// Group jobs by printer ID
const jobsByPrinter: Record<string, InferSelectModel<typeof printJobs>[]> = pJobs.reduce(
(acc, job) => {
if (!acc[job.printerId]) {
acc[job.printerId] = [];
}
acc[job.printerId].push(job);
return acc;
},
{} as Record<string, InferSelectModel<typeof printJobs>[]>,
);
// Calculate the error rate for each printer
const printerErrorRates: PrinterErrorRate[] = Object.entries(jobsByPrinter).map(([printerId, jobs]) => {
const totalJobs = jobs.length;
const abortedJobsCount = jobs.filter((job) => job.aborted).length;
const errorRate = (abortedJobsCount / totalJobs) * 100;
const printer = printers.find((printer) => printer.id === printerId);
const printerName = printer ? printer.name : "Unbekannter Drucker";
return {
printerId,
name: printerName,
errorRate: Number.parseFloat(errorRate.toFixed(2)), // Rounded to two decimal places
};
});
return printerErrorRates;
}

View File

@@ -0,0 +1,39 @@
import type { InferResultType } from "@/utils/drizzle";
export interface AbortReasonCount {
abortReason: string;
count: number;
}
/**
* Calculates the count of each unique abort reason for print jobs.
*
* @param pJobs - Array of print job objects.
* @returns An array of AbortReasonCount objects, each containing the abort reason and its count.
*/
export function calculateAbortReasonsCount(pJobs: InferResultType<"printJobs">[]): AbortReasonCount[] {
if (pJobs.length === 0) {
return []; // No jobs, no data.
}
// Filter aborted jobs and count each abort reason
const abortReasonsCount = pJobs
.filter((job) => job.aborted && job.abortReason) // Consider only aborted jobs with a reason
.reduce(
(acc, job) => {
const reason = job.abortReason || "Unbekannter Grund";
if (!acc[reason]) {
acc[reason] = 0;
}
acc[reason]++;
return acc;
},
{} as Record<string, number>,
);
// Convert the result to an array of AbortReasonCount objects
return Object.entries(abortReasonsCount).map(([abortReason, count]) => ({
abortReason,
count,
}));
}

View File

@@ -0,0 +1,97 @@
import type { InferResultType } from "@/utils/drizzle";
type UsagePerDay = {
day: number; // 0 (Sunday) to 6 (Saturday)
usageMinutes: number;
};
function aggregateUsageByDay(jobs: InferResultType<"printJobs">[]): {
usageData: UsagePerDay[];
earliestDate: Date;
latestDate: Date;
} {
const usagePerDayMap = new Map<number, number>();
let earliestDate: Date | null = null;
let latestDate: Date | null = null;
for (const job of jobs) {
let remainingDuration = job.durationInMinutes;
const currentStart = new Date(job.startAt);
// Update earliest and latest dates
if (!earliestDate || currentStart < earliestDate) {
earliestDate = new Date(currentStart);
}
const jobEnd = new Date(currentStart);
jobEnd.setMinutes(jobEnd.getMinutes() + job.durationInMinutes);
if (!latestDate || jobEnd > latestDate) {
latestDate = new Date(jobEnd);
}
while (remainingDuration > 0) {
const day = currentStart.getDay();
// Calculate minutes remaining in the current day
const minutesRemainingInDay = (24 - currentStart.getHours()) * 60 - currentStart.getMinutes();
const minutesToAdd = Math.min(remainingDuration, minutesRemainingInDay);
// Update the usage for the current day
const usageMinutes = usagePerDayMap.get(day) || 0;
usagePerDayMap.set(day, usageMinutes + minutesToAdd);
// Update remaining duration and move to the next day
remainingDuration -= minutesToAdd;
currentStart.setDate(currentStart.getDate() + 1);
currentStart.setHours(0, 0, 0, 0); // Start at the beginning of the next day
}
}
const usageData: UsagePerDay[] = Array.from({ length: 7 }, (_, day) => ({
day,
usageMinutes: usagePerDayMap.get(day) || 0,
}));
if (earliestDate === null) {
earliestDate = new Date();
}
if (latestDate === null) {
latestDate = new Date();
}
return { usageData, earliestDate: earliestDate, latestDate: latestDate };
}
function countWeekdays(startDate: Date, endDate: Date): number[] {
const countPerDay = Array(7).fill(0);
const currentDate = new Date(startDate);
currentDate.setHours(0, 0, 0, 0); // Ensure starting at midnight
endDate.setHours(0, 0, 0, 0); // Ensure ending at midnight
while (currentDate <= endDate) {
const day = currentDate.getDay();
countPerDay[day]++;
currentDate.setDate(currentDate.getDate() + 1);
}
return countPerDay;
}
export function forecastPrinterUsage(jobs: InferResultType<"printJobs">[]): number[] {
const { usageData, earliestDate, latestDate } = aggregateUsageByDay(jobs);
// Count the number of times each weekday occurs in the data period
const weekdaysCount = countWeekdays(earliestDate, latestDate);
const forecasts: number[] = [];
for (const data of usageData) {
const dayCount = weekdaysCount[data.day];
let usagePrediction = data.usageMinutes / dayCount;
if (Number.isNaN(usagePrediction)) {
usagePrediction = 0;
}
forecasts.push(Math.round(usagePrediction));
}
return forecasts;
}

View File

@@ -0,0 +1,32 @@
import type { InferResultType } from "@/utils/drizzle";
export function calculatePrinterUtilization(jobs: InferResultType<"printJobs", { printer: true }>[]) {
const printers = jobs.reduce<Record<string, string>>((acc, job) => {
acc[job.printerId] = job.printer.name;
return acc;
}, {});
const usedTimePerPrinter: Record<string, number> = jobs.reduce(
(acc, job) => {
acc[job.printer.id] = (acc[job.printer.id] || 0) + job.durationInMinutes;
return acc;
},
{} as Record<string, number>,
);
const totalTimeInMinutes = 60 * 35 * 3; // 60 Minutes * 35h * 3 Weeks
// 35h Woche, 3 mal in der Woche in TBA
const printerUtilizationPercentage = Object.keys(usedTimePerPrinter).map((printerId) => {
const usedTime = usedTimePerPrinter[printerId];
return {
printerId,
name: printers[printerId],
utilizationPercentage: usedTime / totalTimeInMinutes,
};
});
return printerUtilizationPercentage;
}

View File

@@ -0,0 +1,52 @@
import type { printJobs } from "@/server/db/schema";
import { endOfDay, endOfMonth, endOfWeek, startOfDay, startOfMonth, startOfWeek } from "date-fns";
import type { InferSelectModel } from "drizzle-orm";
interface PrintVolumes {
today: number;
thisWeek: number;
thisMonth: number;
}
/**
* Calculates the number of print jobs for today, this week, and this month.
*
* @param printJobs - Array of print job objects.
* @returns An object with counts of print jobs for today, this week, and this month.
*/
export function calculatePrintVolumes(pJobs: InferSelectModel<typeof printJobs>[]): PrintVolumes {
const now = new Date();
// Define time ranges with week starting on Monday
const timeRanges = {
today: { start: startOfDay(now), end: endOfDay(now) },
thisWeek: {
start: startOfWeek(now, { weekStartsOn: 1 }),
end: endOfWeek(now, { weekStartsOn: 1 }),
},
thisMonth: { start: startOfMonth(now), end: endOfMonth(now) },
};
// Initialize counts
const volumes: PrintVolumes = {
today: 0,
thisWeek: 0,
thisMonth: 0,
};
// Iterate over print jobs and count based on time ranges
for (const job of pJobs) {
const jobStart = new Date(job.startAt);
if (jobStart >= timeRanges.today.start && jobStart <= timeRanges.today.end) {
volumes.today += 1;
}
if (jobStart >= timeRanges.thisWeek.start && jobStart <= timeRanges.thisWeek.end) {
volumes.thisWeek += 1;
}
if (jobStart >= timeRanges.thisMonth.start && jobStart <= timeRanges.thisMonth.end) {
volumes.thisMonth += 1;
}
}
return volumes;
}

View File

@@ -0,0 +1,51 @@
// Basis-URL für Backend-API
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://192.168.0.105:5000";
// Frontend-URL für Callbacks - unterstützt mehrere Domains
const getFrontendUrl = () => {
// Priorität 1: Explizit gesetzte Umgebungsvariable
if (process.env.NEXT_PUBLIC_FRONTEND_URL) {
return process.env.NEXT_PUBLIC_FRONTEND_URL;
}
// Priorität 2: Spezifischer Hostname für das Netzwerk
if (typeof window !== 'undefined') {
// Im Browser: Prüfen auf m040tbaraspi001.de040.corpintra.net
const hostname = window.location.hostname;
if (hostname === 'm040tbaraspi001' ||
hostname === 'm040tbaraspi001.de040.corpintra.net' ||
hostname.includes('corpintra.net')) {
return `http://${hostname}`;
}
}
// Priorität 3: Default für Localhost
return "http://localhost:3000";
};
export const FRONTEND_URL = getFrontendUrl();
// OAuth Callback URL - muss exakt mit der registrierten URL in GitHub übereinstimmen
export const OAUTH_CALLBACK_URL = process.env.NEXT_PUBLIC_OAUTH_CALLBACK_URL ||
`${FRONTEND_URL}/auth/login/callback`;
// Liste der erlaubten Hostnamen für OAuth-Callbacks
export const ALLOWED_CALLBACK_HOSTS = [
'localhost',
'm040tbaraspi001',
'm040tbaraspi001.de040.corpintra.net',
'192.168.0.105'
];
// Endpunkte für die verschiedenen Ressourcen
export const API_ENDPOINTS = {
PRINTERS: `${API_BASE_URL}/api/printers`,
JOBS: `${API_BASE_URL}/api/jobs`,
USERS: `${API_BASE_URL}/api/users`,
// OAuth-spezifische Endpunkte
AUTH: {
LOGIN: `${API_BASE_URL}/api/auth/login`,
CALLBACK: `${API_BASE_URL}/api/auth/callback`,
}
};

View File

@@ -0,0 +1,29 @@
import type * as schema from "@/server/db/schema";
import type { BuildQueryResult, DBQueryConfig, ExtractTablesWithRelations } from "drizzle-orm";
type Schema = typeof schema;
type TSchema = ExtractTablesWithRelations<Schema>;
/**
* Infer the relation type of a table.
*/
export type IncludeRelation<TableName extends keyof TSchema> = DBQueryConfig<
"one" | "many",
boolean,
TSchema,
TSchema[TableName]
>["with"];
/**
* Infer the result type of a query with optional relations.
*/
export type InferResultType<
TableName extends keyof TSchema,
With extends IncludeRelation<TableName> | undefined = undefined,
> = BuildQueryResult<
TSchema,
TSchema[TableName],
{
with: With;
}
>;

View File

@@ -0,0 +1,59 @@
import strings from "@/utils/strings";
/**
* Base error class.
*/
class BaseError extends Error {
constructor(message?: string) {
// Pass the message to the Error constructor
super(message);
// Set the name of the error
this.name = this.constructor.name;
// Capture the stack trace
if (Error.captureStackTrace) {
Error.captureStackTrace(this, this.constructor);
} else {
this.stack = new Error(message).stack;
}
}
}
/**
* Permission error class.
*/
export class PermissionError extends BaseError {
constructor() {
super(strings.ERROR.PERMISSION);
}
}
/**
* Authentication error class.
*/
export class AuthenticationError extends BaseError {
constructor() {
super(strings.ERROR.NO_SESSION);
}
}
/**
* Validation error class.
*/
export class ValidationError extends BaseError {
constructor() {
super(strings.ERROR.VALIDATION);
}
}
/**
* Not found error class.
*/
export class NotFoundError extends BaseError {
constructor() {
super(strings.ERROR.NOT_FOUND);
}
}
export default BaseError;

View File

@@ -0,0 +1,78 @@
import { API_ENDPOINTS } from './api-config';
// Typdefinitionen für API-Responses
export interface Printer {
id: string;
name: string;
ip: string;
status: string;
is_enabled: boolean;
}
export interface Job {
id: string;
printer_id: string;
user_id: string;
start_time: string;
end_time: string;
status: string;
}
// Fetcher für SWR mit Fehlerbehandlung
const fetchWithErrorHandling = async (url: string) => {
const response = await fetch(url);
if (!response.ok) {
const error = new Error('Ein Fehler ist bei der API-Anfrage aufgetreten');
throw error;
}
return response.json();
};
// API-Funktionen
export const api = {
// Drucker-Endpunkte
printers: {
getAll: () => fetchWithErrorHandling(API_ENDPOINTS.PRINTERS),
getById: (id: string) => fetchWithErrorHandling(`${API_ENDPOINTS.PRINTERS}/${id}`),
create: (data: Partial<Printer>) =>
fetch(API_ENDPOINTS.PRINTERS, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}).then(res => res.json()),
update: (id: string, data: Partial<Printer>) =>
fetch(`${API_ENDPOINTS.PRINTERS}/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}).then(res => res.json()),
delete: (id: string) =>
fetch(`${API_ENDPOINTS.PRINTERS}/${id}`, {
method: 'DELETE',
}).then(res => res.json()),
},
// Jobs-Endpunkte
jobs: {
getAll: () => fetchWithErrorHandling(API_ENDPOINTS.JOBS),
getById: (id: string) => fetchWithErrorHandling(`${API_ENDPOINTS.JOBS}/${id}`),
create: (data: Partial<Job>) =>
fetch(API_ENDPOINTS.JOBS, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}).then(res => res.json()),
update: (id: string, data: Partial<Job>) =>
fetch(`${API_ENDPOINTS.JOBS}/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}).then(res => res.json()),
delete: (id: string) =>
fetch(`${API_ENDPOINTS.JOBS}/${id}`, {
method: 'DELETE',
}).then(res => res.json()),
},
};

View File

@@ -0,0 +1 @@
export const fetcher = (url: string) => fetch(url).then((response) => response.json());

Some files were not shown because too many files have changed in this diff Show More