diff --git a/packages/reservation-platform/package.json b/packages/reservation-platform/package.json
index ef36b8f..d3cc006 100644
--- a/packages/reservation-platform/package.json
+++ b/packages/reservation-platform/package.json
@@ -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",
diff --git a/packages/reservation-platform/pnpm-lock.yaml b/packages/reservation-platform/pnpm-lock.yaml
index 54df20d..cef9b75 100644
--- a/packages/reservation-platform/pnpm-lock.yaml
+++ b/packages/reservation-platform/pnpm-lock.yaml
@@ -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
diff --git a/packages/reservation-platform/src/app/admin/charts/abort-reasons.tsx b/packages/reservation-platform/src/app/admin/charts/abort-reasons.tsx
deleted file mode 100644
index 2f55517..0000000
--- a/packages/reservation-platform/src/app/admin/charts/abort-reasons.tsx
+++ /dev/null
@@ -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 (
-
- );
-}
diff --git a/packages/reservation-platform/src/app/admin/charts/load-factor.tsx b/packages/reservation-platform/src/app/admin/charts/load-factor.tsx
deleted file mode 100644
index 06e8118..0000000
--- a/packages/reservation-platform/src/app/admin/charts/load-factor.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
- );
-}
diff --git a/packages/reservation-platform/src/app/admin/charts/printer-error.tsx b/packages/reservation-platform/src/app/admin/charts/printer-error.tsx
new file mode 100644
index 0000000..f2364e1
--- /dev/null
+++ b/packages/reservation-platform/src/app/admin/charts/printer-error.tsx
@@ -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 (
+
+
+ Fehlerrate
+ Fehlerrate der Drucker in Prozent
+
+
+
+
+
+ value}
+ />
+ `${value}%`} />
+ } />
+
+ `${value}%`}
+ />
+
+
+
+
+
+
+ Fehlerratenanalyse abgeschlossen
+
+ Zeigt die Fehlerrate für jeden Drucker
+
+
+ );
+}
diff --git a/packages/reservation-platform/src/app/admin/charts/printer-idle.tsx b/packages/reservation-platform/src/app/admin/charts/printer-idle.tsx
new file mode 100644
index 0000000..9489222
--- /dev/null
+++ b/packages/reservation-platform/src/app/admin/charts/printer-idle.tsx
@@ -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 (
+
+
+ Leerlaufzeit der Drucker
+ Durchschnittliche Leerlaufzeit der Drucker in Minuten
+
+
+
+
+
+ value}
+ />
+ } />
+
+
+
+
+
+
+ Durchschnittliche Leerlaufzeiten der Drucker
+
+
+ Zeigt die durchschnittliche Leerlaufzeit für jeden Drucker
+
+
+
+ );
+}
diff --git a/packages/reservation-platform/src/app/admin/charts/printer-utilization.tsx b/packages/reservation-platform/src/app/admin/charts/printer-utilization.tsx
new file mode 100644
index 0000000..da2fbc0
--- /dev/null
+++ b/packages/reservation-platform/src/app/admin/charts/printer-utilization.tsx
@@ -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 (
+
+
+ {data.name}
+ Nutzung des ausgewählten Druckers
+
+
+
+
+ } />
+
+
+
+
+
+
+
+ Übersicht der Nutzung
+
+ Aktuelle Auslastung des Druckers
+
+
+ );
+}
diff --git a/packages/reservation-platform/src/app/admin/charts/printer-volume.tsx b/packages/reservation-platform/src/app/admin/charts/printer-volume.tsx
new file mode 100644
index 0000000..0248612
--- /dev/null
+++ b/packages/reservation-platform/src/app/admin/charts/printer-volume.tsx
@@ -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 (
+
+
+ Druckvolumen
+ Vergleich: Heute, Diese Woche, Diesen Monat
+
+
+
+
+
+ value}
+ />
+ } />
+
+
+
+
+
+
+
+
+ Zeigt das Druckvolumen für heute, diese Woche und diesen Monat
+
+
+
+ );
+}
diff --git a/packages/reservation-platform/src/app/admin/charts/printjobs-donut.tsx b/packages/reservation-platform/src/app/admin/charts/printjobs-donut.tsx
deleted file mode 100644
index 865f640..0000000
--- a/packages/reservation-platform/src/app/admin/charts/printjobs-donut.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
- );
-}
diff --git a/packages/reservation-platform/src/app/admin/page.tsx b/packages/reservation-platform/src/app/admin/page.tsx
index a2296ab..7810e4f 100644
--- a/packages/reservation-platform/src/app/admin/page.tsx
+++ b/packages/reservation-platform/src/app/admin/page.tsx
@@ -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 (
+
+
+ Druckaufträge
+ Zurzeit sind keine Druckaufträge verfügbar.
+
+
+ Aktualisieren Sie die Seite oder prüfen Sie später erneut, ob neue Druckaufträge verfügbar sind.
+
+
+ );
+ }
- 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, 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 (
<>
Allgemein
- {/*allPrinters.map((printer) => (
-
- {printer.name}
-
- ))*/}
+ Druckerauslastung
+ Statistiken & Berichte
- {/*
-
*/}
-
-
- Druckaufträge
- nach Status
-
-
- {/**/}
-
-
-
-
-
- {/* Auslastung: {((1 - freePrinters.length / allPrinters.length) * 100).toFixed(2)}% */}
-
-
-
- {/**/}
-
-
-
-
- Abgebrochene Druckaufträge nach Abbruchgrund
-
- {/**/}
-
+
+
+
+
+
+
+ {printerUtilization.map((data) => (
+
+ ))}
+
+
+
+
- {/*allPrinters.map((printer) => (
-
- {printer.description}
-
- ))*/}
>
);
diff --git a/packages/reservation-platform/src/components/ui/chart.tsx b/packages/reservation-platform/src/components/ui/chart.tsx
new file mode 100644
index 0000000..29a3f02
--- /dev/null
+++ b/packages/reservation-platform/src/components/ui/chart.tsx
@@ -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 }
+ )
+}
+
+type ChartContextProps = {
+ config: ChartConfig
+}
+
+const ChartContext = React.createContext(null)
+
+function useChart() {
+ const context = React.useContext(ChartContext)
+
+ if (!context) {
+ throw new Error("useChart must be used within a ")
+ }
+
+ 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 (
+
+
+
+
+ {children}
+
+
+
+ )
+})
+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 (
+