This file is a merged representation of the entire codebase, combining all repository files into a single document. Generated by Repomix on: 2024-12-09T06:29:51.427Z ================================================================ File Summary ================================================================ Purpose: -------- This file contains a packed representation of the entire repository's contents. It is designed to be easily consumable by AI systems for analysis, code review, or other automated processes. File Format: ------------ The content is organized as follows: 1. This summary section 2. Repository information 3. Repository structure 4. Multiple file entries, each consisting of: a. A separator line (================) b. The file path (File: path/to/file) c. Another separator line d. The full contents of the file e. A blank line Usage Guidelines: ----------------- - This file should be treated as read-only. Any changes should be made to the original repository files, not this packed version. - When processing this file, use the file path to distinguish between different files in the repository. - Be aware that this file may contain sensitive information. Handle it with the same level of security as you would the original repository. Notes: ------ - Some files may have been excluded based on .gitignore rules and Repomix's configuration. - Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files. Additional Info: ---------------- For more information about Repomix, visit: https://github.com/yamadashy/repomix ================================================================ Repository Structure ================================================================ .dockerignore .env.example .gitignore biome.json components.json docker/build.sh docker/caddy/Caddyfile docker/compose.yml docker/deploy.sh docker/images/.gitattributes docker/save.sh Dockerfile drizzle.config.ts drizzle/0000_overjoyed_strong_guy.sql drizzle/meta/_journal.json drizzle/meta/0000_snapshot.json next.config.mjs package.json postcss.config.mjs public/next.svg public/vercel.svg README.md scripts/generate-data.js src/app/admin/about/page.tsx src/app/admin/admin-sidebar.tsx src/app/admin/charts/printer-error-chart.tsx src/app/admin/charts/printer-error-rate.tsx src/app/admin/charts/printer-forecast.tsx src/app/admin/charts/printer-utilization.tsx src/app/admin/charts/printer-volume.tsx src/app/admin/jobs/page.tsx src/app/admin/layout.tsx src/app/admin/page.tsx src/app/admin/printers/columns.tsx src/app/admin/printers/data-table.tsx src/app/admin/printers/dialogs/create-printer.tsx src/app/admin/printers/dialogs/delete-printer.tsx src/app/admin/printers/dialogs/edit-printer.tsx src/app/admin/printers/form.tsx src/app/admin/printers/page.tsx src/app/admin/settings/download/route.ts src/app/admin/settings/page.tsx src/app/admin/users/columns.tsx src/app/admin/users/data-table.tsx src/app/admin/users/dialog.tsx src/app/admin/users/form.tsx src/app/admin/users/page.tsx src/app/api/job/[jobId]/remaining-time/route.ts src/app/api/printers/route.ts src/app/auth/login/callback/route.ts src/app/auth/login/route.ts src/app/globals.css src/app/job/[jobId]/cancel-form.tsx src/app/job/[jobId]/edit-comments.tsx src/app/job/[jobId]/extend-form.tsx src/app/job/[jobId]/finish-form.tsx src/app/job/[jobId]/page.tsx src/app/layout.tsx src/app/my/jobs/columns.tsx src/app/my/jobs/data-table.tsx src/app/my/profile/page.tsx src/app/not-found.tsx src/app/page.tsx src/app/printer/[printerId]/reserve/form.tsx src/app/printer/[printerId]/reserve/page.tsx src/components/data-card.tsx src/components/dynamic-printer-cards.tsx src/components/header/index.tsx src/components/header/navigation.tsx src/components/login-button.tsx src/components/logout-button.tsx src/components/personalized-cards.tsx src/components/printer-availability-badge.tsx src/components/printer-card/countdown.tsx src/components/printer-card/index.tsx src/components/ui/alert-dialog.tsx src/components/ui/alert.tsx src/components/ui/avatar.tsx src/components/ui/badge.tsx src/components/ui/breadcrumb.tsx src/components/ui/button.tsx src/components/ui/card.tsx src/components/ui/chart.tsx src/components/ui/dialog.tsx src/components/ui/dropdown-menu.tsx src/components/ui/form.tsx src/components/ui/hover-card.tsx src/components/ui/input.tsx src/components/ui/label.tsx src/components/ui/scroll-area.tsx src/components/ui/select.tsx src/components/ui/skeleton.tsx src/components/ui/sonner.tsx src/components/ui/table.tsx src/components/ui/tabs.tsx src/components/ui/textarea.tsx src/components/ui/toast.tsx src/components/ui/toaster.tsx src/components/ui/use-toast.ts src/server/actions/authentication/logout.ts src/server/actions/printers.ts src/server/actions/printJobs.ts src/server/actions/timer.ts src/server/actions/user/delete.ts src/server/actions/user/update.ts src/server/actions/users.ts src/server/auth/index.ts src/server/auth/oauth.ts src/server/auth/permissions.ts src/utils/analytics/error-rate.ts src/utils/analytics/errors.ts src/utils/analytics/forecast.ts src/utils/analytics/utilization.ts src/utils/analytics/volume.ts src/utils/drizzle.ts src/utils/errors.ts src/utils/fetch.ts src/utils/guard.ts src/utils/printers.ts src/utils/strings.ts src/utils/styles.ts tailwind.config.ts tsconfig.json ================================================================ Repository Files ================================================================ ================ File: .dockerignore ================ # Build and utility assets docker/ scripts/ # Ignore node_modules as they will be installed in the container node_modules # Ignore build artifacts .next # Ignore runtime data db/ # Ignore local configuration files .env .env.example # Ignore version control files .git .gitignore # Ignore IDE/editor specific files *.log *.tmp *.DS_Store .vscode/ .idea/ ================ File: .env.example ================ # OAuth Configuration OAUTH_CLIENT_ID=client_id OAUTH_CLIENT_SECRET=client_secret ================ File: .gitignore ================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # db folder db/ # Env file .env # dependencies /node_modules /.pnp .pnp.js .yarn/install-state.gz # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* # local env files .env*.local # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts ================ File: biome.json ================ { "$schema": "https://biomejs.dev/schemas/1.7.0/schema.json", "organizeImports": { "enabled": true }, "formatter": { "enabled": true, "lineWidth": 120 }, "linter": { "enabled": true, "rules": { "recommended": true, "correctness": { "noUnusedImports": "error" } } } } ================ File: components.json ================ { "$schema": "https://ui.shadcn.com/schema.json", "style": "new-york", "rsc": true, "tsx": true, "tailwind": { "config": "tailwind.config.ts", "css": "src/app/globals.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" }, "aliases": { "components": "@/components", "utils": "@/utils/styles" } } ================ File: docker/build.sh ================ #!/bin/bash # Define image name MYP_RP_IMAGE_NAME="myp-rp" # Function to build Docker image build_image() { local image_name=$1 local dockerfile=$2 local platform=$3 echo "Building $image_name Docker image for $platform..." docker buildx build --platform $platform -t ${image_name}:latest -f $dockerfile --load . if [ $? -eq 0 ]; then echo "$image_name Docker image built successfully" else echo "Error occurred while building $image_name Docker image" exit 1 fi } # Create and use a builder instance (if not already created) BUILDER_NAME="myp-rp-arm64-builder" docker buildx create --name $BUILDER_NAME --use || docker buildx use $BUILDER_NAME # Build myp-rp image build_image "$MYP_RP_IMAGE_NAME" "$PWD/Dockerfile" "linux/arm64" # Remove the builder instance docker buildx rm $BUILDER_NAME ================ File: docker/caddy/Caddyfile ================ { debug } m040tbaraspi001.de040.corpintra.net, m040tbaraspi001.de040.corpinter.net { reverse_proxy myp-rp:3000 tls internal } ================ File: docker/compose.yml ================ services: caddy: image: caddy:2.8 container_name: caddy restart: unless-stopped ports: - 80:80 - 443:443 volumes: - ./caddy/data:/data - ./caddy/config:/config - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro myp-rp: image: myp-rp:latest container_name: myp-rp env_file: "/srv/myp-env/github.env" volumes: - /srv/MYP-DB:/usr/src/app/db restart: unless-stopped ================ File: docker/deploy.sh ================ #!/bin/bash # Directory containing the Docker images IMAGE_DIR="docker/images" # Load all Docker images from the tar.xz files in the IMAGE_DIR echo "Loading Docker images from $IMAGE_DIR..." for image_file in "$IMAGE_DIR"/*.tar.xz; do if [ -f "$image_file" ]; then echo "Loading Docker image from $image_file..." docker load -i "$image_file" # Check if the image loading was successful if [ $? -ne 0 ]; then echo "Error occurred while loading Docker image from $image_file" exit 1 fi else echo "No Docker image tar.xz files found in $IMAGE_DIR." fi done # Execute docker compose echo "Running docker compose..." docker compose -f "docker/compose.yml" up -d # Check if the operation was successful if [ $? -eq 0 ]; then echo "Docker compose executed successfully" else echo "Error occurred while executing docker compose" exit 1 fi echo "Deployment completed successfully" ================ File: docker/images/.gitattributes ================ caddy_2.8.tar.xz filter=lfs diff=lfs merge=lfs -text myp-rp_latest.tar.xz filter=lfs diff=lfs merge=lfs -text ================ File: docker/save.sh ================ #!/bin/bash # Get image name as argument IMAGE_NAME=$1 PLATFORM="linux/arm64" # Define paths IMAGE_DIR="docker/images" IMAGE_FILE="${IMAGE_DIR}/${IMAGE_NAME//[:\/]/_}.tar" COMPRESSED_FILE="${IMAGE_FILE}.xz" # Function to pull the image pull_image() { local image=$1 if [[ $image == arm64v8/* ]]; then echo "Pulling image $image without platform specification..." docker pull $image else echo "Pulling image $image for platform $PLATFORM..." docker pull --platform $PLATFORM $image fi return $? } # Pull the image if it is not available locally if ! docker image inspect ${IMAGE_NAME} &>/dev/null; then if pull_image ${IMAGE_NAME}; then echo "Image $IMAGE_NAME pulled successfully." else echo "Error occurred while pulling $IMAGE_NAME for platform $PLATFORM" echo "Trying to pull $IMAGE_NAME without platform specification..." # Attempt to pull again without platform if pull_image ${IMAGE_NAME}; then echo "Image $IMAGE_NAME pulled successfully without platform." else echo "Error occurred while pulling $IMAGE_NAME without platform." echo "Trying to pull arm64v8/${IMAGE_NAME} instead..." # Construct new image name NEW_IMAGE_NAME="arm64v8/${IMAGE_NAME}" if pull_image ${NEW_IMAGE_NAME}; then echo "Image $NEW_IMAGE_NAME pulled successfully." IMAGE_NAME=${NEW_IMAGE_NAME} # Update IMAGE_NAME to use the new one else echo "Error occurred while pulling $NEW_IMAGE_NAME" exit 1 fi fi fi else echo "Image $IMAGE_NAME found locally. Skipping pull." fi # Save the Docker image echo "Saving $IMAGE_NAME Docker image..." docker save ${IMAGE_NAME} > $IMAGE_FILE # Compress the Docker image (overwriting if file exists) echo "Compressing $IMAGE_FILE..." xz -z --force $IMAGE_FILE if [ $? -eq 0 ]; then echo "$IMAGE_NAME Docker image saved and compressed successfully as $COMPRESSED_FILE" else echo "Error occurred while compressing $IMAGE_NAME Docker image" exit 1 fi ================ File: Dockerfile ================ FROM node:20-bookworm-slim # Create application directory RUN mkdir -p /usr/src/app # Set environment variables ENV PORT=3000 ENV NEXT_TELEMETRY_DISABLED=1 WORKDIR /usr/src/app # Copy package.json and pnpm-lock.yaml COPY package.json /usr/src/app COPY pnpm-lock.yaml /usr/src/app # Install pnpm RUN corepack enable pnpm # Install dependencies RUN pnpm install # Copy the rest of the application code COPY . /usr/src/app # Initialize Database, if it not already exists RUN pnpm run db # Build the application RUN pnpm run build EXPOSE 3000 # Start the application CMD ["/bin/sh", "-c", "if [ ! -f ./db/sqlite.db ]; then pnpm db; fi && pnpm start"] ================ File: drizzle.config.ts ================ import { defineConfig } from "drizzle-kit"; //@ts-ignore - better-sqlite driver throws an error even though its an valid value export default defineConfig({ dialect: "sqlite", schema: "./src/server/db/schema.ts", out: "./drizzle", driver: "libsql", dbCredentials: { url: "file:./db/sqlite.db", }, }); ================ File: drizzle/0000_overjoyed_strong_guy.sql ================ CREATE TABLE `printJob` ( `id` text PRIMARY KEY NOT NULL, `printerId` text NOT NULL, `userId` text NOT NULL, `startAt` integer NOT NULL, `durationInMinutes` integer NOT NULL, `comments` text, `aborted` integer DEFAULT false NOT NULL, `abortReason` text, FOREIGN KEY (`printerId`) REFERENCES `printer`(`id`) ON UPDATE no action ON DELETE cascade, FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade ); --> statement-breakpoint CREATE TABLE `printer` ( `id` text PRIMARY KEY NOT NULL, `name` text NOT NULL, `description` text NOT NULL, `status` integer DEFAULT 0 NOT NULL ); --> statement-breakpoint CREATE TABLE `session` ( `id` text PRIMARY KEY NOT NULL, `user_id` text NOT NULL, `expires_at` integer NOT NULL, FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade ); --> statement-breakpoint CREATE TABLE `user` ( `id` text PRIMARY KEY NOT NULL, `github_id` integer NOT NULL, `name` text, `displayName` text, `email` text NOT NULL, `role` text DEFAULT 'guest' ); ================ File: drizzle/meta/_journal.json ================ { "version": "6", "dialect": "sqlite", "entries": [ { "idx": 0, "version": "6", "when": 1715416514336, "tag": "0000_overjoyed_strong_guy", "breakpoints": true } ] } ================ File: drizzle/meta/0000_snapshot.json ================ { "version": "6", "dialect": "sqlite", "id": "791dc197-5254-4432-bd9f-1368d1a5aa6a", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "printJob": { "name": "printJob", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true, "autoincrement": false }, "printerId": { "name": "printerId", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, "userId": { "name": "userId", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, "startAt": { "name": "startAt", "type": "integer", "primaryKey": false, "notNull": true, "autoincrement": false }, "durationInMinutes": { "name": "durationInMinutes", "type": "integer", "primaryKey": false, "notNull": true, "autoincrement": false }, "comments": { "name": "comments", "type": "text", "primaryKey": false, "notNull": false, "autoincrement": false }, "aborted": { "name": "aborted", "type": "integer", "primaryKey": false, "notNull": true, "autoincrement": false, "default": false }, "abortReason": { "name": "abortReason", "type": "text", "primaryKey": false, "notNull": false, "autoincrement": false } }, "indexes": {}, "foreignKeys": { "printJob_printerId_printer_id_fk": { "name": "printJob_printerId_printer_id_fk", "tableFrom": "printJob", "tableTo": "printer", "columnsFrom": [ "printerId" ], "columnsTo": [ "id" ], "onDelete": "cascade", "onUpdate": "no action" }, "printJob_userId_user_id_fk": { "name": "printJob_userId_user_id_fk", "tableFrom": "printJob", "tableTo": "user", "columnsFrom": [ "userId" ], "columnsTo": [ "id" ], "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {} }, "printer": { "name": "printer", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true, "autoincrement": false }, "name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, "description": { "name": "description", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, "status": { "name": "status", "type": "integer", "primaryKey": false, "notNull": true, "autoincrement": false, "default": 0 } }, "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {} }, "session": { "name": "session", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true, "autoincrement": false }, "user_id": { "name": "user_id", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, "expires_at": { "name": "expires_at", "type": "integer", "primaryKey": false, "notNull": true, "autoincrement": false } }, "indexes": {}, "foreignKeys": { "session_user_id_user_id_fk": { "name": "session_user_id_user_id_fk", "tableFrom": "session", "tableTo": "user", "columnsFrom": [ "user_id" ], "columnsTo": [ "id" ], "onDelete": "cascade", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {} }, "user": { "name": "user", "columns": { "id": { "name": "id", "type": "text", "primaryKey": true, "notNull": true, "autoincrement": false }, "github_id": { "name": "github_id", "type": "integer", "primaryKey": false, "notNull": true, "autoincrement": false }, "name": { "name": "name", "type": "text", "primaryKey": false, "notNull": false, "autoincrement": false }, "displayName": { "name": "displayName", "type": "text", "primaryKey": false, "notNull": false, "autoincrement": false }, "email": { "name": "email", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, "role": { "name": "role", "type": "text", "primaryKey": false, "notNull": false, "autoincrement": false, "default": "'guest'" } }, "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {} } }, "enums": {}, "_meta": { "schemas": {}, "tables": {}, "columns": {} } } ================ File: next.config.mjs ================ /** @type {import('next').NextConfig} */ const nextConfig = { async headers() { return [ { source: "/:path*", headers: [ { key: "Access-Control-Allow-Origin", value: "m040tbaraspi001.de040.corpintra.net", }, { key: "Access-Control-Allow-Methods", value: "GET, POST, PUT, DELETE, OPTIONS", }, { key: "Access-Control-Allow-Headers", value: "Content-Type, Authorization", }, ], }, ]; }, }; export default nextConfig; ================ File: package.json ================ { "name": "myp-rp", "version": "1.0.0", "private": true, "packageManager": "pnpm@9.12.1", "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint", "db:create-default": "mkdir -p db/", "db:generate-sqlite": "pnpm drizzle-kit generate", "db:clean": "rm -rf db/ drizzle/", "db:migrate": "pnpm drizzle-kit migrate", "db": "pnpm db:create-default && pnpm db:generate-sqlite && pnpm db:migrate", "db:reset": "pnpm db:clean && pnpm db" }, "dependencies": { "@faker-js/faker": "^9.2.0", "@headlessui/react": "^2.1.10", "@headlessui/tailwindcss": "^0.2.1", "@hookform/resolvers": "^3.9.0", "@libsql/client": "^0.14.0", "@lucia-auth/adapter-drizzle": "^1.1.0", "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-hover-card": "^1.1.2", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-scroll-area": "^1.2.0", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toast": "^1.2.2", "@remixicon/react": "^4.3.0", "@tanstack/react-table": "^8.20.5", "@tremor/react": "^3.18.3", "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", "lucide-react": "^0.378.0", "luxon": "^3.5.0", "next": "14.2.3", "next-themes": "^0.3.0", "oslo": "^1.2.1", "react": "^18.3.1", "react-dom": "^18.3.1", "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", "sqlite3": "^5.1.7", "swr": "^2.2.5", "tailwind-merge": "^2.5.3", "tailwindcss-animate": "^1.0.7", "use-debounce": "^10.0.3", "uuid": "^11.0.2", "zod": "^3.23.8" }, "devDependencies": { "@biomejs/biome": "^1.9.3", "@tailwindcss/forms": "^0.5.9", "@types/lodash": "^4.17.13", "@types/luxon": "^3.4.2", "@types/node": "^20.16.11", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.1", "drizzle-kit": "^0.21.4", "postcss": "^8.4.47", "tailwindcss": "^3.4.13", "ts-node": "^10.9.2", "typescript": "^5.6.3" } } ================ File: postcss.config.mjs ================ /** @type {import('postcss-load-config').Config} */ const config = { plugins: { tailwindcss: {}, }, }; export default config; ================ File: public/next.svg ================ ================ File: public/vercel.svg ================ ================ File: README.md ================ # MYP - Manage Your Printer MYP (Manage Your Printer) ist eine Webanwendung zur Reservierung von 3D-Druckern. Sie wurde im Rahmen des Abschlussprojektes der Fachinformatiker Ausbildung für Daten- und Prozessanalyse für die Technische Berufsausbildung des Mercedes-Benz Werkes Berlin-Marienfelde entwickelt. ## Deployment ### Voraussetzungen - Netzwerk auf Raspberry Pi ist eingerichtet - Docker ist installiert ### Schritte 1. Docker-Container bauen (docker/build.sh) 2. Docker-Container speichern (docker/save.sh caddy:2.8 myp-rp:latest) 3. Docker-Container auf Raspberry Pi bereitstellen (docker/deploy.sh) ## Entwicklerinformationen ### Raspberry Pi Einstellungen Auf dem Raspberry Pi wurde Raspbian Lite installiert. Unter /srv/* sind die Projektdateien zu finden. ### Anmeldedaten ``` Benutzer: myp Passwort: (persönlich bekannt) ``` ================ File: scripts/generate-data.js ================ const sqlite3 = require("sqlite3"); const faker = require("@faker-js/faker").faker; const { random, sample, sampleSize, sum } = require("lodash"); const { DateTime } = require("luxon"); const { open } = require("sqlite"); const { v4: uuidv4 } = require("uuid"); const dbPath = "./db/sqlite.db"; // Configuration for test data generation let startDate = DateTime.fromISO("2024-10-08"); let endDate = DateTime.fromISO("2024-11-08"); let numberOfPrinters = 5; // Use weekday names for better readability and ease of setting trends let avgPrintTimesPerDay = { Monday: 4, Tuesday: 2, Wednesday: 5, Thursday: 2, Friday: 3, Saturday: 0, Sunday: 0, }; // Average number of prints for each weekday let avgPrintDurationPerDay = { Monday: 240, // Total average duration in minutes for Monday Tuesday: 30, Wednesday: 45, Thursday: 40, Friday: 120, Saturday: 0, Sunday: 0, }; // Average total duration of prints for each weekday let printerUsage = { "Drucker 1": 0.5, "Drucker 2": 0.7, "Drucker 3": 0.6, "Drucker 4": 0.3, "Drucker 5": 0.4, }; // Usage percentages for each printer // **New Configurations for Error Rates** let generalErrorRate = 0.05; // 5% chance any print job may fail let printerErrorRates = { "Drucker 1": 0.02, // 2% error rate for Printer 1 "Drucker 2": 0.03, "Drucker 3": 0.01, "Drucker 4": 0.05, "Drucker 5": 0.04, }; // Error rates for each printer const holidays = []; // Example holidays const existingJobs = []; const initDB = async () => { console.log("Initializing database connection..."); return open({ filename: dbPath, driver: sqlite3.Database, }); }; const createUser = (isPowerUser = false) => { const name = [faker.person.firstName(), faker.person.lastName()]; const user = { id: uuidv4(), github_id: faker.number.int(), username: `${name[0].slice(0, 2)}${name[1].slice(0, 6)}`.toUpperCase(), displayName: `${name[0]} ${name[1]}`.toUpperCase(), email: `${name[0]}.${name[1]}@example.com`, role: sample(["user", "admin"]), isPowerUser, }; console.log("Created user:", user); return user; }; const createPrinter = (index) => { const printer = { id: uuidv4(), name: `Drucker ${index}`, description: faker.lorem.sentence(), status: random(0, 2), }; console.log("Created printer:", printer); return printer; }; const isPrinterAvailable = (printer, startAt, duration) => { const endAt = startAt + duration * 60 * 1000; // Convert minutes to milliseconds return !existingJobs.some((job) => { const jobStart = job.startAt; const jobEnd = job.startAt + job.durationInMinutes * 60 * 1000; return ( printer.id === job.printerId && ((startAt >= jobStart && startAt < jobEnd) || (endAt > jobStart && endAt <= jobEnd) || (startAt <= jobStart && endAt >= jobEnd)) ); }); }; const createPrintJob = (users, printers, startAt, duration) => { const user = sample(users); let printer; // Weighted selection based on printer usage const printerNames = Object.keys(printerUsage); const weightedPrinters = printers.filter((p) => printerNames.includes(p.name)); // Create a weighted array of printers based on usage percentages const printerWeights = weightedPrinters.map((p) => ({ printer: p, weight: printerUsage[p.name], })); const totalWeight = sum(printerWeights.map((pw) => pw.weight)); const randomWeight = Math.random() * totalWeight; let accumulatedWeight = 0; for (const pw of printerWeights) { accumulatedWeight += pw.weight; if (randomWeight <= accumulatedWeight) { printer = pw.printer; break; } } if (!printer) { printer = sample(printers); } if (!isPrinterAvailable(printer, startAt, duration)) { console.log("Printer not available, skipping job creation."); return null; } // **Determine if the job should be aborted based on error rates** let aborted = false; let abortReason = null; // Calculate the combined error rate const printerErrorRate = printerErrorRates[printer.name] || 0; const combinedErrorRate = 1 - (1 - generalErrorRate) * (1 - printerErrorRate); if (Math.random() < combinedErrorRate) { aborted = true; const errorMessages = [ "Unbekannt", "Keine Ahnung", "Falsch gebucht", "Filament gelöst", "Druckabbruch", "Düsenverstopfung", "Schichthaftung fehlgeschlagen", "Materialmangel", "Dateifehler", "Temperaturproblem", "Mechanischer Fehler", "Softwarefehler", "Kalibrierungsfehler", "Überhitzung", ]; abortReason = sample(errorMessages); // Generate a random abort reason } const printJob = { id: uuidv4(), printerId: printer.id, userId: user.id, startAt, durationInMinutes: duration, comments: faker.lorem.sentence(), aborted, abortReason, }; console.log("Created print job:", printJob); return printJob; }; const generatePrintJobsForDay = async (users, printers, dayDate, totalJobsForDay, totalDurationForDay, db, dryRun) => { console.log(`Generating print jobs for ${dayDate.toISODate()}...`); // Generate random durations that sum up approximately to totalDurationForDay const durations = []; let remainingDuration = totalDurationForDay; for (let i = 0; i < totalJobsForDay; i++) { const avgJobDuration = remainingDuration / (totalJobsForDay - i); const jobDuration = Math.max( Math.round(random(avgJobDuration * 0.8, avgJobDuration * 1.2)), 5, // Minimum duration of 5 minutes ); durations.push(jobDuration); remainingDuration -= jobDuration; } // Shuffle durations to randomize job lengths const shuffledDurations = sampleSize(durations, durations.length); for (let i = 0; i < totalJobsForDay; i++) { const duration = shuffledDurations[i]; // Random start time between 8 AM and 6 PM, adjusted to avoid overlapping durations const possibleStartHours = Array.from({ length: 10 }, (_, idx) => idx + 8); // 8 AM to 6 PM let startAt; let attempts = 0; do { const hour = sample(possibleStartHours); const minute = random(0, 59); startAt = dayDate.set({ hour, minute, second: 0, millisecond: 0 }).toMillis(); attempts++; if (attempts > 10) { console.log("Unable to find available time slot, skipping job."); break; } } while (!isPrinterAvailable(sample(printers), startAt, duration)); if (attempts > 10) continue; const printJob = createPrintJob(users, printers, startAt, duration); if (printJob) { if (!dryRun) { await db.run( `INSERT INTO printJob (id, printerId, userId, startAt, durationInMinutes, comments, aborted, abortReason) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [ printJob.id, printJob.printerId, printJob.userId, printJob.startAt, printJob.durationInMinutes, printJob.comments, printJob.aborted ? 1 : 0, printJob.abortReason, ], ); } existingJobs.push(printJob); console.log("Inserted print job into database:", printJob.id); } } }; const generateTestData = async (dryRun = false) => { console.log("Starting test data generation..."); const db = await initDB(); // Generate users and printers const users = [ ...Array.from({ length: 7 }, () => createUser(false)), ...Array.from({ length: 3 }, () => createUser(true)), ]; const printers = Array.from({ length: numberOfPrinters }, (_, index) => createPrinter(index + 1)); if (!dryRun) { // Insert users into the database for (const user of users) { await db.run( `INSERT INTO user (id, github_id, name, displayName, email, role) VALUES (?, ?, ?, ?, ?, ?)`, [user.id, user.github_id, user.username, user.displayName, user.email, user.role], ); console.log("Inserted user into database:", user.id); } // Insert printers into the database for (const printer of printers) { await db.run( `INSERT INTO printer (id, name, description, status) VALUES (?, ?, ?, ?)`, [printer.id, printer.name, printer.description, printer.status], ); console.log("Inserted printer into database:", printer.id); } } // Generate print jobs for each day within the specified date range let currentDay = startDate; while (currentDay <= endDate) { const weekdayName = currentDay.toFormat("EEEE"); // Get weekday name (e.g., 'Monday') if (holidays.includes(currentDay.toISODate()) || avgPrintTimesPerDay[weekdayName] === 0) { console.log(`Skipping holiday or no jobs scheduled: ${currentDay.toISODate()}`); currentDay = currentDay.plus({ days: 1 }); continue; } const totalJobsForDay = avgPrintTimesPerDay[weekdayName]; const totalDurationForDay = avgPrintDurationPerDay[weekdayName]; await generatePrintJobsForDay(users, printers, currentDay, totalJobsForDay, totalDurationForDay, db, dryRun); currentDay = currentDay.plus({ days: 1 }); } if (!dryRun) { await db.close(); console.log("Database connection closed. Test data generation complete."); } else { console.log("Dry run complete. No data was written to the database."); } }; const setConfigurations = (config) => { if (config.startDate) startDate = DateTime.fromISO(config.startDate); if (config.endDate) endDate = DateTime.fromISO(config.endDate); if (config.numberOfPrinters) numberOfPrinters = config.numberOfPrinters; if (config.avgPrintTimesPerDay) avgPrintTimesPerDay = config.avgPrintTimesPerDay; if (config.avgPrintDurationPerDay) avgPrintDurationPerDay = config.avgPrintDurationPerDay; if (config.printerUsage) printerUsage = config.printerUsage; if (config.generalErrorRate !== undefined) generalErrorRate = config.generalErrorRate; if (config.printerErrorRates) printerErrorRates = config.printerErrorRates; }; // Example usage setConfigurations({ startDate: "2024-10-08", endDate: "2024-11-08", numberOfPrinters: 6, avgPrintTimesPerDay: { Monday: 4, // High usage Tuesday: 2, // Low usage Wednesday: 3, // Low usage Thursday: 2, // Low usage Friday: 8, // High usage Saturday: 0, Sunday: 0, }, avgPrintDurationPerDay: { Monday: 300, // High total duration Tuesday: 60, // Low total duration Wednesday: 90, Thursday: 60, Friday: 240, Saturday: 0, Sunday: 0, }, printerUsage: { "Drucker 1": 2.3, "Drucker 2": 1.7, "Drucker 3": 0.1, "Drucker 4": 1.5, "Drucker 5": 2.4, "Drucker 6": 0.3, "Drucker 7": 0.9, "Drucker 8": 0.1, }, generalErrorRate: 0.05, // 5% general error rate printerErrorRates: { "Drucker 1": 0.02, "Drucker 2": 0.03, "Drucker 3": 0.1, "Drucker 4": 0.05, "Drucker 5": 0.04, "Drucker 6": 0.02, "Drucker 7": 0.01, "PrinteDrucker 8": 0.03, }, }); generateTestData(process.argv.includes("--dry-run")) .then(() => { console.log("Test data generation script finished."); }) .catch((err) => { console.error("Error generating test data:", err); }); ================ File: src/app/admin/about/page.tsx ================ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import type { Metadata } from "next"; export const metadata: Metadata = { title: "Über MYP", }; export default async function AdminPage() { return ( Über MYP MYP — Manage Your Printer

MYP ist eine Webanwendung zur Reservierung von 3D-Druckern. Sie wurde im Rahmen des Abschlussprojektes der Fachinformatiker Ausbildung für Daten- und Prozessanalyse für die Technische Berufsausbildung des Mercedes-Benz Werkes Berlin-Marienfelde entwickelt.

© 2024{" "} Torben Haack

); } ================ File: src/app/admin/admin-sidebar.tsx ================ "use client"; import { cn } from "@/utils/styles"; import { FileIcon, HeartIcon, LayoutDashboardIcon, PrinterIcon, UsersIcon, WrenchIcon } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; interface AdminSite { name: string; path: string; icon: React.ReactNode; } export function AdminSidebar() { const pathname = usePathname(); const adminSites: AdminSite[] = [ { name: "Dashboard", path: "/admin", icon: , }, { name: "Benutzer", path: "/admin/users", icon: , }, { name: "Drucker", path: "/admin/printers", icon: , }, { name: "Druckaufträge", path: "/admin/jobs", icon: , }, { name: "Einstellungen", path: "/admin/settings", icon: , }, { name: "Über MYP", path: "/admin/about", icon: , }, ]; return (
    {adminSites.map((site) => (
  • {site.icon} {site.name}
  • ))}
); } ================ File: src/app/admin/charts/printer-error-chart.tsx ================ "use client"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"; import { Bar, BarChart, CartesianGrid, LabelList, XAxis, YAxis } from "recharts"; export const description = "Ein Säulendiagramm zur Darstellung der Abbruchgründe und ihrer Häufigkeit"; interface AbortReasonCountChartProps { abortReasonCount: { abortReason: string; count: number; }[]; } const chartConfig = { abortReason: { label: "Abbruchgrund", }, } satisfies ChartConfig; export function AbortReasonCountChart({ abortReasonCount }: AbortReasonCountChartProps) { // Transform data to fit the chart structure const chartData = abortReasonCount.map((reason) => ({ abortReason: reason.abortReason, count: reason.count, })); return ( Abbruchgründe Häufigkeit der Abbruchgründe für Druckaufträge value} /> `${value}`} /> } /> `${value}`} /> ); } ================ File: src/app/admin/charts/printer-error-rate.tsx ================ "use client"; import { Bar, BarChart, CartesianGrid, LabelList, XAxis, YAxis } from "recharts"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"; import type { PrinterErrorRate } from "@/utils/analytics/error-rate"; export const description = "Ein Säulendiagramm zur Darstellung der Fehlerrate"; interface PrinterErrorRateChartProps { printerErrorRate: PrinterErrorRate[]; } const chartConfig = { errorRate: { label: "Fehlerrate", }, } satisfies ChartConfig; export function PrinterErrorRateChart({ printerErrorRate }: PrinterErrorRateChartProps) { // Transform data to fit the chart structure const chartData = printerErrorRate.map((printer) => ({ printer: printer.name, errorRate: printer.errorRate, })); return ( Fehlerrate Fehlerrate der Drucker in Prozent value} /> `${value}%`} /> } /> `${value}%`} /> ); } ================ File: src/app/admin/charts/printer-forecast.tsx ================ "use client"; import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"; import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; export const description = "Ein Bereichsdiagramm zur Darstellung der prognostizierten Nutzung pro Wochentag"; interface ForecastData { day: number; // 0 for Sunday, 1 for Monday, ..., 6 for Saturday usageMinutes: number; } interface ForecastChartProps { forecastData: ForecastData[]; } const chartConfig = { usage: { label: "Prognostizierte Nutzung", color: "hsl(var(--chart-1))", }, } satisfies ChartConfig; const daysOfWeek = ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"]; export function ForecastPrinterUsageChart({ forecastData }: ForecastChartProps) { // Transform and slice data to fit the chart structure const chartData = forecastData.map((data) => ({ //slice(1, forecastData.length - 1). day: daysOfWeek[data.day], // Map day number to weekday name usage: data.usageMinutes, })); return ( Prognostizierte Nutzung pro Wochentag } />
Zeigt die prognostizierte Nutzungszeit pro Wochentag in Minuten.
Besten Tage zur Wartung: {bestMaintenanceDays(forecastData)}
); } function bestMaintenanceDays(forecastData: ForecastData[]) { const sortedData = forecastData.map((a) => a).sort((a, b) => a.usageMinutes - b.usageMinutes); // Sort ascending const q1Index = Math.floor(sortedData.length * 0.33); const q1 = sortedData[q1Index].usageMinutes; // First quartile (Q1) value const filteredData = sortedData.filter((data) => data.usageMinutes <= q1); return filteredData .map((data) => { const days = ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"]; return days[data.day]; }) .join(", "); } ================ File: src/app/admin/charts/printer-utilization.tsx ================ "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
); } ================ File: src/app/admin/charts/printer-volume.tsx ================ "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
); } ================ File: src/app/admin/jobs/page.tsx ================ import { columns } from "@/app/my/jobs/columns"; import { JobsTable } from "@/app/my/jobs/data-table"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { db } from "@/server/db"; import { printJobs } from "@/server/db/schema"; import { desc } from "drizzle-orm"; import type { Metadata } from "next"; export const metadata: Metadata = { title: "Alle Druckaufträge", }; export default async function AdminJobsPage() { const allJobs = await db.query.printJobs.findMany({ orderBy: [desc(printJobs.startAt)], with: { user: true, printer: true, }, }); return (
Druckaufträge Alle Druckaufträge
); } ================ File: src/app/admin/layout.tsx ================ import { AdminSidebar } from "@/app/admin/admin-sidebar"; import { validateRequest } from "@/server/auth"; import { UserRole } from "@/server/auth/permissions"; import { IS_NOT, guard } from "@/utils/guard"; import { redirect } from "next/navigation"; interface AdminLayoutProps { children: React.ReactNode; } export const dynamic = "force-dynamic"; export default async function AdminLayout(props: AdminLayoutProps) { const { children } = props; const { user } = await validateRequest(); if (guard(user, IS_NOT, UserRole.ADMIN)) { redirect("/"); } return (

Admin

{children}
); } ================ File: src/app/admin/page.tsx ================ import { AbortReasonCountChart } from "@/app/admin/charts/printer-error-chart"; import { PrinterErrorRateChart } from "@/app/admin/charts/printer-error-rate"; import { ForecastPrinterUsageChart } from "@/app/admin/charts/printer-forecast"; import { PrinterUtilizationChart } from "@/app/admin/charts/printer-utilization"; import { PrinterVolumeChart } from "@/app/admin/charts/printer-volume"; import { DataCard } from "@/components/data-card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { db } from "@/server/db"; import { calculatePrinterErrorRate } from "@/utils/analytics/error-rate"; import { calculateAbortReasonsCount } from "@/utils/analytics/errors"; import { forecastPrinterUsage } from "@/utils/analytics/forecast"; import { calculatePrinterUtilization } from "@/utils/analytics/utilization"; import { calculatePrintVolumes } from "@/utils/analytics/volume"; import type { Metadata } from "next"; export const metadata: Metadata = { title: "Admin Dashboard", }; export const dynamic = "force-dynamic"; export default async function AdminPage() { 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 currentPrintJobs = printJobs.filter((job) => { if (job.aborted) return false; const endAt = job.startAt.getTime() + job.durationInMinutes * 1000 * 60; return endAt > currentDate.getTime(); }); const occupiedPrinters = currentPrintJobs.map((job) => job.printer.id); const freePrinters = printers.filter((printer) => !occupiedPrinters.includes(printer.id)); const printerUtilization = calculatePrinterUtilization(printJobs); const printerVolume = calculatePrintVolumes(printJobs); const printerAbortReasons = calculateAbortReasonsCount(printJobs); const printerErrorRate = calculatePrinterErrorRate(printJobs); const printerForecast = forecastPrinterUsage(printJobs); return ( <> Allgemein Druckerauslastung Fehlerberichte Prognosen
{printerUtilization.map((data) => ( ))}
({ day: index, usageMinutes, }))} />
); } ================ File: src/app/admin/printers/columns.tsx ================ "use client"; import type { printers } from "@/server/db/schema"; import type { ColumnDef } from "@tanstack/react-table"; import type { InferSelectModel } from "drizzle-orm"; import { ArrowUpDown, MoreHorizontal, PencilIcon } from "lucide-react"; import { EditPrinterDialogContent, EditPrinterDialogTrigger } from "@/app/admin/printers/dialogs/edit-printer"; import { Button } from "@/components/ui/button"; import { Dialog } from "@/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { type PrinterStatus, translatePrinterStatus } from "@/utils/printers"; import { useState } from "react"; // This type is used to define the shape of our data. // You can use a Zod schema here if you want. export const columns: ColumnDef>[] = [ { accessorKey: "id", header: ({ column }) => { return ( ); }, }, { accessorKey: "name", header: "Name", }, { accessorKey: "description", header: "Beschreibung", }, { accessorKey: "status", header: "Status", cell: ({ row }) => { const status = row.getValue("status"); const translated = translatePrinterStatus(status as PrinterStatus); return translated; }, }, { id: "actions", cell: ({ row }) => { const printer = row.original; const [open, setOpen] = useState(false); return ( Aktionen ABC
Bearbeiten
); }, }, ]; ================ File: src/app/admin/printers/data-table.tsx ================ "use client"; import { type ColumnDef, type ColumnFiltersState, type SortingState, type VisibilityState, flexRender, getCoreRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useReactTable, } from "@tanstack/react-table"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { SlidersHorizontalIcon } from "lucide-react"; import { useState } from "react"; interface DataTableProps { columns: ColumnDef[]; data: TData[]; } export function DataTable({ columns, data }: DataTableProps) { const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); const [columnVisibility, setColumnVisibility] = useState({}); const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), onSortingChange: setSorting, getSortedRowModel: getSortedRowModel(), onColumnFiltersChange: setColumnFilters, getFilteredRowModel: getFilteredRowModel(), onColumnVisibilityChange: setColumnVisibility, state: { sorting, columnFilters, columnVisibility, }, }); return (
table.getColumn("name")?.setFilterValue(event.target.value)} className="max-w-sm" /> {table .getAllColumns() .filter((column) => column.getCanHide()) .map((column) => { return ( column.toggleVisibility(!!value)} > {column.id} ); })}
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { return ( {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} ); })} ))} {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} )) ) : ( Keine Ergebnisse gefunden. )}
); } ================ File: src/app/admin/printers/dialogs/create-printer.tsx ================ "use client"; import { PrinterForm } from "@/app/admin/printers/form"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { useState } from "react"; interface CreatePrinterDialogProps { children: React.ReactNode; } export function CreatePrinterDialog(props: CreatePrinterDialogProps) { const { children } = props; const [open, setOpen] = useState(false); return ( {children} Drucker erstellen ); } ================ File: src/app/admin/printers/dialogs/delete-printer.tsx ================ "use client"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { useToast } from "@/components/ui/use-toast"; import { deletePrinter } from "@/server/actions/printers"; import { TrashIcon } from "lucide-react"; interface DeletePrinterDialogProps { printerId: string; setOpen: (state: boolean) => void; } export function DeletePrinterDialog(props: DeletePrinterDialogProps) { const { printerId, setOpen } = props; const { toast } = useToast(); async function onSubmit() { toast({ description: "Drucker wird gelöscht...", }); try { const result = await deletePrinter(printerId); if (result?.error) { toast({ description: result.error, variant: "destructive", }); } toast({ description: "Drucker wurde gelöscht.", }); setOpen(false); } catch (error) { if (error instanceof Error) { toast({ description: error.message, variant: "destructive", }); } else { toast({ description: "Ein unbekannter Fehler ist aufgetreten.", variant: "destructive", }); } } } return ( Bist Du dir sicher? Diese Aktion kann nicht rückgängig gemacht werden. Der Drucker und die damit verbundenen Daten werden unwiderruflich gelöscht. Abbrechen Ja, löschen ); } ================ File: src/app/admin/printers/dialogs/edit-printer.tsx ================ import { PrinterForm } from "@/app/admin/printers/form"; import { DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import type { InferResultType } from "@/utils/drizzle"; interface EditPrinterDialogTriggerProps { children: React.ReactNode; } export function EditPrinterDialogTrigger(props: EditPrinterDialogTriggerProps) { const { children } = props; return {children}; } interface EditPrinterDialogContentProps { printer: InferResultType<"printers">; setOpen: (open: boolean) => void; } export function EditPrinterDialogContent(props: EditPrinterDialogContentProps) { const { printer, setOpen } = props; return ( Drucker bearbeiten ); } ================ File: src/app/admin/printers/form.tsx ================ "use client"; import { DeletePrinterDialog } from "@/app/admin/printers/dialogs/delete-printer"; import { Button } from "@/components/ui/button"; import { DialogClose } from "@/components/ui/dialog"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { useToast } from "@/components/ui/use-toast"; import { createPrinter, updatePrinter } from "@/server/actions/printers"; import type { InferResultType } from "@/utils/drizzle"; import { zodResolver } from "@hookform/resolvers/zod"; import { SaveIcon, XCircleIcon } from "lucide-react"; import { useForm } from "react-hook-form"; import { z } from "zod"; export const formSchema = z.object({ name: z .string() .min(2, { message: "Der Name muss mindestens 2 Zeichen lang sein.", }) .max(50), description: z .string() .min(2, { message: "Die Beschreibung muss mindestens 2 Zeichen lang sein.", }) .max(50), status: z.coerce.number().int().min(0).max(1), }); interface PrinterFormProps { printer?: InferResultType<"printers">; setOpen: (state: boolean) => void; } export function PrinterForm(props: PrinterFormProps) { const { printer, setOpen } = props; const { toast } = useToast(); const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { name: printer?.name ?? "", description: printer?.description ?? "", status: printer?.status ?? 0, }, }); // 2. Define a submit handler. async function onSubmit(values: z.infer) { // TODO: create or update if (printer) { toast({ description: "Drucker wird aktualisiert...", }); // Update try { const result = await updatePrinter(printer.id, { description: values.description, name: values.name, status: values.status, }); if (result?.error) { toast({ description: result.error, variant: "destructive", }); } setOpen(false); toast({ description: "Drucker wurde aktualisiert.", variant: "default", }); } catch (error) { if (error instanceof Error) { toast({ description: error.message, variant: "destructive", }); } else { toast({ description: "Ein unbekannter Fehler ist aufgetreten.", variant: "destructive", }); } } } else { toast({ description: "Drucker wird erstellt...", variant: "default", }); // Create try { const result = await createPrinter({ description: values.description, name: values.name, status: values.status, }); if (result?.error) { toast({ description: result.error, variant: "destructive", }); } setOpen(false); toast({ description: "Drucker wurde erstellt.", variant: "default", }); } catch (error) { if (error instanceof Error) { toast({ description: error.message, variant: "destructive", }); } else { toast({ description: "Ein unbekannter Fehler ist aufgetreten.", variant: "destructive", }); } } } } return (
( Name Bitte gib einen eindeutigen Namen für den Drucker ein. )} /> ( Beschreibung Füge eine kurze Beschreibung des Druckers hinzu. )} /> ( Status Wähle den aktuellen Status des Druckers. )} />
{printer && } {!printer && ( )}
); } ================ File: src/app/admin/printers/page.tsx ================ import { columns } from "@/app/admin/printers/columns"; import { DataTable } from "@/app/admin/printers/data-table"; import { CreatePrinterDialog } from "@/app/admin/printers/dialogs/create-printer"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { db } from "@/server/db"; import { PlusCircleIcon } from "lucide-react"; export default async function AdminPage() { const data = await db.query.printers.findMany(); return (
Druckerverwaltung Suche, Bearbeite, Lösche und Erstelle Drucker
); } ================ File: src/app/admin/settings/download/route.ts ================ import fs from "node:fs"; export const dynamic = 'force-dynamic'; export async function GET() { return new Response(fs.readFileSync("./db/sqlite.db")); } ================ File: src/app/admin/settings/page.tsx ================ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import Link from "next/link"; import type { Metadata } from "next"; export const metadata: Metadata = { title: "Systemeinstellungen", }; export default function AdminPage() { return ( Einstellungen Systemeinstellungen

Datenbank herunterladen

); } ================ File: src/app/admin/users/columns.tsx ================ "use client"; import { type UserRole, translateUserRole } from "@/server/auth/permissions"; import type { users } from "@/server/db/schema"; import type { ColumnDef } from "@tanstack/react-table"; import type { InferSelectModel } from "drizzle-orm"; import { ArrowUpDown, MailIcon, MessageCircleIcon, MoreHorizontal, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import Link from "next/link"; import { EditUserDialogContent, EditUserDialogRoot, EditUserDialogTrigger, } from "@/app/admin/users/dialog"; // This type is used to define the shape of our data. // You can use a Zod schema here if you want. export type User = { id: string; github_id: number; username: string; displayName: string; email: string; role: string; }; export const columns: ColumnDef>[] = [ { accessorKey: "id", header: ({ column }) => { return ( ); }, }, { accessorKey: "github_id", header: "GitHub ID", }, { accessorKey: "username", header: "Username", }, { accessorKey: "displayName", header: "Name", }, { accessorKey: "email", header: "E-Mail", }, { accessorKey: "role", header: "Rolle", cell: ({ row }) => { const role = row.getValue("role"); const translated = translateUserRole(role as UserRole); return translated; }, }, { id: "actions", cell: ({ row }) => { const user = row.original; return ( Aktionen Teams-Chat öffnen E-Mail schicken ); }, }, ]; function generateTeamsChatURL(email: string) { return `https://teams.microsoft.com/l/chat/0/0?users=${email}`; } function generateEMailURL(email: string) { return `mailto:${email}`; } ================ File: src/app/admin/users/data-table.tsx ================ "use client"; import { type ColumnDef, type ColumnFiltersState, type SortingState, type VisibilityState, flexRender, getCoreRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useReactTable, } from "@tanstack/react-table"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { SlidersHorizontalIcon } from "lucide-react"; import { useState } from "react"; interface DataTableProps { columns: ColumnDef[]; data: TData[]; } export function DataTable({ columns, data }: DataTableProps) { const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); const [columnVisibility, setColumnVisibility] = useState({}); const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), onSortingChange: setSorting, getSortedRowModel: getSortedRowModel(), onColumnFiltersChange: setColumnFilters, getFilteredRowModel: getFilteredRowModel(), onColumnVisibilityChange: setColumnVisibility, state: { sorting, columnFilters, columnVisibility, }, }); return (
table.getColumn("email")?.setFilterValue(event.target.value)} className="max-w-sm" /> {table .getAllColumns() .filter((column) => column.getCanHide()) .map((column) => { return ( column.toggleVisibility(!!value)} > {column.id} ); })}
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { return ( {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} ); })} ))} {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} )) ) : ( Keine Ergebnisse gefunden. )}
); } ================ File: src/app/admin/users/dialog.tsx ================ import type { User } from "@/app/admin/users/columns"; import { ProfileForm } from "@/app/admin/users/form"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { PencilIcon } from "lucide-react"; interface EditUserDialogRootProps { children: React.ReactNode; } export function EditUserDialogRoot(props: EditUserDialogRootProps) { const { children } = props; return {children}; } export function EditUserDialogTrigger() { return ( Benutzer bearbeiten ); } interface EditUserDialogContentProps { user: User; } export function EditUserDialogContent(props: EditUserDialogContentProps) { const { user } = props; if (!user) { return; } return ( Benutzer bearbeiten Hinweis: In den seltensten Fällen sollten die Daten eines Benutzers geändert werden. Dies kann zu unerwarteten Problemen führen. ); } ================ File: src/app/admin/users/form.tsx ================ "use client"; import type { User } from "@/app/admin/users/columns"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { DialogClose } from "@/components/ui/dialog"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { useToast } from "@/components/ui/use-toast"; import { deleteUser, updateUser } from "@/server/actions/users"; import type { UserRole } from "@/server/auth/permissions"; import { zodResolver } from "@hookform/resolvers/zod"; import { SaveIcon, TrashIcon } from "lucide-react"; import { useForm } from "react-hook-form"; import { z } from "zod"; export const formSchema = z.object({ username: z .string() .min(2, { message: "Der Benutzername muss mindestens 2 Zeichen lang sein.", }) .max(50), displayName: z .string() .min(2, { message: "Der Anzeigename muss mindestens 2 Zeichen lang sein.", }) .max(50), email: z.string().email(), role: z.enum(["admin", "user", "guest"]), }); interface ProfileFormProps { user: User; } export function ProfileForm(props: ProfileFormProps) { const { user } = props; const { toast } = useToast(); // 1. Define your form. const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { username: user.username, displayName: user.displayName, email: user.email, role: user.role as UserRole, }, }); // 2. Define a submit handler. async function onSubmit(values: z.infer) { toast({ description: "Benutzerprofil wird aktualisiert..." }); await updateUser(user.id, values); toast({ description: "Benutzerprofil wurde aktualisiert." }); } return (
( Benutzername Nur in Ausnahmefällen sollte der Benutzername geändert werden. )} /> ( Anzeigename Der Anzeigename darf frei verändert werden. )} /> ( E-Mail Adresse Nur in Ausnahmefällen sollte die E-Mail Adresse geändert werden. )} /> ( Benutzerrolle Die Benutzerrolle bestimmt die Berechtigungen des Benutzers. )} />
Bist du dir sicher? Diese Aktion kann nicht rückgängig gemacht werden. Das Benutzerprofil und die damit verbundenen Daten werden unwiderruflich gelöscht. Abbrechen { toast({ description: "Benutzerprofil wird gelöscht..." }); deleteUser(user.id); toast({ description: "Benutzerprofil wurde gelöscht." }); }} > Ja, löschen
); } ================ File: src/app/admin/users/page.tsx ================ import { columns } from "@/app/admin/users/columns"; import { DataTable } from "@/app/admin/users/data-table"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { db } from "@/server/db"; import type { Metadata } from "next"; export const metadata: Metadata = { title: "Alle Benutzer", }; export default async function AdminPage() { const data = await db.query.users.findMany(); return ( Benutzerverwaltung Suche, Bearbeite und Lösche Benutzer ); } ================ File: src/app/api/job/[jobId]/remaining-time/route.ts ================ import { db } from "@/server/db"; import { printJobs } from "@/server/db/schema"; import { eq } from "drizzle-orm"; export const dynamic = "force-dynamic"; interface RemainingTimeRouteProps { params: { jobId: string; }; } export async function GET(request: Request, { params }: RemainingTimeRouteProps) { // Trying to fix build error in container... if (params.jobId === undefined) { return Response.json({}); } // Get the job details const jobDetails = await db.query.printJobs.findFirst({ where: eq(printJobs.id, params.jobId), }); // Check if the job exists if (!jobDetails) { return Response.json({ id: params.jobId, error: "Job not found", }); } // Calculate the remaining time const startAt = new Date(jobDetails.startAt).getTime(); const endAt = startAt + jobDetails.durationInMinutes * 60 * 1000; const remainingTime = Math.max(0, endAt - Date.now()); // Return the remaining time return Response.json({ id: params.jobId, remainingTime, }); } ================ File: src/app/api/printers/route.ts ================ import { getPrinters } from "@/server/actions/printers"; export const dynamic = "force-dynamic"; export async function GET() { const printers = await getPrinters(); return Response.json(printers); } ================ File: src/app/auth/login/callback/route.ts ================ import { lucia } from "@/server/auth"; import { type GitHubUserResult, github } from "@/server/auth/oauth"; import { db } from "@/server/db"; import { users } from "@/server/db/schema"; import { OAuth2RequestError } from "arctic"; import { eq } from "drizzle-orm"; import { generateIdFromEntropySize } from "lucia"; import { cookies } from "next/headers"; export const dynamic = "force-dynamic"; interface GithubEmailResponse { email: string; primary: boolean; verified: boolean; visibility: string; } export async function GET(request: Request): Promise { const url = new URL(request.url); const code = url.searchParams.get("code"); const state = url.searchParams.get("state"); const storedState = cookies().get("github_oauth_state")?.value ?? null; if (!code || !state || !storedState || state !== storedState) { return new Response( JSON.stringify({ status_text: "Something is wrong", data: { code, state, storedState }, }), { status: 400, }, ); } try { const tokens = await github.validateAuthorizationCode(code); const githubUserResponse = await fetch("https://git.i.mercedes-benz.com/api/v3/user", { headers: { Authorization: `Bearer ${tokens.accessToken}`, }, }); const githubUser: GitHubUserResult = await githubUserResponse.json(); // Sometimes email can be null in the user query. if (githubUser.email === null || githubUser.email === undefined) { const githubEmailResponse = await fetch("https://git.i.mercedes-benz.com/api/v3/user/emails", { headers: { Authorization: `Bearer ${tokens.accessToken}`, }, }); const githubUserEmail: GithubEmailResponse[] = await githubEmailResponse.json(); githubUser.email = githubUserEmail[0].email; } const existingUser = await db.query.users.findFirst({ where: eq(users.github_id, githubUser.id), }); if (existingUser) { const session = await lucia.createSession(existingUser.id, {}); const sessionCookie = lucia.createSessionCookie(session.id); cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); return new Response(null, { status: 302, headers: { Location: "/", }, }); } const userId = generateIdFromEntropySize(10); // 16 characters long await db.insert(users).values({ id: userId, github_id: githubUser.id, username: githubUser.login, displayName: githubUser.name, email: githubUser.email, }); const session = await lucia.createSession(userId, {}); const sessionCookie = lucia.createSessionCookie(session.id); cookies().set(sessionCookie.name, sessionCookie.value, { ...sessionCookie.attributes, secure: false, // Else cookie does not get set cause IT has not provided us an SSL certificate yet }); return new Response(null, { status: 302, headers: { Location: "/", }, }); } catch (e) { // the specific error message depends on the provider if (e instanceof OAuth2RequestError) { // invalid code return new Response( JSON.stringify({ status_text: "Invalid code", error: JSON.stringify(e), }), { status: 400, }, ); } return new Response(null, { status: 500, }); } } ================ File: src/app/auth/login/route.ts ================ import { github } from "@/server/auth/oauth"; import { generateState } from "arctic"; import { cookies } from "next/headers"; export const dynamic = "force-dynamic"; export async function GET(): Promise { const state = generateState(); const url = await github.createAuthorizationURL(state, { scopes: ["user"], }); const ONE_HOUR = 60 * 60; cookies().set("github_oauth_state", state, { path: "/", secure: false, //process.env.NODE_ENV === "production", -- can't be used until SSL certificate is provided by IT httpOnly: true, maxAge: ONE_HOUR, sameSite: "lax", }); return Response.redirect(url); } ================ File: src/app/globals.css ================ @tailwind base; @tailwind components; @tailwind utilities; @layer base { :root { --background: 0 0% 100%; --foreground: 222.2 84% 4.9%; --card: 0 0% 100%; --card-foreground: 222.2 84% 4.9%; --popover: 0 0% 100%; --popover-foreground: 222.2 84% 4.9%; --primary: 221.2 83.2% 53.3%; --primary-foreground: 210 40% 98%; --secondary: 210 40% 96.1%; --secondary-foreground: 222.2 47.4% 11.2%; --muted: 210 40% 96.1%; --muted-foreground: 215.4 16.3% 46.9%; --accent: 210 40% 96.1%; --accent-foreground: 222.2 47.4% 11.2%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 210 40% 98%; --border: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%; --ring: 221.2 83.2% 53.3%; --radius: 0.75rem; --chart-1: 12 76% 61%; --chart-2: 173 58% 39%; --chart-3: 197 37% 24%; --chart-4: 43 74% 66%; --chart-5: 27 87% 67%; } .dark { --background: 222.2 84% 4.9%; --foreground: 210 40% 98%; --card: 222.2 84% 4.9%; --card-foreground: 210 40% 98%; --popover: 222.2 84% 4.9%; --popover-foreground: 210 40% 98%; --primary: 217.2 91.2% 59.8%; --primary-foreground: 222.2 47.4% 11.2%; --secondary: 217.2 32.6% 17.5%; --secondary-foreground: 210 40% 98%; --muted: 217.2 32.6% 17.5%; --muted-foreground: 215 20.2% 65.1%; --accent: 217.2 32.6% 17.5%; --accent-foreground: 210 40% 98%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 210 40% 98%; --border: 217.2 32.6% 17.5%; --input: 217.2 32.6% 17.5%; --ring: 224.3 76.3% 48%; --chart-1: 220 70% 50%; --chart-2: 160 60% 45%; --chart-3: 30 80% 55%; --chart-4: 280 65% 60%; --chart-5: 340 75% 55%; } } ================ File: src/app/job/[jobId]/cancel-form.tsx ================ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { Button } from "@/components/ui/button"; import { Dialog, DialogClose, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { useToast } from "@/components/ui/use-toast"; import { abortPrintJob } from "@/server/actions/printJobs"; import { TriangleAlertIcon } from "lucide-react"; import { useState } from "react"; const formSchema = z.object({ abortReason: z .string() .min(1, { message: "Bitte gebe einen Grund für den Abbruch an.", }) .max(255, { message: "Der Grund darf maximal 255 Zeichen lang sein.", }), }); interface CancelFormProps { jobId: string; } export function CancelForm(props: CancelFormProps) { const { jobId } = props; const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { abortReason: "", }, }); const { toast } = useToast(); const [open, setOpen] = useState(false); async function onSubmit(values: z.infer) { toast({ description: "Druckauftrag wird abgebrochen...", }); try { const result = await abortPrintJob(jobId, values.abortReason); if (result?.error) { toast({ description: result.error, variant: "destructive", }); } setOpen(false); toast({ description: "Druckauftrag wurde abgebrochen.", }); } catch (error) { if (error instanceof Error) { toast({ description: error.message, variant: "destructive", }); } else { toast({ description: "Ein unbekannter Fehler ist aufgetreten.", variant: "destructive", }); } } } return ( Druckauftrag abbrechen? Du bist dabei, den Druckauftrag abzubrechen. Bitte beachte, dass ein abgebrochener Druckauftrag nicht wieder aufgenommen werden kann und der Drucker sich automatisch abschaltet.
( Grund für den Abbruch Bitte teile uns den Grund für den Abbruch des Druckauftrags mit. Wenn der Drucker eine Fehlermeldung anzeigt, gib bitte nur diese Fehlermeldung an. )} />
); } ================ File: src/app/job/[jobId]/edit-comments.tsx ================ "use client"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { useToast } from "@/components/ui/use-toast"; import { updatePrintComments } from "@/server/actions/printJobs"; import { useDebouncedCallback } from "use-debounce"; interface EditCommentsProps { defaultValue: string | null; jobId: string; disabled?: boolean; } export function EditComments(props: EditCommentsProps) { const { defaultValue, jobId, disabled } = props; const { toast } = useToast(); const debounced = useDebouncedCallback(async (value) => { try { const result = await updatePrintComments(jobId, value); if (result?.error) { toast({ description: result.error, variant: "destructive", }); } toast({ description: "Anmerkungen wurden gespeichert.", }); } catch (error) { if (error instanceof Error) { toast({ description: error.message, variant: "destructive", }); } else { toast({ description: "Ein unbekannter Fehler ist aufgetreten.", variant: "destructive", }); } } }, 1000); return (