torben fronted wiederhergestellt
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(", ");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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("/");
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { getPrinters } from "@/server/actions/printers";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const printers = await getPrinters();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
11
packages/reservation-platform/src/app/not-found.tsx
Normal file
11
packages/reservation-platform/src/app/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
370
packages/reservation-platform/src/components/ui/chart.tsx
Normal file
370
packages/reservation-platform/src/components/ui/chart.tsx
Normal 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,
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("/");
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
39
packages/reservation-platform/src/utils/analytics/errors.ts
Normal file
39
packages/reservation-platform/src/utils/analytics/errors.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
52
packages/reservation-platform/src/utils/analytics/volume.ts
Normal file
52
packages/reservation-platform/src/utils/analytics/volume.ts
Normal 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;
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user