add statistics
This commit is contained in:
parent
2e396c7cf2
commit
cddd8c1814
@ -40,6 +40,7 @@
|
|||||||
"arctic": "^1.9.2",
|
"arctic": "^1.9.2",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.30.10",
|
"drizzle-orm": "^0.30.10",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"lucia": "^3.2.1",
|
"lucia": "^3.2.1",
|
||||||
@ -53,6 +54,7 @@
|
|||||||
"react-hook-form": "^7.53.0",
|
"react-hook-form": "^7.53.0",
|
||||||
"react-if": "^4.1.5",
|
"react-if": "^4.1.5",
|
||||||
"react-timer-hook": "^3.0.7",
|
"react-timer-hook": "^3.0.7",
|
||||||
|
"recharts": "^2.13.3",
|
||||||
"regression": "^2.0.1",
|
"regression": "^2.0.1",
|
||||||
"sonner": "^1.5.0",
|
"sonner": "^1.5.0",
|
||||||
"sqlite": "^5.1.1",
|
"sqlite": "^5.1.1",
|
||||||
|
26
packages/reservation-platform/pnpm-lock.yaml
generated
26
packages/reservation-platform/pnpm-lock.yaml
generated
@ -80,6 +80,9 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
|
date-fns:
|
||||||
|
specifier: ^4.1.0
|
||||||
|
version: 4.1.0
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.30.10
|
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)
|
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:
|
react-timer-hook:
|
||||||
specifier: ^3.0.7
|
specifier: ^3.0.7
|
||||||
version: 3.0.7(react@18.3.1)
|
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:
|
regression:
|
||||||
specifier: ^2.0.1
|
specifier: ^2.0.1
|
||||||
version: 2.0.1
|
version: 2.0.1
|
||||||
@ -1772,6 +1778,9 @@ packages:
|
|||||||
date-fns@3.6.0:
|
date-fns@3.6.0:
|
||||||
resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==}
|
resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==}
|
||||||
|
|
||||||
|
date-fns@4.1.0:
|
||||||
|
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
|
||||||
|
|
||||||
debug@4.3.4:
|
debug@4.3.4:
|
||||||
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
|
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@ -2568,6 +2577,9 @@ packages:
|
|||||||
react-is@16.13.1:
|
react-is@16.13.1:
|
||||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
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:
|
react-remove-scroll-bar@2.3.6:
|
||||||
resolution: {integrity: sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==}
|
resolution: {integrity: sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@ -2639,8 +2651,8 @@ packages:
|
|||||||
recharts-scale@0.4.5:
|
recharts-scale@0.4.5:
|
||||||
resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==}
|
resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==}
|
||||||
|
|
||||||
recharts@2.12.7:
|
recharts@2.13.3:
|
||||||
resolution: {integrity: sha512-hlLJMhPQfv4/3NBSAyq3gzGg4h2v69RJh6KU7b3pXYNNAELs9kEoXOjbkxdXpALqKBoVmVptGfLpxdaVYqjmXQ==}
|
resolution: {integrity: sha512-YDZ9dOfK9t3ycwxgKbrnDlRC4BHdjlY73fet3a0C1+qGMjXVZe6+VXmpOIIhzkje5MMEL8AN4hLIe4AMskBzlA==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.0.0 || ^17.0.0 || ^18.0.0
|
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-day-picker: 8.10.1(date-fns@3.6.0)(react@18.3.1)
|
||||||
react-dom: 18.3.1(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)
|
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
|
tailwind-merge: 2.5.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- tailwindcss
|
- tailwindcss
|
||||||
@ -4397,6 +4409,8 @@ snapshots:
|
|||||||
|
|
||||||
date-fns@3.6.0: {}
|
date-fns@3.6.0: {}
|
||||||
|
|
||||||
|
date-fns@4.1.0: {}
|
||||||
|
|
||||||
debug@4.3.4:
|
debug@4.3.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.2
|
ms: 2.1.2
|
||||||
@ -5230,6 +5244,8 @@ snapshots:
|
|||||||
|
|
||||||
react-is@16.13.1: {}
|
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):
|
react-remove-scroll-bar@2.3.6(@types/react@18.3.11)(react@18.3.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
@ -5306,14 +5322,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
decimal.js-light: 2.5.1
|
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:
|
dependencies:
|
||||||
clsx: 2.1.1
|
clsx: 2.1.1
|
||||||
eventemitter3: 4.0.7
|
eventemitter3: 4.0.7
|
||||||
lodash: 4.17.21
|
lodash: 4.17.21
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
react-dom: 18.3.1(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)
|
react-smooth: 4.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
recharts-scale: 0.4.5
|
recharts-scale: 0.4.5
|
||||||
tiny-invariant: 1.3.3
|
tiny-invariant: 1.3.3
|
||||||
|
@ -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,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>
|
||||||
|
);
|
||||||
|
}
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
@ -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" 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,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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
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";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@ -9,114 +19,75 @@ export const metadata: Metadata = {
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function AdminPage() {
|
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: {
|
with: {
|
||||||
printer: true,
|
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 currentPrintJobs = printJobs.filter((job) => {
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const completedPrintJobs = allPrintJobs.filter((job) => {
|
|
||||||
if (job.aborted) return false;
|
if (job.aborted) return false;
|
||||||
|
|
||||||
const endAt = new Date(job.startAt).getTime() + job.durationInMinutes * 1000 * 60;
|
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(
|
return endAt > currentDate.getTime();
|
||||||
totalAmountOfPrintJobs > 0
|
});
|
||||||
? allPrintJobs.reduce((accumulator: Record<string, number>, job) => {
|
const occupiedPrinters = currentPrintJobs.map((job) => job.printer.id);
|
||||||
if (job.aborted && job.abortReason) {
|
const freePrinters = printers.filter((printer) => !occupiedPrinters.includes(printer.id));
|
||||||
if (!accumulator[job.abortReason]) {
|
const printerUtilization = calculatePrinterUtilization(printJobs);
|
||||||
accumulator[job.abortReason] = 1;
|
const printerVolume = calculatePrintVolumes(printJobs);
|
||||||
} else {
|
const printerIdleTime = calculatePrinterIdleTime(printJobs, printers);
|
||||||
accumulator[job.abortReason]++;
|
const printerErrorRate = calculatePrinterErrorRate(printJobs);
|
||||||
}
|
|
||||||
}
|
|
||||||
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Tabs defaultValue={"@general"} className="flex flex-col gap-4 items-start">
|
<Tabs defaultValue={"@general"} className="flex flex-col gap-4 items-start">
|
||||||
<TabsList className="bg-neutral-100 w-full py-6">
|
<TabsList className="bg-neutral-100 w-full py-6">
|
||||||
<TabsTrigger value="@general">Allgemein</TabsTrigger>
|
<TabsTrigger value="@general">Allgemein</TabsTrigger>
|
||||||
{/*allPrinters.map((printer) => (
|
<TabsTrigger value="@capacity">Druckerauslastung</TabsTrigger>
|
||||||
<TabsTrigger key={printer.id} value={printer.id}>
|
<TabsTrigger value="@report">Statistiken & Berichte</TabsTrigger>
|
||||||
{printer.name}
|
|
||||||
</TabsTrigger>
|
|
||||||
))*/}
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="@general" className="w-full">
|
<TabsContent value="@general" className="w-full">
|
||||||
<div className="flex flex-col lg:grid lg:grid-cols-2 gap-4">
|
<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="Aktive Drucker" value={occupiedPrinters.length} icon={"Rotate3d"} />
|
||||||
<DataCard title="Drucker mit meisten Abbrüchen" value={mostAbortedPrinter.printer.name} icon="Printer" />*/}
|
<DataCard title="Freie Drucker" value={freePrinters.length} icon={"PowerOff"} />
|
||||||
<Card className="w-full">
|
</div>
|
||||||
<CardHeader>
|
</TabsContent>
|
||||||
<CardTitle>Druckaufträge</CardTitle>
|
<TabsContent value="@capacity" className="w-full">
|
||||||
<CardDescription>nach Status</CardDescription>
|
<div className="flex flex-col lg:grid lg:grid-cols-2 gap-4">
|
||||||
</CardHeader>
|
{printerUtilization.map((data) => (
|
||||||
<CardContent>
|
<PrinterUtilizationChart key={data.printerId} data={data} />
|
||||||
{/*<PrintJobsDonut
|
))}
|
||||||
data={[
|
</div>
|
||||||
{ name: "Abgeschlossen", value: completedPrintJobs },
|
</TabsContent>
|
||||||
{ name: "Abgebrochen", value: abortedPrintJobs },
|
<TabsContent value="@report" className="w-full">
|
||||||
{ name: "Ausstehend", value: pendingPrintJobs },
|
<div className="flex flex-col lg:grid lg:grid-cols-2 gap-4">
|
||||||
]}
|
<PrinterIdleTimeChart printerIdleTime={printerIdleTime} />
|
||||||
/>*/}
|
<PrinterVolumeChart printerVolume={printerVolume} />
|
||||||
</CardContent>
|
<div className="w-full col-span-2">
|
||||||
</Card>
|
<PrinterErrorRateChart printerErrorRate={printerErrorRate} />
|
||||||
<Card className="w-full ">
|
</div>
|
||||||
<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>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
{/*allPrinters.map((printer) => (
|
|
||||||
<TabsContent key={printer.id} value={printer.id}>
|
|
||||||
{printer.description}
|
|
||||||
</TabsContent>
|
|
||||||
))*/}
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
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,
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
49
packages/reservation-platform/src/utils/analytics/volume.ts
Normal file
49
packages/reservation-platform/src/utils/analytics/volume.ts
Normal 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;
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user