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 ( +