torben fronted wiederhergestellt

This commit is contained in:
2025-04-01 12:01:57 +02:00
parent 1f6feafecc
commit cef6abec32
122 changed files with 14055 additions and 5096 deletions

View File

@@ -1,26 +0,0 @@
"use client";
import { BarChart } from "@tremor/react";
interface AbortReasonsBarChartProps {
// biome-ignore lint/suspicious/noExplicitAny: temporary fix
data: any[];
}
export function AbortReasonsBarChart(props: AbortReasonsBarChartProps) {
const { data } = props;
const dataFormatter = (number: number) => Intl.NumberFormat("de-DE").format(number).toString();
return (
<BarChart
className="mt-6"
data={data}
index="name"
categories={["Anzahl"]}
colors={["blue"]}
valueFormatter={dataFormatter}
yAxisWidth={48}
/>
);
}

View File

@@ -1,20 +0,0 @@
"use client";
import { DonutChart, Legend } from "@tremor/react";
const dataFormatter = (number: number) => Intl.NumberFormat("de-DE").format(number).toString();
interface LoadFactorChartProps {
// biome-ignore lint/suspicious/noExplicitAny: temp. fix
data: any[];
}
export function LoadFactorChart(props: LoadFactorChartProps) {
const { data } = props;
return (
<div className="flex gap-4">
<DonutChart data={data} variant="donut" colors={["green", "yellow"]} valueFormatter={dataFormatter} />
<Legend categories={["Frei", "Belegt"]} colors={["green", "yellow"]} className="max-w-xs" />
</div>
);
}

View File

@@ -0,0 +1,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

@@ -1,24 +0,0 @@
"use client";
import { DonutChart, Legend } from "@tremor/react";
const dataFormatter = (number: number) => Intl.NumberFormat("de-DE").format(number).toString();
interface PrintJobsDonutProps {
// biome-ignore lint/suspicious/noExplicitAny: temp. fix
data: any[];
}
export function PrintJobsDonut(props: PrintJobsDonutProps) {
const { data } = props;
return (
<div className="flex gap-4">
<DonutChart data={data} variant="donut" colors={["green", "red", "yellow"]} valueFormatter={dataFormatter} />
<Legend
categories={["Abgeschlossen", "Abgebrochen", "Ausstehend"]}
colors={["green", "red", "yellow"]}
className="max-w-xs"
/>
</div>
);
}

View File

@@ -1,18 +1,20 @@
import { AdminSidebar } from "@/app/admin/admin-sidebar";
import { validateRequest } from "@/server/auth";
import { UserRole } from "@/server/auth/permissions";
import { guard, is_not } from "@/utils/heimdall";
import { 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)) {
if (guard(user, IS_NOT, UserRole.ADMIN)) {
redirect("/");
}

View File

@@ -1,10 +1,17 @@
import { AbortReasonsBarChart } from "@/app/admin/charts/abort-reasons";
import { LoadFactorChart } from "@/app/admin/charts/load-factor";
import { PrintJobsDonut } from "@/app/admin/charts/printjobs-donut";
import { 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 = {
@@ -14,114 +21,100 @@ export const metadata: Metadata = {
export const dynamic = "force-dynamic";
export default async function AdminPage() {
const allPrintJobs = await db.query.printJobs.findMany({
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 totalAmountOfPrintJobs = allPrintJobs.length;
const now = new Date();
const completedPrintJobs = allPrintJobs.filter((job) => {
const currentPrintJobs = printJobs.filter((job) => {
if (job.aborted) return false;
const endAt = new Date(job.startAt).getTime() + job.durationInMinutes * 1000 * 60;
return endAt < now.getTime();
}).length;
const abortedPrintJobs = allPrintJobs.filter((job) => job.aborted).length;
const pendingPrintJobs = totalAmountOfPrintJobs - completedPrintJobs - abortedPrintJobs;
const abortedPrintJobsReasons = Object.entries(
allPrintJobs.reduce((accumulator: Record<string, number>, job) => {
if (job.aborted && job.abortReason) {
if (!accumulator[job.abortReason]) {
accumulator[job.abortReason] = 1;
} else {
accumulator[job.abortReason]++;
}
}
return accumulator;
}, {}),
).map(([name, count]) => ({ name, Anzahl: count }));
const endAt = job.startAt.getTime() + job.durationInMinutes * 1000 * 60;
const mostAbortedPrinter = allPrintJobs.reduce((prev, current) => (prev.aborted > current.aborted ? prev : current));
const mostUsedPrinter = allPrintJobs.reduce((prev, current) =>
prev.durationInMinutes > current.durationInMinutes ? prev : current,
);
const allPrinters = await db.query.printers.findMany();
const freePrinters = allPrinters.filter((printer) => {
const jobs = allPrintJobs.filter((job) => job.printerId === printer.id);
const now = new Date();
const inUse = jobs.some((job) => {
const endAt = new Date(job.startAt).getTime() + job.durationInMinutes * 1000 * 60;
return endAt > now.getTime();
});
return !inUse;
return 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>
{allPrinters.map((printer) => (
<TabsTrigger key={printer.id} value={printer.id}>
{printer.name}
</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">
<DataCard title="Drucker mit meisten Reservierungen" value={mostUsedPrinter.printer.name} icon="Printer" />
<DataCard title="Drucker mit meisten Abbrüchen" value={mostAbortedPrinter.printer.name} icon="Printer" />
<Card className="w-full">
<CardHeader>
<CardTitle>Druckaufträge</CardTitle>
<CardDescription>nach Status</CardDescription>
</CardHeader>
<CardContent>
<PrintJobsDonut
data={[
{ name: "Abgeschlossen", value: completedPrintJobs },
{ name: "Abgebrochen", value: abortedPrintJobs },
{ name: "Ausstehend", value: pendingPrintJobs },
]}
/>
</CardContent>
</Card>
<Card className="w-full ">
<CardHeader>
<CardTitle>
Auslastung: <span>{((1 - freePrinters.length / allPrinters.length) * 100).toFixed(2)}%</span>
</CardTitle>
</CardHeader>
<CardContent>
<LoadFactorChart
data={[
{ name: "Frei", value: freePrinters.length },
{ name: "Belegt", value: allPrinters.length - freePrinters.length },
]}
/>
</CardContent>
</Card>
<Card className="w-full col-span-2">
<CardHeader>
<CardTitle>Abgebrochene Druckaufträge nach Abbruchgrund</CardTitle>
</CardHeader>
<CardContent>
<AbortReasonsBarChart data={abortedPrintJobsReasons} />
</CardContent>
</Card>
<div 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>
{allPrinters.map((printer) => (
<TabsContent key={printer.id} value={printer.id}>
{printer.description}
</TabsContent>
))}
</Tabs>
</>
);

View File

@@ -29,7 +29,13 @@ export function DeletePrinterDialog(props: DeletePrinterDialogProps) {
description: "Drucker wird gelöscht...",
});
try {
await deletePrinter(printerId);
const result = await deletePrinter(printerId);
if (result?.error) {
toast({
description: result.error,
variant: "destructive",
});
}
toast({
description: "Drucker wurde gelöscht.",
});

View File

@@ -57,11 +57,17 @@ export function PrinterForm(props: PrinterFormProps) {
// Update
try {
await updatePrinter(printer.id, {
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);
@@ -90,11 +96,17 @@ export function PrinterForm(props: PrinterFormProps) {
// Create
try {
await createPrinter({
const result = await createPrinter({
description: values.description,
name: values.name,
status: values.status,
});
if (result?.error) {
toast({
description: result.error,
variant: "destructive",
});
}
setOpen(false);

View File

@@ -1,5 +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

@@ -2,12 +2,19 @@ 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),

View File

@@ -1,5 +1,7 @@
import { getPrinters } from "@/server/actions/printers";
export const dynamic = "force-dynamic";
export async function GET() {
const printers = await getPrinters();

View File

@@ -7,15 +7,30 @@ 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;
if (!code || !state || !storedState || state !== storedState) {
return new Response(null, {
status: 400,
});
return new Response(
JSON.stringify({
status_text: "Something is wrong",
data: { code, state, storedState },
}),
{
status: 400,
},
);
}
try {
@@ -27,7 +42,16 @@ export async function GET(request: Request): Promise<Response> {
});
const githubUser: GitHubUserResult = await githubUserResponse.json();
// Replace this with your own DB client.
// 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),
});
@@ -56,7 +80,10 @@ export async function GET(request: Request): Promise<Response> {
const session = await lucia.createSession(userId, {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
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: {
@@ -64,13 +91,18 @@ export async function GET(request: Request): Promise<Response> {
},
});
} catch (e) {
console.log(e);
// the specific error message depends on the provider
if (e instanceof OAuth2RequestError) {
// invalid code
return new Response(null, {
status: 400,
});
return new Response(
JSON.stringify({
status_text: "Invalid code",
error: JSON.stringify(e),
}),
{
status: 400,
},
);
}
return new Response(null, {
status: 500,

View File

@@ -2,14 +2,18 @@ import { github } 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();
const url = await github.createAuthorizationURL(state);
const url = await github.createAuthorizationURL(state, {
scopes: ["user"],
});
const ONE_HOUR = 60 * 60;
cookies().set("github_oauth_state", state, {
path: "/",
secure: process.env.NODE_ENV === "production",
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",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 166 KiB

View File

@@ -2,76 +2,60 @@
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 90.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--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: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
--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: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--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: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--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%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -52,7 +52,13 @@ export function CancelForm(props: CancelFormProps) {
description: "Druckauftrag wird abgebrochen...",
});
try {
await abortPrintJob(jobId, values.abortReason);
const result = await abortPrintJob(jobId, values.abortReason);
if (result?.error) {
toast({
description: result.error,
variant: "destructive",
});
}
setOpen(false);
toast({
description: "Druckauftrag wurde abgebrochen.",

View File

@@ -17,7 +17,13 @@ export function EditComments(props: EditCommentsProps) {
const debounced = useDebouncedCallback(async (value) => {
try {
await updatePrintComments(jobId, value);
const result = await updatePrintComments(jobId, value);
if (result?.error) {
toast({
description: result.error,
variant: "destructive",
});
}
toast({
description: "Anmerkungen wurden gespeichert.",
});

View File

@@ -53,7 +53,14 @@ export function ExtendForm(props: ExtendFormProps) {
description: "Druckauftrag wird verlängert...",
});
try {
await extendPrintJob(jobId, values.minutes, values.hours);
const result = await extendPrintJob(jobId, values.minutes, values.hours);
if (result?.error) {
toast({
description: result.error,
variant: "destructive",
});
}
setOpen(false);
form.reset();

View File

@@ -27,7 +27,13 @@ export function FinishForm(props: FinishFormProps) {
description: "Druckauftrag wird abgeschlossen...",
});
try {
await earlyFinishPrintJob(jobId);
const result = await earlyFinishPrintJob(jobId);
if (result?.error) {
toast({
description: result.error,
variant: "destructive",
});
}
toast({
description: "Druckauftrag wurde abgeschlossen.",
});

View File

@@ -36,7 +36,7 @@ export default async function JobDetailsPage(props: JobDetailsPageProps) {
});
if (!jobDetails) {
return <div>Job not found</div>;
return <div>Druckauftrag wurde nicht gefunden.</div>;
}
const jobIsOnGoing = new Date(jobDetails.startAt).getTime() + jobDetails.durationInMinutes * 60 * 1000 > Date.now();

View File

@@ -1,15 +1,8 @@
import { Header } from "@/components/header";
import { Toaster } from "@/components/ui/toaster";
import { cn } from "@/utils/styles";
import type { Metadata } from "next";
import "@/app/globals.css";
import { Inter as FontSans } from "next/font/google";
const fontSans = FontSans({
subsets: ["latin"],
variable: "--font-sans",
});
export const metadata: Metadata = {
title: {
@@ -23,13 +16,15 @@ 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={cn("min-h-dvh bg-muted font-sans antialiased", fontSans.variable)}>
<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}

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

View File

@@ -1,11 +1,12 @@
import { columns } from "@/app/my/jobs/columns";
import { JobsTable } from "@/app/my/jobs/data-table";
import { DynamicPrinterCards } from "@/components/dynamic-printer-cards";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { 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 = {
@@ -43,8 +44,10 @@ export default async function HomePage() {
{/* NEEDS TO BE FIXED FOR A NEW / EMPTY USER {isLoggedIn && <PersonalizedCards />} */}
<Card>
<CardHeader>
<CardTitle>Druckerbelegung</CardTitle>
<CardDescription>({printers.length} Verfügbar)</CardDescription>
<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} />
@@ -53,8 +56,10 @@ export default async function HomePage() {
{userIsLoggedIn && (
<Card>
<CardHeader>
<CardTitle>Druckaufträge</CardTitle>
<CardDescription>Deine aktuellen Druckaufträge</CardDescription>
<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} />

View File

@@ -1,15 +1,7 @@
"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 { 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";
@@ -17,6 +9,7 @@ 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";
@@ -41,6 +34,7 @@ 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),
@@ -52,13 +46,25 @@ export function PrinterReserveForm(props: PrinterReserveFormProps) {
});
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.",
message: "Die Dauer des Druckauftrags muss mindestens 1 Minute betragen.",
});
return;
}
@@ -70,6 +76,12 @@ export function PrinterReserveForm(props: PrinterReserveFormProps) {
userId: userId,
printerId: printerId,
});
if (typeof jobId === "object") {
toast({
description: jobId.error,
variant: "destructive",
});
}
router.push(`/job/${jobId}`);
} catch (error) {
@@ -128,9 +140,8 @@ export function PrinterReserveForm(props: PrinterReserveFormProps) {
<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.
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>
@@ -140,17 +151,14 @@ export function PrinterReserveForm(props: PrinterReserveFormProps) {
<If condition={isDialog}>
<Then>
<DialogClose asChild>
<Button
variant={"secondary"}
className="gap-2 flex items-center"
>
<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">
<Button type="submit" className="gap-2 flex items-center" disabled={isLocked}>
<CalendarPlusIcon className="w-4 h-4" />
<span>Reservieren</span>
</Button>

View File

@@ -1,7 +1,7 @@
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 { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
@@ -13,7 +13,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { validateRequest } from "@/server/auth";
import { UserRole, hasRole } from "@/server/auth/permissions";
import { ScanFaceIcon, StickerIcon, UserIcon, WrenchIcon } from "lucide-react";
import { StickerIcon, UserIcon, WrenchIcon } from "lucide-react";
import Link from "next/link";
import { If, Then } from "react-if";
@@ -78,14 +78,7 @@ export async function Header() {
</DropdownMenuContent>
</DropdownMenu>
)}
{user == null && (
<Button variant={"ghost"} className="gap-2 flex items-center" asChild>
<Link href="/auth/login">
<ScanFaceIcon className="w-4 h-4" />
<span>Anmelden</span>
</Link>
</Button>
)}
{user == null && <LoginButton />}
</div>
</header>
);

View File

@@ -1,10 +1,12 @@
"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;
}
@@ -12,7 +14,8 @@ export function HeaderNavigation() {
const pathname = usePathname();
const sites: Site[] = [
{
name: "Mein Dashboard",
name: "Dashboard",
icon: <LayersIcon className="w-4 h-4" />,
path: "/",
},
/* {
@@ -21,6 +24,7 @@ export function HeaderNavigation() {
}, */
{
name: "Mein Profil",
icon: <ContactRoundIcon className="w-4 h-4" />,
path: "/my/profile",
},
];
@@ -31,12 +35,13 @@ export function HeaderNavigation() {
<Link
key={site.path}
href={site.path}
className={cn("transition-colors hover:text-neutral-50", {
"text-neutral-50": pathname === 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.name}
{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

@@ -1,12 +1,21 @@
"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={() => logout()} className="flex items-center gap-2">
<Link href="/" onClick={onClick} className="flex items-center gap-2">
<LogOutIcon className="w-4 h-4" />
<span>Abmelden</span>
</Link>

View File

@@ -22,29 +22,29 @@ export default async function PersonalizedCards() {
.reduce((acc, curr) => acc + curr.durationInMinutes, 0);
const averagePrintingHoursPerWeek = totalPrintingMinutes / 60 / 52;
const mostUsedPrinters = allPrintJobs
const mostUsedPrinters = {printer:{name:'-'}}; /*allPrintJobs
.map((job) => job.printer.name)
.reduce<Record<string, number>>((acc, curr) => {
.reduce((acc, curr) => {
acc[curr] = (acc[curr] || 0) + 1;
return acc;
}, {});
}, {});*/
const mostUsedPrinter = Object.keys(mostUsedPrinters).reduce((a, b) =>
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 = allPrintJobs
const mostUsedWeekday = {printer:{name:'-'}}; /*allPrintJobs
.map((job) => job.startAt.getDay())
.reduce<Record<string, number>>((acc, curr) => {
.reduce((acc, curr) => {
acc[curr] = (acc[curr] || 0) + 1;
return acc;
}, {});
}, {});*/
const mostUsedWeekdayIndex = Object.keys(mostUsedWeekday).reduce((a, b) =>
const mostUsedWeekdayIndex = ""; /*Object.keys(mostUsedWeekday).reduce((a, b) =>
mostUsedWeekday[a] > mostUsedWeekday[b] ? a : b,
);
);*/
const mostUsedWeekdayName = new Intl.DateTimeFormat("de-DE", {
weekday: "long",

View File

@@ -20,8 +20,6 @@ export function Countdown(props: CountdownProps) {
return <>...</>;
}
console.log(data);
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));

View File

@@ -33,23 +33,25 @@ export function PrinterCard(props: PrinterCardProps) {
"opacity-50 select-none cursor-not-allowed": status === PrinterStatus.OUT_OF_ORDER,
})}
>
<CardHeader className="flex flex-row justify-between">
<div>
<CardTitle>{printer.name}</CardTitle>
<CardDescription>{printer.description}</CardDescription>
<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>
<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>
</CardHeader>
<CardContent className="flex justify-end">
<If condition={status === PrinterStatus.IDLE && userIsLoggedIn && !hasRole(user, UserRole.GUEST)}>

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

@@ -1,7 +1,7 @@
"use server";
import { lucia, validateRequest } from "@/server/auth";
import { AuthenticationError } from "@/utils/errors";
import strings from "@/utils/strings";
import { revalidatePath } from "next/cache";
import { cookies } from "next/headers";
@@ -9,13 +9,17 @@ export async function logout(path?: string) {
const { session } = await validateRequest();
if (!session) {
throw new AuthenticationError();
return {
error: strings.ERROR.NO_SESSION,
};
}
try {
await lucia.invalidateSession(session.id);
} catch (error) {
throw new AuthenticationError();
return {
error: strings.ERROR.NO_SESSION,
};
}
const sessionCookie = lucia.createBlankSessionCookie();

View File

@@ -4,8 +4,8 @@ import { validateRequest } from "@/server/auth";
import { UserRole } from "@/server/auth/permissions";
import { db } from "@/server/db";
import { printJobs, users } from "@/server/db/schema";
import { PermissionError } from "@/utils/errors";
import { IS, guard } from "@/utils/guard";
import strings from "@/utils/strings";
import { type InferInsertModel, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
@@ -13,7 +13,9 @@ export async function createPrintJob(printJob: InferInsertModel<typeof printJobs
const { user } = await validateRequest();
if (guard(user, IS, UserRole.GUEST)) {
throw new PermissionError();
return {
error: strings.ERROR.PERMISSION,
};
}
const dbUser = await db.query.users.findFirst({
@@ -22,7 +24,9 @@ export async function createPrintJob(printJob: InferInsertModel<typeof printJobs
});
if (guard(dbUser, IS, UserRole.GUEST)) {
throw new PermissionError();
return {
error: strings.ERROR.PERMISSION,
};
}
try {
@@ -32,7 +36,9 @@ export async function createPrintJob(printJob: InferInsertModel<typeof printJobs
return result[0].jobId;
} catch (error) {
throw new Error("Druckauftrag konnte nicht hinzugefügt werden.");
return {
error: "Druckauftrag konnte nicht hinzugefügt werden.",
};
}
}
@@ -40,7 +46,9 @@ export async function createPrintJob(printJob: InferInsertModel<typeof printJobs
const { user } = await validateRequest();
if (guard(user, is, UserRole.GUEST)) {
throw new PermissionError();
return {
error: strings.ERROR.PERMISSION
}
}
const dbUser = await db.query.users.findFirst({
@@ -49,7 +57,9 @@ export async function createPrintJob(printJob: InferInsertModel<typeof printJobs
});
if (guard(dbUser, is, UserRole.GUEST)) {
throw new PermissionError();
return {
error: strings.ERROR.PERMISSION
}
}
await db.update(printJobs).set(printJob).where(eq(printJobs.id, jobId));
@@ -59,7 +69,9 @@ export async function abortPrintJob(jobId: string, reason: string) {
const { user } = await validateRequest();
if (guard(user, IS, UserRole.GUEST)) {
throw new PermissionError();
return {
error: strings.ERROR.PERMISSION,
};
}
const dbUser = await db.query.users.findFirst({
@@ -68,7 +80,9 @@ export async function abortPrintJob(jobId: string, reason: string) {
});
if (guard(dbUser, IS, UserRole.GUEST)) {
throw new PermissionError();
return {
error: strings.ERROR.PERMISSION,
};
}
// Get the print job
@@ -77,22 +91,26 @@ export async function abortPrintJob(jobId: string, reason: string) {
});
if (!printJob) {
throw new Error("Druckauftrag nicht gefunden");
return {
error: "Druckauftrag nicht gefunden",
};
}
// Check if the print job is already aborted or completed
if (printJob.aborted) {
throw new Error("Druckauftrag wurde bereits abgebrochen");
return { error: "Druckauftrag wurde bereits abgebrochen" };
}
if (new Date(printJob.startAt).getTime() + printJob.durationInMinutes * 60 * 1000 < Date.now()) {
throw new Error("Druckauftrag ist bereits abgeschlossen");
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) {
throw new PermissionError();
if (printJob.userId !== dbUser!.id && dbUser!.role !== UserRole.ADMIN) {
return {
error: strings.ERROR.PERMISSION,
};
}
// Get duration in minutes since startAt
@@ -115,7 +133,9 @@ export async function earlyFinishPrintJob(jobId: string) {
const { user } = await validateRequest();
if (guard(user, IS, UserRole.GUEST)) {
throw new PermissionError();
return {
error: strings.ERROR.PERMISSION,
};
}
const dbUser = await db.query.users.findFirst({
@@ -124,7 +144,9 @@ export async function earlyFinishPrintJob(jobId: string) {
});
if (guard(dbUser, IS, UserRole.GUEST)) {
throw new PermissionError();
return {
error: strings.ERROR.PERMISSION,
};
}
// Get the print job
@@ -133,22 +155,24 @@ export async function earlyFinishPrintJob(jobId: string) {
});
if (!printJob) {
throw new Error("Druckauftrag nicht gefunden");
return { error: "Druckauftrag nicht gefunden" };
}
// Check if the print job is already aborted or completed
if (printJob.aborted) {
throw new Error("Druckauftrag wurde bereits abgebrochen");
return { error: "Druckauftrag wurde bereits abgebrochen" };
}
if (new Date(printJob.startAt).getTime() + printJob.durationInMinutes * 60 * 1000 < Date.now()) {
throw new Error("Druckauftrag ist bereits abgeschlossen");
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) {
throw new PermissionError();
if (printJob.userId !== dbUser!.id && dbUser!.role !== UserRole.ADMIN) {
return {
error: strings.ERROR.PERMISSION,
};
}
// Get duration in minutes since startAt
@@ -169,7 +193,9 @@ export async function extendPrintJob(jobId: string, minutes: number, hours: numb
const { user } = await validateRequest();
if (guard(user, IS, UserRole.GUEST)) {
throw new PermissionError();
return {
error: strings.ERROR.PERMISSION,
};
}
const dbUser = await db.query.users.findFirst({
@@ -178,7 +204,9 @@ export async function extendPrintJob(jobId: string, minutes: number, hours: numb
});
if (guard(dbUser, IS, UserRole.GUEST)) {
throw new PermissionError();
return {
error: strings.ERROR.PERMISSION,
};
}
// Get the print job
@@ -187,22 +215,24 @@ export async function extendPrintJob(jobId: string, minutes: number, hours: numb
});
if (!printJob) {
throw new Error("Druckauftrag nicht gefunden");
return { error: "Druckauftrag nicht gefunden" };
}
// Check if the print job is already aborted or completed
if (printJob.aborted) {
throw new Error("Druckauftrag wurde bereits abgebrochen");
return { error: "Druckauftrag wurde bereits abgebrochen" };
}
if (new Date(printJob.startAt).getTime() + printJob.durationInMinutes * 60 * 1000 < Date.now()) {
throw new Error("Druckauftrag ist bereits abgeschlossen");
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) {
throw new PermissionError();
if (printJob.userId !== dbUser!.id && dbUser!.role !== UserRole.ADMIN) {
return {
error: strings.ERROR.PERMISSION,
};
}
const duration = minutes + hours * 60;
@@ -222,7 +252,9 @@ export async function updatePrintComments(jobId: string, comments: string) {
const { user } = await validateRequest();
if (guard(user, IS, UserRole.GUEST)) {
throw new PermissionError();
return {
error: strings.ERROR.PERMISSION,
};
}
const dbUser = await db.query.users.findFirst({
@@ -231,7 +263,9 @@ export async function updatePrintComments(jobId: string, comments: string) {
});
if (guard(dbUser, IS, UserRole.GUEST)) {
throw new PermissionError();
return {
error: strings.ERROR.PERMISSION,
};
}
// Get the print job
@@ -240,22 +274,24 @@ export async function updatePrintComments(jobId: string, comments: string) {
});
if (!printJob) {
throw new Error("Druckauftrag nicht gefunden");
return { error: "Druckauftrag nicht gefunden" };
}
// Check if the print job is already aborted or completed
if (printJob.aborted) {
throw new Error("Druckauftrag wurde bereits abgebrochen");
return { error: "Druckauftrag wurde bereits abgebrochen" };
}
if (new Date(printJob.startAt).getTime() + printJob.durationInMinutes * 60 * 1000 < Date.now()) {
throw new Error("Druckauftrag ist bereits abgeschlossen");
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) {
throw new PermissionError();
if (printJob.userId !== dbUser!.id && dbUser!.role !== UserRole.ADMIN) {
return {
error: strings.ERROR.PERMISSION,
};
}
await db

View File

@@ -1,11 +1,10 @@
"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 { PermissionError } from "@/utils/errors";
import { IS_NOT, guard } from "@/utils/guard";
import strings from "@/utils/strings";
import { type InferInsertModel, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
@@ -13,7 +12,9 @@ export async function createPrinter(printer: InferInsertModel<typeof printers>)
const { user } = await validateRequest();
if (guard(user, IS_NOT, UserRole.ADMIN)) {
throw new PermissionError();
return {
error: strings.ERROR.PERMISSION,
};
}
const dbUser = await db.query.users.findFirst({
@@ -22,17 +23,23 @@ export async function createPrinter(printer: InferInsertModel<typeof printers>)
});
if (guard(dbUser, IS_NOT, UserRole.ADMIN)) {
throw new PermissionError();
return {
error: strings.ERROR.PERMISSION,
};
}
if (!printer) {
throw new Error("Druckerdaten sind erforderlich.");
return {
error: "Druckerdaten sind erforderlich.",
};
}
try {
await db.insert(printers).values(printer);
} catch (error) {
throw new Error("Drucker konnte nicht hinzugefügt werden.");
return {
error: "Drucker konnte nicht hinzugefügt werden.",
};
}
revalidatePath("/");
@@ -42,7 +49,9 @@ export async function updatePrinter(id: string, data: InferInsertModel<typeof pr
const { user } = await validateRequest();
if (guard(user, IS_NOT, UserRole.ADMIN)) {
throw new PermissionError();
return {
error: strings.ERROR.PERMISSION,
};
}
const dbUser = await db.query.users.findFirst({
@@ -51,17 +60,23 @@ export async function updatePrinter(id: string, data: InferInsertModel<typeof pr
});
if (guard(dbUser, IS_NOT, UserRole.ADMIN)) {
throw new PermissionError();
return {
error: strings.ERROR.PERMISSION,
};
}
if (!data) {
throw new Error("Druckerdaten sind erforderlich.");
return {
error: "Druckerdaten sind erforderlich.",
};
}
try {
await db.update(printers).set(data).where(eq(printers.id, id));
} catch (error) {
throw new Error("Drucker konnte nicht aktualisiert werden.");
return {
error: "Druckerdaten sind erforderlich.",
};
}
revalidatePath("/");
@@ -71,7 +86,9 @@ export async function deletePrinter(id: string) {
const { user } = await validateRequest();
if (guard(user, IS_NOT, UserRole.ADMIN)) {
throw new PermissionError();
return {
error: strings.ERROR.PERMISSION,
};
}
const dbUser = await db.query.users.findFirst({
@@ -80,16 +97,22 @@ export async function deletePrinter(id: string) {
});
if (guard(dbUser, IS_NOT, UserRole.ADMIN)) {
throw new PermissionError();
return {
error: strings.ERROR.PERMISSION,
};
}
try {
await db.delete(printers).where(eq(printers.id, id));
} catch (error) {
if (error instanceof Error) {
throw new Error(error.message);
return {
error: error.message,
};
}
throw new Error("Ein unbekannter Fehler ist aufgetreten.");
return {
error: "Ein unbekannter Fehler ist aufgetreten.",
};
}
revalidatePath("/");

View File

@@ -4,8 +4,8 @@ import { validateRequest } from "@/server/auth";
import { UserRole } from "@/server/auth/permissions";
import { db } from "@/server/db";
import { users } from "@/server/db/schema";
import { PermissionError } from "@/utils/errors";
import { IS, IS_NOT, guard } from "@/utils/guard";
import strings from "@/utils/strings";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
@@ -18,7 +18,9 @@ export async function deleteUser(userId: string, path?: string) {
const { user } = await validateRequest();
if (guard(user, IS_NOT, UserRole.ADMIN)) {
throw new PermissionError();
return {
error: strings.ERROR.PERMISSION,
};
}
const dbUser = await db.query.users.findFirst({
@@ -27,7 +29,9 @@ export async function deleteUser(userId: string, path?: string) {
});
if (guard(dbUser, IS_NOT, UserRole.ADMIN)) {
throw new PermissionError();
return {
error: strings.ERROR.PERMISSION,
};
}
const targetUser = await db.query.users.findFirst({
@@ -35,11 +39,15 @@ export async function deleteUser(userId: string, path?: string) {
});
if (!targetUser) {
throw new Error("Benutzer nicht gefunden");
return {
error: "Benutzer nicht gefunden",
};
}
if (guard(targetUser, IS, UserRole.ADMIN)) {
throw new Error("Kann keinen Admin löschen");
return {
error: "Admins können nicht gelöscht werden.",
};
}
await db.delete(users).where(eq(users.id, userId));

View File

@@ -3,8 +3,8 @@ import { validateRequest } from "@/server/auth";
import { UserRole } from "@/server/auth/permissions";
import { db } from "@/server/db";
import { users } from "@/server/db/schema";
import { PermissionError } from "@/utils/errors";
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";
@@ -19,7 +19,9 @@ export async function updateUser(userId: string, data: z.infer<typeof formSchema
const { user } = await validateRequest();
if (guard(user, IS_NOT, UserRole.ADMIN)) {
throw new PermissionError();
return {
error: strings.ERROR.PERMISSION,
};
}
const dbUser = await db.query.users.findFirst({
@@ -28,7 +30,9 @@ export async function updateUser(userId: string, data: z.infer<typeof formSchema
});
if (guard(dbUser, IS_NOT, UserRole.ADMIN)) {
throw new PermissionError();
return {
error: strings.ERROR.PERMISSION,
};
}
await db.update(users).set(data).where(eq(users.id, userId));

View File

@@ -5,8 +5,8 @@ import { validateRequest } from "@/server/auth";
import { UserRole } from "@/server/auth/permissions";
import { db } from "@/server/db";
import { users } from "@/server/db/schema";
import { PermissionError } from "@/utils/errors";
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";
@@ -18,7 +18,9 @@ export async function updateUser(userId: string, data: z.infer<typeof formSchema
const { user } = await validateRequest();
if (guard(user, IS_NOT, UserRole.ADMIN)) {
throw new PermissionError();
return {
error: strings.ERROR.PERMISSION,
};
}
const dbUser = await db.query.users.findFirst({
@@ -27,7 +29,9 @@ export async function updateUser(userId: string, data: z.infer<typeof formSchema
});
if (guard(dbUser, IS_NOT, UserRole.ADMIN)) {
throw new PermissionError();
return {
error: strings.ERROR.PERMISSION,
};
}
await db.update(users).set(data).where(eq(users.id, userId));
@@ -42,7 +46,9 @@ export async function deleteUser(userId: string) {
const { user } = await validateRequest();
if (guard(user, IS_NOT, UserRole.ADMIN)) {
throw new PermissionError();
return {
error: strings.ERROR.PERMISSION,
};
}
const dbUser = await db.query.users.findFirst({
@@ -51,7 +57,9 @@ export async function deleteUser(userId: string) {
});
if (guard(dbUser, IS_NOT, UserRole.ADMIN)) {
throw new PermissionError();
return {
error: strings.ERROR.PERMISSION,
};
}
const targetUser = await db.query.users.findFirst({
@@ -59,11 +67,11 @@ export async function deleteUser(userId: string) {
});
if (!targetUser) {
throw new Error("Benutzer nicht gefunden");
return { error: "Benutzer nicht gefunden" };
}
if (guard(targetUser, IS, UserRole.ADMIN)) {
throw new Error("Kann keinen Admin löschen");
return { error: "Kann keinen Admin löschen" };
}
await db.delete(users).where(eq(users.id, userId));

View File

@@ -1,19 +1,19 @@
import type { UserRole } from "@/server/auth/permissions";
import { db } from "@/server/db";
import { sessions, users } from "@/server/db/schema";
import { env } from "@/utils/env";
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: env.RUNTIME_ENVIRONMENT === "prod",
secure: process.env.NODE_ENV === "production",
},
},
getUserAttributes: (attributes) => {

View File

@@ -1,7 +1,6 @@
import { env } from "@/utils/env";
import { GitHub } from "arctic";
export const github = new GitHub(env.OAUTH.CLIENT_ID, env.OAUTH.CLIENT_SECRET, {
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",
});

View File

@@ -1,123 +1,8 @@
import { env } from "@/utils/env";
import * as schema from "@/server/db/schema";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { drizzle as drizzleJson } from "drizzle-orm/json-db";
import fs from 'fs';
import path from 'path';
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
// Stellen sicher, dass DB_PATH tatsächlich gesetzt ist
const dbPath = env.DB_PATH || "/app/db/sqlite.db";
const jsonDbPath = env.DB_JSON_PATH || "/app/db/db.json";
// JSON-Fallback-Implementierung
class JsonDbAdapter {
private data: Record<string, any[]> = {};
private dbPath: string;
constructor(dbPath: string) {
this.dbPath = dbPath;
this.loadFromDisk();
}
private loadFromDisk() {
try {
if (fs.existsSync(this.dbPath)) {
const content = fs.readFileSync(this.dbPath, 'utf8');
this.data = JSON.parse(content);
} else {
// Ensure directory exists
const dir = path.dirname(this.dbPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
this.saveToFile();
}
} catch (error) {
console.error('Error loading JSON database:', error);
this.data = {};
this.saveToFile();
}
}
private saveToFile() {
try {
fs.writeFileSync(this.dbPath, JSON.stringify(this.data, null, 2));
} catch (error) {
console.error('Error saving JSON database:', error);
}
}
getTable(table: string) {
if (!this.data[table]) {
this.data[table] = [];
}
return {
all: () => this.data[table],
get: (id: string) => this.data[table].find(item => item.id === id),
add: (item: any) => {
this.data[table].push(item);
this.saveToFile();
return item;
},
update: (id: string, item: any) => {
const index = this.data[table].findIndex(i => i.id === id);
if (index >= 0) {
this.data[table][index] = { ...this.data[table][index], ...item };
this.saveToFile();
return this.data[table][index];
}
return null;
},
delete: (id: string) => {
const index = this.data[table].findIndex(i => i.id === id);
if (index >= 0) {
const deleted = this.data[table].splice(index, 1)[0];
this.saveToFile();
return deleted;
}
return null;
}
};
}
}
// Versuche SQLite zu laden, mit Fallback auf JSON
let db;
try {
// Versuche SQLite zu initialisieren
console.log("Initialisiere SQLite-Datenbank...");
const Database = require('better-sqlite3');
// Konfiguriere SQLite für zuverlässigeren Betrieb
const sqlite = new Database(dbPath, {
// Setze längeres Timeout für Operationen auf langsamen Geräten (RPi)
timeout: 30000,
// Aktiviere WAL-Modus für höhere Performance
journalMode: 'wal',
// Verbesserte Fehlerbehandlung
verbose: console.error,
});
// Aktiviere Fremdschlüssel-Constraints
sqlite.pragma('foreign_keys = ON');
// Exportiere die Drizzle-Datenbankinstanz
db = drizzle(sqlite, { schema });
console.log("SQLite-Datenbank erfolgreich initialisiert.");
} catch (error) {
// Bei Fehler: Fallback auf JSON-Datenbank
console.warn(`SQLite-Initialisierung fehlgeschlagen: ${error.message}`);
console.warn("Verwende JSON-Fallback-Datenbank...");
try {
const jsonDbAdapter = new JsonDbAdapter(jsonDbPath);
db = drizzleJson(jsonDbAdapter, { schema });
console.log(`JSON-Datenbank wird verwendet: ${jsonDbPath}`);
} catch (jsonError) {
console.error("Konnte keine Datenbank initialisieren:", jsonError);
throw new Error("Keine Datenbankverbindung möglich.");
}
}
// Exportiere die Datenbankinstanz
export { db };
const sqlite = createClient({
url: "file:./db/sqlite.db",
});
export const db = drizzle(sqlite, { schema });

View File

@@ -1,36 +1,4 @@
import { db } from "@/server/db";
import fs from "fs";
import path from "path";
import { migrate } from "drizzle-orm/libsql/migrator";
try {
// Try to use the SQLite migrator if available
const { migrate } = require("drizzle-orm/better-sqlite3/migrator");
console.log("Using SQLite migrator...");
migrate(db, { migrationsFolder: "./drizzle" });
console.log("SQLite migration completed successfully.");
} catch (error) {
console.warn("SQLite migration failed:", error.message);
console.warn("Attempting JSON database initialization...");
try {
// Ensure JSON DB file exists
const jsonDbPath = process.env.DB_JSON_PATH || "/app/db/db.json";
const jsonDir = path.dirname(jsonDbPath);
if (!fs.existsSync(jsonDir)) {
fs.mkdirSync(jsonDir, { recursive: true });
}
if (!fs.existsSync(jsonDbPath)) {
// Initialize with empty schema
const emptyDb = {};
fs.writeFileSync(jsonDbPath, JSON.stringify(emptyDb, null, 2));
console.log("Created empty JSON database structure at", jsonDbPath);
} else {
console.log("JSON database file already exists at", jsonDbPath);
}
} catch (jsonError) {
console.error("Failed to initialize JSON database:", jsonError);
throw new Error("Cannot initialize any database system");
}
}
migrate(db, { migrationsFolder: "./drizzle" });

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

@@ -1,14 +0,0 @@
import { z } from "zod";
/**
* Environment variables
*/
export const env = {
RUNTIME_ENVIRONMENT: z.enum(["prod", "dev"]).parse(process.env.RUNTIME_ENVIRONMENT),
DB_PATH: process.env.DB_PATH || "db/sqlite.db", // Support environment variable or use default
DB_JSON_PATH: process.env.DB_JSON_PATH || "db/db.json", // JSON fallback database path
OAUTH: {
CLIENT_ID: z.string().parse(process.env.OAUTH_CLIENT_ID),
CLIENT_SECRET: z.string().parse(process.env.OAUTH_CLIENT_SECRET),
},
};

View File

@@ -1,33 +0,0 @@
import type { UserRole } from "@/server/auth/permissions";
import type { users } from "@/server/db/schema";
import type { InferSelectModel } from "drizzle-orm";
import type { RegisteredDatabaseUserAttributes } from "lucia";
// Helpers to improve readability of the guard function
export const is = false;
export const is_not = true;
/**
* @deprecated
*/
export function guard(
user: RegisteredDatabaseUserAttributes | InferSelectModel<typeof users> | undefined | null,
negate: boolean,
roleRequirements: UserRole | UserRole[],
) {
if (!user) {
return true; // Guard against unauthenticated users
}
const hasRole = Array.isArray(roleRequirements)
? roleRequirements.includes(user?.role as UserRole)
: user?.role === roleRequirements;
return negate ? !hasRole : hasRole;
}
export class PermissionError extends Error {
constructor() {
super("Du besitzt nicht die erforderlichen Berechtigungen um diese Aktion auszuführen.");
}
}