add statistics

This commit is contained in:
Torben Haack 2024-11-04 12:11:36 +01:00
parent 2e396c7cf2
commit cddd8c1814
No known key found for this signature in database
15 changed files with 924 additions and 164 deletions

View File

@ -40,6 +40,7 @@
"arctic": "^1.9.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.30.10",
"lodash": "^4.17.21",
"lucia": "^3.2.1",
@ -53,6 +54,7 @@
"react-hook-form": "^7.53.0",
"react-if": "^4.1.5",
"react-timer-hook": "^3.0.7",
"recharts": "^2.13.3",
"regression": "^2.0.1",
"sonner": "^1.5.0",
"sqlite": "^5.1.1",

View File

@ -80,6 +80,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
date-fns:
specifier: ^4.1.0
version: 4.1.0
drizzle-orm:
specifier: ^0.30.10
version: 0.30.10(@libsql/client@0.14.0)(@types/better-sqlite3@7.6.11)(@types/react@18.3.11)(better-sqlite3@9.6.0)(react@18.3.1)(sqlite3@5.1.7)
@ -119,6 +122,9 @@ importers:
react-timer-hook:
specifier: ^3.0.7
version: 3.0.7(react@18.3.1)
recharts:
specifier: ^2.13.3
version: 2.13.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
regression:
specifier: ^2.0.1
version: 2.0.1
@ -1772,6 +1778,9 @@ packages:
date-fns@3.6.0:
resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==}
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
debug@4.3.4:
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
engines: {node: '>=6.0'}
@ -2568,6 +2577,9 @@ packages:
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
react-remove-scroll-bar@2.3.6:
resolution: {integrity: sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==}
engines: {node: '>=10'}
@ -2639,8 +2651,8 @@ packages:
recharts-scale@0.4.5:
resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==}
recharts@2.12.7:
resolution: {integrity: sha512-hlLJMhPQfv4/3NBSAyq3gzGg4h2v69RJh6KU7b3pXYNNAELs9kEoXOjbkxdXpALqKBoVmVptGfLpxdaVYqjmXQ==}
recharts@2.13.3:
resolution: {integrity: sha512-YDZ9dOfK9t3ycwxgKbrnDlRC4BHdjlY73fet3a0C1+qGMjXVZe6+VXmpOIIhzkje5MMEL8AN4hLIe4AMskBzlA==}
engines: {node: '>=14'}
peerDependencies:
react: ^16.0.0 || ^17.0.0 || ^18.0.0
@ -4072,7 +4084,7 @@ snapshots:
react-day-picker: 8.10.1(date-fns@3.6.0)(react@18.3.1)
react-dom: 18.3.1(react@18.3.1)
react-transition-state: 2.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
recharts: 2.12.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
recharts: 2.13.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
tailwind-merge: 2.5.3
transitivePeerDependencies:
- tailwindcss
@ -4397,6 +4409,8 @@ snapshots:
date-fns@3.6.0: {}
date-fns@4.1.0: {}
debug@4.3.4:
dependencies:
ms: 2.1.2
@ -5230,6 +5244,8 @@ snapshots:
react-is@16.13.1: {}
react-is@18.3.1: {}
react-remove-scroll-bar@2.3.6(@types/react@18.3.11)(react@18.3.1):
dependencies:
react: 18.3.1
@ -5306,14 +5322,14 @@ snapshots:
dependencies:
decimal.js-light: 2.5.1
recharts@2.12.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
recharts@2.13.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
clsx: 2.1.1
eventemitter3: 4.0.7
lodash: 4.17.21
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-is: 16.13.1
react-is: 18.3.1
react-smooth: 4.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
recharts-scale: 0.4.5
tiny-invariant: 1.3.3

View File

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

View File

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

View File

@ -0,0 +1,74 @@
"use client";
import { TrendingUp } from "lucide-react";
import { Bar, BarChart, CartesianGrid, LabelList, XAxis, YAxis } from "recharts";
import { Card, CardContent, CardDescription, CardFooter, 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>
<CardFooter className="flex-col items-start gap-2 text-sm">
<div className="flex gap-2 font-medium leading-none">
Fehlerratenanalyse abgeschlossen <TrendingUp className="h-4 w-4" />
</div>
<div className="leading-none text-muted-foreground">Zeigt die Fehlerrate für jeden Drucker</div>
</CardFooter>
</Card>
);
}

View File

@ -0,0 +1,66 @@
"use client";
import { Bar, BarChart, XAxis, YAxis } from "recharts";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
import { StopwatchIcon } from "@radix-ui/react-icons";
export const description = "Ein horizontales Balkendiagramm zur Darstellung der durchschnittlichen Leerlaufzeit";
interface PrinterIdleTime {
printerId: string;
printerName: string;
averageIdleTime: number; // in minutes
}
interface PrinterIdleTimeChartProps {
printerIdleTime: PrinterIdleTime[];
}
const chartConfig = {
idleTime: {
label: "Leerlaufzeit",
},
} satisfies ChartConfig;
export function PrinterIdleTimeChart({ printerIdleTime }: PrinterIdleTimeChartProps) {
// Transform data to fit the chart structure
const chartData = printerIdleTime.map((printer) => ({
printer: printer.printerName,
idleTime: printer.averageIdleTime,
}));
return (
<Card>
<CardHeader>
<CardTitle>Leerlaufzeit der Drucker</CardTitle>
<CardDescription>Durchschnittliche Leerlaufzeit der Drucker in Minuten</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer className="h-64" config={chartConfig}>
<BarChart accessibilityLayer data={chartData} layout="vertical">
<XAxis type="number" dataKey="idleTime" hide />
<YAxis
dataKey="printer"
type="category"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={(value) => value}
/>
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} />
<Bar dataKey="idleTime" fill="hsl(var(--chart-1))" radius={5} />
</BarChart>
</ChartContainer>
</CardContent>
<CardFooter className="flex-col items-start gap-2 text-sm">
<div className="flex gap-2 font-medium leading-none">
Durchschnittliche Leerlaufzeiten der Drucker <StopwatchIcon className="h-4 w-4" />
</div>
<div className="leading-none text-muted-foreground">
Zeigt die durchschnittliche Leerlaufzeit für jeden Drucker
</div>
</CardFooter>
</Card>
);
}

View File

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

View File

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

View File

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

View File

@ -1,5 +1,15 @@
import { PrinterErrorRateChart } from "@/app/admin/charts/printer-error";
import { PrinterIdleTimeChart } from "@/app/admin/charts/printer-idle";
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 { calculatePrinterIdleTime } from "@/utils/analytics/idle-time";
import { calculatePrinterUtilization } from "@/utils/analytics/utilization";
import { calculatePrintVolumes } from "@/utils/analytics/volume";
import type { Metadata } from "next";
export const metadata: Metadata = {
@ -9,114 +19,75 @@ 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(
totalAmountOfPrintJobs > 0
? allPrintJobs.reduce((accumulator: Record<string, number>, job) => {
if (job.aborted && job.abortReason) {
if (!accumulator[job.abortReason]) {
accumulator[job.abortReason] = 1;
} else {
accumulator[job.abortReason]++;
}
}
return accumulator;
}, {})
: {},
).map(([name, count]) => ({ name, Anzahl: count }));
const mostAbortedPrinter = totalAmountOfPrintJobs > 0 ? 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 printerIdleTime = calculatePrinterIdleTime(printJobs, printers);
const printerErrorRate = calculatePrinterErrorRate(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">Statistiken & Berichte</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>
<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">
{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">
<PrinterIdleTimeChart printerIdleTime={printerIdleTime} />
<PrinterVolumeChart printerVolume={printerVolume} />
<div className="w-full col-span-2">
<PrinterErrorRateChart printerErrorRate={printerErrorRate} />
</div>
</div>
</TabsContent>
{/*allPrinters.map((printer) => (
<TabsContent key={printer.id} value={printer.id}>
{printer.description}
</TabsContent>
))*/}
</Tabs>
</>
);

View File

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

View File

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

View File

@ -0,0 +1,49 @@
import type { printJobs, printers } from "@/server/db/schema";
import { endOfMonth, startOfMonth } from "date-fns";
import type { InferSelectModel } from "drizzle-orm";
interface PrinterIdleTime {
printerId: string;
printerName: string;
averageIdleTime: number; // in minutes
}
/**
* Calculates the average idle time for each printer within the current month.
*
* @param printJobs - Array of print job objects.
* @param printers - Array of printer objects.
* @returns An array of PrinterIdleTime objects with average idle times.
*/
export function calculatePrinterIdleTime(
pJobs: InferSelectModel<typeof printJobs>[],
p: InferSelectModel<typeof printers>[],
): PrinterIdleTime[] {
const now = new Date();
const startOfThisMonth = startOfMonth(now);
const endOfThisMonth = endOfMonth(now);
const totalMinutesInMonth = (endOfThisMonth.getTime() - startOfThisMonth.getTime()) / (1000 * 60);
const usedTimePerPrinter: Record<string, number> = pJobs.reduce(
(acc, job) => {
const jobStart = new Date(job.startAt);
if (jobStart >= startOfThisMonth && jobStart <= endOfThisMonth) {
acc[job.printerId] = (acc[job.printerId] || 0) + job.durationInMinutes;
}
return acc;
},
{} as Record<string, number>,
);
return p.map((printer) => {
const usedTime = usedTimePerPrinter[printer.id] || 0;
const idleTime = totalMinutesInMonth - usedTime;
const averageIdleTime = idleTime < 0 ? 0 : idleTime; // Ensure no negative idle time
return {
printerId: printer.id,
printerName: printer.name,
averageIdleTime,
};
});
}

View File

@ -0,0 +1,30 @@
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 * 70 * 4; // 60 Minutes * 70h * 4 Weeks
const printerUtilizationPercentage = Object.keys(usedTimePerPrinter).map((printerId) => {
const usedTime = usedTimePerPrinter[printerId];
return {
printerId,
name: printers[printerId],
utilizationPercentage: usedTime / totalTimeInMinutes,
};
});
return printerUtilizationPercentage;
}

View File

@ -0,0 +1,49 @@
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
const timeRanges = {
today: { start: startOfDay(now), end: endOfDay(now) },
thisWeek: { start: startOfWeek(now), end: endOfWeek(now) },
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;
}