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 ================ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg> ================ File: public/vercel.svg ================ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></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 ( <Card> <CardHeader> <CardTitle>Über MYP</CardTitle> <CardDescription> <i className="italic">MYP — Manage Your Printer</i> </CardDescription> </CardHeader> <CardContent className="gap-y-2 flex flex-col"> <p className="max-w-[80ch]"> <strong>MYP</strong> 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. </p> <p> © 2024{" "} <a href="https://linkedin.com/in/torben-haack" target="_blank" rel="noreferrer"> Torben Haack </a> </p> </CardContent> </Card> ); } ================ 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: <LayoutDashboardIcon className="w-4 h-4" />, }, { name: "Benutzer", path: "/admin/users", icon: <UsersIcon className="w-4 h-4" />, }, { name: "Drucker", path: "/admin/printers", icon: <PrinterIcon className="w-4 h-4" />, }, { name: "Druckaufträge", path: "/admin/jobs", icon: <FileIcon className="w-4 h-4" />, }, { name: "Einstellungen", path: "/admin/settings", icon: <WrenchIcon className="w-4 h-4" />, }, { name: "Über MYP", path: "/admin/about", icon: <HeartIcon className="w-4 h-4" />, }, ]; return ( <ul className="w-full"> {adminSites.map((site) => ( <li key={site.path}> <Link href={site.path} className={cn("flex items-center gap-2 p-2 rounded hover:bg-muted", { "font-semibold": pathname === site.path, })} > {site.icon} <span>{site.name}</span> </Link> </li> ))} </ul> ); } ================ 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 ( <Card> <CardHeader> <CardTitle>Abbruchgründe</CardTitle> <CardDescription>Häufigkeit der Abbruchgründe für Druckaufträge</CardDescription> </CardHeader> <CardContent> <ChartContainer config={chartConfig}> <BarChart accessibilityLayer data={chartData} margin={{ top: 20, }} > <CartesianGrid vertical={false} strokeDasharray="3 3" /> <XAxis dataKey="abortReason" tickLine={false} tickMargin={10} axisLine={false} tickFormatter={(value) => value} /> <YAxis tickFormatter={(value) => `${value}`} /> <ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} /> <Bar dataKey="count" fill="hsl(var(--chart-1))" radius={8}> <LabelList position="top" offset={12} className="fill-foreground" fontSize={12} formatter={(value: number) => `${value}`} /> </Bar> </BarChart> </ChartContainer> </CardContent> </Card> ); } ================ 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 ( <Card> <CardHeader> <CardTitle>Fehlerrate</CardTitle> <CardDescription>Fehlerrate der Drucker in Prozent</CardDescription> </CardHeader> <CardContent> <ChartContainer config={chartConfig}> <BarChart accessibilityLayer data={chartData} margin={{ top: 20, }} > <CartesianGrid vertical={false} strokeDasharray="3 3" /> <XAxis dataKey="printer" tickLine={false} tickMargin={10} axisLine={false} tickFormatter={(value) => value} /> <YAxis tickFormatter={(value) => `${value}%`} /> <ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} /> <Bar dataKey="errorRate" fill="hsl(var(--chart-1))" radius={8}> <LabelList position="top" offset={12} className="fill-foreground" fontSize={12} formatter={(value: number) => `${value}%`} /> </Bar> </BarChart> </ChartContainer> </CardContent> </Card> ); } ================ 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 ( <Card> <CardHeader> <CardTitle>Prognostizierte Nutzung pro Wochentag</CardTitle> </CardHeader> <CardContent> <ChartContainer className="h-64 w-full" config={chartConfig}> <AreaChart accessibilityLayer data={chartData} margin={{ left: 12, right: 12, top: 12 }}> <CartesianGrid vertical={true} /> <XAxis dataKey="day" type="category" tickLine={true} tickMargin={10} axisLine={false} /> <YAxis type="number" dataKey="usage" tickLine={false} tickMargin={10} axisLine={false} /> <ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} /> <Area dataKey="usage" type="step" fill="hsl(var(--chart-1))" fillOpacity={0.4} stroke="hsl(var(--chart-1))" /> </AreaChart> </ChartContainer> </CardContent> <CardFooter className="flex-col items-start gap-2 text-sm"> <div className="flex items-center gap-2 font-medium leading-none"> Zeigt die prognostizierte Nutzungszeit pro Wochentag in Minuten. </div> <div className="leading-none text-muted-foreground"> Besten Tage zur Wartung: {bestMaintenanceDays(forecastData)} </div> </CardFooter> </Card> ); } 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 ( <Card className="flex flex-col"> <CardHeader className="items-center pb-0"> <CardTitle>{data.name}</CardTitle> <CardDescription>Nutzung des ausgewählten Druckers</CardDescription> </CardHeader> <CardContent className="flex-1 pb-0"> <ChartContainer config={chartConfig} className="mx-auto aspect-square max-h-[250px]"> <PieChart> <ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} /> <Pie data={[dataWithColor, free]} dataKey="utilizationPercentage" nameKey="name" innerRadius={60} strokeWidth={5} > <Label content={({ viewBox }) => { if (viewBox && "cx" in viewBox && "cy" in viewBox) { return ( <text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle"> <tspan x={viewBox.cx} y={viewBox.cy} className="fill-foreground text-3xl font-bold"> {(totalUtilization * 100).toFixed(2)}% </tspan> <tspan x={viewBox.cx} y={(viewBox.cy || 0) + 24} className="fill-muted-foreground"> Gesamt-Nutzung </tspan> </text> ); } }} /> </Pie> </PieChart> </ChartContainer> </CardContent> <CardFooter className="flex-col gap-2 text-sm"> <div className="flex items-center gap-2 font-medium leading-none"> Übersicht der Nutzung <TrendingUp className="h-4 w-4" /> </div> <div className="leading-none text-muted-foreground">Aktuelle Auslastung des Druckers</div> </CardFooter> </Card> ); } ================ 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 ( <Card> <CardHeader> <CardTitle>Druckvolumen</CardTitle> <CardDescription>Vergleich: Heute, Diese Woche, Diesen Monat</CardDescription> </CardHeader> <CardContent> <ChartContainer className="h-64 w-full" config={chartConfig}> <BarChart accessibilityLayer data={chartData} margin={{ top: 20, }} > <CartesianGrid vertical={false} /> <XAxis dataKey="period" tickLine={false} tickMargin={10} axisLine={false} tickFormatter={(value) => value} /> <ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} /> <Bar dataKey="volume" fill="var(--color-volume)" radius={8}> <LabelList position="top" offset={12} className="fill-foreground" fontSize={12} /> </Bar> </BarChart> </ChartContainer> </CardContent> <CardFooter className="flex-col items-start gap-2 text-sm"> <div className="leading-none text-muted-foreground"> Zeigt das Druckvolumen für heute, diese Woche und diesen Monat </div> </CardFooter> </Card> ); } ================ 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 ( <Card> <CardHeader className="flex flex-row justify-between items-center"> <div> <CardTitle>Druckaufträge</CardTitle> <CardDescription>Alle Druckaufträge</CardDescription> </div> </CardHeader> <CardContent> <JobsTable columns={columns} data={allJobs} /> </CardContent> </Card> ); } ================ 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 ( <main className="flex flex-1 flex-col gap-4"> <div className="mx-auto grid w-full gap-2"> <h1 className="text-3xl font-semibold">Admin</h1> </div> <div className="mx-auto grid w-full items-start gap-4 md:gap-6 md:grid-cols-[180px_1fr] lg:grid-cols-[250px_1fr]"> <nav className="grid gap-4 text-sm"> <AdminSidebar /> </nav> <div>{children}</div> </div> </main> ); } ================ 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 ( <Card className="w-full"> <CardHeader> <CardTitle>Druckaufträge</CardTitle> <CardDescription>Zurzeit sind keine Druckaufträge verfügbar.</CardDescription> </CardHeader> <CardContent> <p>Aktualisieren Sie die Seite oder prüfen Sie später erneut, ob neue Druckaufträge verfügbar sind.</p> </CardContent> </Card> ); } const 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 ( <> <Tabs defaultValue={"@general"} className="flex flex-col gap-4 items-start"> <TabsList className="bg-neutral-100 w-full py-6"> <TabsTrigger value="@general">Allgemein</TabsTrigger> <TabsTrigger value="@capacity">Druckerauslastung</TabsTrigger> <TabsTrigger value="@report">Fehlerberichte</TabsTrigger> <TabsTrigger value="@forecasts">Prognosen</TabsTrigger> </TabsList> <TabsContent value="@general" className="w-full"> <div className="flex flex-col lg:grid lg:grid-cols-2 gap-4"> <div className="w-full col-span-2"> <DataCard title="Aktuelle Auslastung" value={`${Math.round((occupiedPrinters.length / (freePrinters.length + occupiedPrinters.length)) * 100)}%`} icon={"Percent"} /> </div> <DataCard title="Aktive Drucker" value={occupiedPrinters.length} icon={"Rotate3d"} /> <DataCard title="Freie Drucker" value={freePrinters.length} icon={"PowerOff"} /> </div> </TabsContent> <TabsContent value="@capacity" className="w-full"> <div className="flex flex-col lg:grid lg:grid-cols-2 gap-4"> <div className="w-full col-span-2"> <PrinterVolumeChart printerVolume={printerVolume} /> </div> {printerUtilization.map((data) => ( <PrinterUtilizationChart key={data.printerId} data={data} /> ))} </div> </TabsContent> <TabsContent value="@report" className="w-full"> <div className="flex flex-col lg:grid lg:grid-cols-2 gap-4"> <div className="w-full col-span-2"> <PrinterErrorRateChart printerErrorRate={printerErrorRate} /> </div> <div className="w-full col-span-2"> <AbortReasonCountChart abortReasonCount={printerAbortReasons} /> </div> </div> </TabsContent> <TabsContent value="@forecasts" className="w-full"> <div className="flex flex-col lg:grid lg:grid-cols-2 gap-4"> <div className="w-full col-span-2"> <ForecastPrinterUsageChart forecastData={printerForecast.map((usageMinutes, index) => ({ day: index, usageMinutes, }))} /> </div> </div> </TabsContent> </Tabs> </> ); } ================ 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<InferSelectModel<typeof printers>>[] = [ { accessorKey: "id", header: ({ column }) => { return ( <Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}> ID <ArrowUpDown className="ml-2 h-4 w-4" /> </Button> ); }, }, { 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 ( <Dialog open={open} onOpenChange={setOpen}> <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" className="h-8 w-8 p-0"> <span className="sr-only">Menu öffnen</span> <MoreHorizontal className="h-4 w-4" /> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuLabel>Aktionen</DropdownMenuLabel> <DropdownMenuItem asChild>ABC</DropdownMenuItem> <DropdownMenuItem> <EditPrinterDialogTrigger> <div className="flex items-center gap-2"> <PencilIcon className="w-4 h-4" /> <span>Bearbeiten</span> </div> </EditPrinterDialogTrigger> </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> <EditPrinterDialogContent setOpen={setOpen} printer={printer} /> </Dialog> ); }, }, ]; ================ 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<TData, TValue> { columns: ColumnDef<TData, TValue>[]; data: TData[]; } export function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) { const [sorting, setSorting] = useState<SortingState>([]); const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]); const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({}); const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), onSortingChange: setSorting, getSortedRowModel: getSortedRowModel(), onColumnFiltersChange: setColumnFilters, getFilteredRowModel: getFilteredRowModel(), onColumnVisibilityChange: setColumnVisibility, state: { sorting, columnFilters, columnVisibility, }, }); return ( <div> <div className="flex items-center py-4"> <Input placeholder="Name filtern..." value={(table.getColumn("name")?.getFilterValue() as string) ?? ""} onChange={(event) => table.getColumn("name")?.setFilterValue(event.target.value)} className="max-w-sm" /> <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="outline" className="ml-auto flex items-center gap-2"> <SlidersHorizontalIcon className="h-4 w-4" /> <span>Spalten</span> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> {table .getAllColumns() .filter((column) => column.getCanHide()) .map((column) => { return ( <DropdownMenuCheckboxItem key={column.id} className="capitalize" checked={column.getIsVisible()} onCheckedChange={(value) => column.toggleVisibility(!!value)} > {column.id} </DropdownMenuCheckboxItem> ); })} </DropdownMenuContent> </DropdownMenu> </div> <div className="rounded-md border"> <Table> <TableHeader> {table.getHeaderGroups().map((headerGroup) => ( <TableRow key={headerGroup.id}> {headerGroup.headers.map((header) => { return ( <TableHead key={header.id}> {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} </TableHead> ); })} </TableRow> ))} </TableHeader> <TableBody> {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( <TableRow key={row.id} data-state={row.getIsSelected() && "selected"}> {row.getVisibleCells().map((cell) => ( <TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell> ))} </TableRow> )) ) : ( <TableRow> <TableCell colSpan={columns.length} className="h-24 text-center"> Keine Ergebnisse gefunden. </TableCell> </TableRow> )} </TableBody> </Table> </div> <div className="flex items-center justify-end space-x-2 py-4"> <Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}> Zurück </Button> <Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}> Nächste Seite </Button> </div> </div> ); } ================ 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 ( <Dialog open={open} onOpenChange={setOpen}> <DialogTrigger asChild>{children}</DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle>Drucker erstellen</DialogTitle> </DialogHeader> <PrinterForm setOpen={setOpen} /> </DialogContent> </Dialog> ); } ================ 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 ( <AlertDialog> <AlertDialogTrigger asChild> <Button variant="destructive" className="gap-2 flex items-center"> <TrashIcon className="w-4 h-4" /> <span>Drucker löschen</span> </Button> </AlertDialogTrigger> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>Bist Du dir sicher?</AlertDialogTitle> <AlertDialogDescription> Diese Aktion kann nicht rückgängig gemacht werden. Der Drucker und die damit verbundenen Daten werden unwiderruflich gelöscht. </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel>Abbrechen</AlertDialogCancel> <AlertDialogAction className="bg-red-500" onClick={onSubmit}> Ja, löschen </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> ); } ================ 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 <DialogTrigger asChild>{children}</DialogTrigger>; } interface EditPrinterDialogContentProps { printer: InferResultType<"printers">; setOpen: (open: boolean) => void; } export function EditPrinterDialogContent(props: EditPrinterDialogContentProps) { const { printer, setOpen } = props; return ( <DialogContent> <DialogHeader> <DialogTitle>Drucker bearbeiten</DialogTitle> </DialogHeader> <PrinterForm setOpen={setOpen} printer={printer} /> </DialogContent> ); } ================ 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<z.infer<typeof formSchema>>({ 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<typeof formSchema>) { // 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 ( <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <FormField control={form.control} name="name" render={({ field }) => ( <FormItem> <FormLabel>Name</FormLabel> <FormControl> <Input placeholder="Anycubic Kobra 2 Pro" {...field} /> </FormControl> <FormDescription>Bitte gib einen eindeutigen Namen für den Drucker ein.</FormDescription> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="description" render={({ field }) => ( <FormItem> <FormLabel>Beschreibung</FormLabel> <FormControl> <Input placeholder="80x80x80 Druckfläche, langsam" {...field} /> </FormControl> <FormDescription>Füge eine kurze Beschreibung des Druckers hinzu.</FormDescription> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="status" render={({ field }) => ( <FormItem> <FormLabel>Status</FormLabel> <Select onValueChange={field.onChange} defaultValue={field.value.toString()}> <FormControl> <SelectTrigger> <SelectValue placeholder="Select a verified email to display" /> </SelectTrigger> </FormControl> <SelectContent> <SelectItem value={"0"}>Verfügbar</SelectItem> <SelectItem value={"1"}>Außer Betrieb</SelectItem> </SelectContent> </Select> <FormDescription>Wähle den aktuellen Status des Druckers.</FormDescription> <FormMessage /> </FormItem> )} /> <div className="flex justify-between items-center"> {printer && <DeletePrinterDialog setOpen={setOpen} printerId={printer?.id} />} {!printer && ( <DialogClose asChild> <Button variant="secondary" className="gap-2 flex items-center"> <XCircleIcon className="w-4 h-4" /> <span>Abbrechen</span> </Button> </DialogClose> )} <Button type="submit" className="gap-2 flex items-center"> <SaveIcon className="w-4 h-4" /> <span>Speichern</span> </Button> </div> </form> </Form> ); } ================ 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 ( <Card> <CardHeader className="flex flex-row justify-between items-center"> <div> <CardTitle>Druckerverwaltung</CardTitle> <CardDescription>Suche, Bearbeite, Lösche und Erstelle Drucker</CardDescription> </div> <CreatePrinterDialog> <Button variant={"default"} className="flex gap-2 items-center"> <PlusCircleIcon className="w-4 h-4" /> <span>Drucker erstellen</span> </Button> </CreatePrinterDialog> </CardHeader> <CardContent> <DataTable columns={columns} data={data} /> </CardContent> </Card> ); } ================ 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 ( <Card> <CardHeader> <CardTitle>Einstellungen</CardTitle> <CardDescription>Systemeinstellungen</CardDescription> </CardHeader> <CardContent> <div className="flex gap-8 items-center"> <p>Datenbank herunterladen</p> <Button variant="default" asChild> <Link href="/admin/settings/download" target="_blank"> Herunterladen </Link> </Button> </div> </CardContent> </Card> ); } ================ 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<InferSelectModel<typeof users>>[] = [ { accessorKey: "id", header: ({ column }) => { return ( <Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} > ID <ArrowUpDown className="ml-2 h-4 w-4" /> </Button> ); }, }, { 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 ( <EditUserDialogRoot> <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" className="h-8 w-8 p-0"> <span className="sr-only">Menu öffnen</span> <MoreHorizontal className="h-4 w-4" /> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuLabel>Aktionen</DropdownMenuLabel> <DropdownMenuItem asChild> <Link target="_blank" href={generateTeamsChatURL(user.email)} className="flex gap-2 items-center" > <MessageCircleIcon className="w-4 h-4" /> <span>Teams-Chat öffnen</span> </Link> </DropdownMenuItem> <DropdownMenuItem asChild> <Link target="_blank" href={generateEMailURL(user.email)} className="flex gap-2 items-center" > <MailIcon className="w-4 h-4" /> <span>E-Mail schicken</span> </Link> </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem> <EditUserDialogTrigger /> </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> <EditUserDialogContent user={user as User} /> </EditUserDialogRoot> ); }, }, ]; 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<TData, TValue> { columns: ColumnDef<TData, TValue>[]; data: TData[]; } export function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) { const [sorting, setSorting] = useState<SortingState>([]); const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]); const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({}); const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), onSortingChange: setSorting, getSortedRowModel: getSortedRowModel(), onColumnFiltersChange: setColumnFilters, getFilteredRowModel: getFilteredRowModel(), onColumnVisibilityChange: setColumnVisibility, state: { sorting, columnFilters, columnVisibility, }, }); return ( <div> <div className="flex items-center py-4"> <Input placeholder="E-Mails filtern..." value={(table.getColumn("email")?.getFilterValue() as string) ?? ""} onChange={(event) => table.getColumn("email")?.setFilterValue(event.target.value)} className="max-w-sm" /> <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="outline" className="ml-auto flex items-center gap-2"> <SlidersHorizontalIcon className="h-4 w-4" /> <span>Spalten</span> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> {table .getAllColumns() .filter((column) => column.getCanHide()) .map((column) => { return ( <DropdownMenuCheckboxItem key={column.id} className="capitalize" checked={column.getIsVisible()} onCheckedChange={(value) => column.toggleVisibility(!!value)} > {column.id} </DropdownMenuCheckboxItem> ); })} </DropdownMenuContent> </DropdownMenu> </div> <div className="rounded-md border"> <Table> <TableHeader> {table.getHeaderGroups().map((headerGroup) => ( <TableRow key={headerGroup.id}> {headerGroup.headers.map((header) => { return ( <TableHead key={header.id}> {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} </TableHead> ); })} </TableRow> ))} </TableHeader> <TableBody> {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( <TableRow key={row.id} data-state={row.getIsSelected() && "selected"}> {row.getVisibleCells().map((cell) => ( <TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell> ))} </TableRow> )) ) : ( <TableRow> <TableCell colSpan={columns.length} className="h-24 text-center"> Keine Ergebnisse gefunden. </TableCell> </TableRow> )} </TableBody> </Table> </div> <div className="flex items-center justify-end space-x-2 py-4"> <Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}> Zurück </Button> <Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}> Nächste Seite </Button> </div> </div> ); } ================ 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 <Dialog>{children}</Dialog>; } export function EditUserDialogTrigger() { return ( <DialogTrigger className="flex gap-2 items-center"> <PencilIcon className="w-4 h-4" /> <span>Benutzer bearbeiten</span> </DialogTrigger> ); } interface EditUserDialogContentProps { user: User; } export function EditUserDialogContent(props: EditUserDialogContentProps) { const { user } = props; if (!user) { return; } return ( <DialogContent> <DialogHeader> <DialogTitle>Benutzer bearbeiten</DialogTitle> <DialogDescription> <strong>Hinweis:</strong> In den seltensten Fällen sollten die Daten eines Benutzers geändert werden. Dies kann zu unerwarteten Problemen führen. </DialogDescription> </DialogHeader> <ProfileForm user={user} /> </DialogContent> ); } ================ 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<z.infer<typeof formSchema>>({ 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<typeof formSchema>) { toast({ description: "Benutzerprofil wird aktualisiert..." }); await updateUser(user.id, values); toast({ description: "Benutzerprofil wurde aktualisiert." }); } return ( <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <FormField control={form.control} name="username" render={({ field }) => ( <FormItem> <FormLabel>Benutzername</FormLabel> <FormControl> <Input placeholder="MAXMUS" {...field} /> </FormControl> <FormDescription> Nur in Ausnahmefällen sollte der Benutzername geändert werden. </FormDescription> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="displayName" render={({ field }) => ( <FormItem> <FormLabel>Anzeigename</FormLabel> <FormControl> <Input placeholder="Max Mustermann" {...field} /> </FormControl> <FormDescription> Der Anzeigename darf frei verändert werden. </FormDescription> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="email" render={({ field }) => ( <FormItem> <FormLabel>E-Mail Adresse</FormLabel> <FormControl> <Input placeholder="max.mustermann@mercedes-benz.com" {...field} /> </FormControl> <FormDescription> Nur in Ausnahmefällen sollte die E-Mail Adresse geändert werden. </FormDescription> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="role" render={({ field }) => ( <FormItem> <FormLabel>Benutzerrolle</FormLabel> <Select onValueChange={field.onChange} defaultValue={field.value}> <FormControl> <SelectTrigger> <SelectValue placeholder="Select a verified email to display" /> </SelectTrigger> </FormControl> <SelectContent> <SelectItem value="admin">Administrator</SelectItem> <SelectItem value="user">Benutzer</SelectItem> <SelectItem value="guest">Gast</SelectItem> </SelectContent> </Select> <FormDescription> Die Benutzerrolle bestimmt die Berechtigungen des Benutzers. </FormDescription> <FormMessage /> </FormItem> )} /> <div className="flex justify-between items-center"> <AlertDialog> <AlertDialogTrigger asChild> <Button type="submit" variant="destructive" className="gap-2 flex items-center" > <TrashIcon className="w-4 h-4" /> <span>Benutzer löschen</span> </Button> </AlertDialogTrigger> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>Bist du dir sicher?</AlertDialogTitle> <AlertDialogDescription> Diese Aktion kann nicht rückgängig gemacht werden. Das Benutzerprofil und die damit verbundenen Daten werden unwiderruflich gelöscht. </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel>Abbrechen</AlertDialogCancel> <AlertDialogAction className="bg-red-500" onClick={() => { toast({ description: "Benutzerprofil wird gelöscht..." }); deleteUser(user.id); toast({ description: "Benutzerprofil wurde gelöscht." }); }} > Ja, löschen </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> <DialogClose asChild> <Button type="submit" className="gap-2 flex items-center"> <SaveIcon className="w-4 h-4" /> <span>Speichern</span> </Button> </DialogClose> </div> </form> </Form> ); } ================ 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 ( <Card> <CardHeader> <CardTitle>Benutzerverwaltung</CardTitle> <CardDescription>Suche, Bearbeite und Lösche Benutzer</CardDescription> </CardHeader> <CardContent> <DataTable columns={columns} data={data} /> </CardContent> </Card> ); } ================ 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<Response> { 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<Response> { 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<z.infer<typeof formSchema>>({ resolver: zodResolver(formSchema), defaultValues: { abortReason: "", }, }); const { toast } = useToast(); const [open, setOpen] = useState(false); async function onSubmit(values: z.infer<typeof formSchema>) { 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 ( <Dialog open={open} onOpenChange={setOpen}> <DialogTrigger asChild> <Button variant={"ghost"} className="text-red-500 hover:text-red-600 flex-grow gap-2 items-center flex justify-start" > <TriangleAlertIcon className="w-4 h-4" /> <span>Druckauftrag abbrechen</span> </Button> </DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle>Druckauftrag abbrechen?</DialogTitle> <DialogDescription> Du bist dabei, den Druckauftrag abzubrechen. Bitte beachte, dass ein abgebrochener Druckauftrag nicht wieder aufgenommen werden kann und der Drucker sich automatisch abschaltet. </DialogDescription> </DialogHeader> <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8"> <FormField control={form.control} name="abortReason" render={({ field }) => ( <FormItem> <FormLabel>Grund für den Abbruch</FormLabel> <FormControl> <Input {...field} /> </FormControl> <FormDescription> Bitte teile uns den Grund für den Abbruch des Druckauftrags mit. Wenn der Drucker eine Fehlermeldung anzeigt, gib bitte nur diese Fehlermeldung an. </FormDescription> <FormMessage /> </FormItem> )} /> <div className="flex flex-row justify-between"> <DialogClose asChild> <Button variant={"secondary"}>Nein</Button> </DialogClose> <Button variant={"destructive"} type="submit"> Ja, Druck abbrechen </Button> </div> </form> </Form> </DialogContent> </Dialog> ); } ================ 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 ( <div className="flex flex-col gap-2"> <Label>Anmerkungen</Label> <Textarea placeholder="Anmerkungen" disabled={disabled} defaultValue={defaultValue ?? ""} onChange={(e) => debounced(e.target.value)} /> </div> ); } ================ File: src/app/job/[jobId]/extend-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, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { useToast } from "@/components/ui/use-toast"; import { extendPrintJob } from "@/server/actions/printJobs"; import { CircleFadingPlusIcon } from "lucide-react"; import { useState } from "react"; import { useSWRConfig } from "swr"; const formSchema = z.object({ minutes: z.coerce.number().int().max(59, { message: "Die Minuten müssen zwischen 0 und 59 liegen.", }), hours: z.coerce.number().int().max(24, { message: "Die Stunden müssen zwischen 0 und 24 liegen.", }), }); interface ExtendFormProps { jobId: string; } export function ExtendForm(props: ExtendFormProps) { const { jobId } = props; const form = useForm<z.infer<typeof formSchema>>({ resolver: zodResolver(formSchema), defaultValues: { minutes: 0, hours: 0, }, }); const { toast } = useToast(); const [open, setOpen] = useState(false); const { mutate } = useSWRConfig(); async function onSubmit(values: z.infer<typeof formSchema>) { toast({ description: "Druckauftrag wird verlängert...", }); try { const result = await extendPrintJob(jobId, values.minutes, values.hours); if (result?.error) { toast({ description: result.error, variant: "destructive", }); } setOpen(false); form.reset(); mutate(`/api/job/${jobId}/remaining-time`); // Refresh the countdown toast({ description: "Druckauftrag wurde verlängert.", }); } catch (error) { if (error instanceof Error) { toast({ description: error.message, variant: "destructive", }); } else { toast({ description: "Ein unbekannter Fehler ist aufgetreten.", variant: "destructive", }); } } } return ( <Dialog open={open} onOpenChange={setOpen}> <DialogTrigger asChild> <Button variant={"ghost"} className="flex-grow gap-2 items-center flex justify-start"> <CircleFadingPlusIcon className="w-4 h-4" /> <span>Druckauftrag verlängern</span> </Button> </DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle>Druckauftrag verlängern</DialogTitle> <DialogDescription> Braucht dein Druck mehr Zeit als erwartet? Füge weitere Stunden oder Minuten zur Druckzeit hinzu. </DialogDescription> </DialogHeader> <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8"> <p className="text-sm px-4 py-2 text-yellow-700 bg-yellow-500/20 rounded-md"> <span className="font-medium">Wichtig:</span> Bitte verlängere die Druckzeit nur, wenn es sich um denselben Druck handelt. Wenn es ein anderer Druck ist, brich bitte den aktuellen Druckauftrag ab und starte einen neuen. </p> <div className="flex flex-row gap-2"> <FormField control={form.control} name="hours" render={({ field }) => ( <FormItem className="w-1/2"> <FormLabel>Stunden</FormLabel> <FormControl> <Input placeholder="0" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="minutes" render={({ field }) => ( <FormItem className="w-1/2"> <FormLabel>Minuten</FormLabel> <FormControl> <Input placeholder="0" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> </div> <div className="flex flex-row justify-between"> <DialogClose asChild> <Button variant={"secondary"}>Abbrechen</Button> </DialogClose> <Button variant={"default"} type="submit"> Verlängern </Button> </div> </form> </Form> </DialogContent> </Dialog> ); } ================ File: src/app/job/[jobId]/finish-form.tsx ================ "use client"; import { AlertDialogHeader } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { useToast } from "@/components/ui/use-toast"; import { earlyFinishPrintJob } from "@/server/actions/printJobs"; import { CircleCheckBigIcon } from "lucide-react"; interface FinishFormProps { jobId: string; } export function FinishForm(props: FinishFormProps) { const { jobId } = props; const { toast } = useToast(); async function onClick() { toast({ description: "Druckauftrag wird abgeschlossen...", }); try { const result = await earlyFinishPrintJob(jobId); if (result?.error) { toast({ description: result.error, variant: "destructive", }); } toast({ description: "Druckauftrag wurde abgeschlossen.", }); } catch (error) { if (error instanceof Error) { toast({ description: error.message, variant: "destructive", }); } else { toast({ description: "Ein unbekannter Fehler ist aufgetreten.", variant: "destructive", }); } } } return ( <Dialog> <DialogTrigger asChild> <Button variant={"ghost"} className="flex-grow gap-2 items-center flex justify-start"> <CircleCheckBigIcon className="w-4 h-4" /> <span>Druckauftrag abschließen</span> </Button> </DialogTrigger> <DialogContent> <AlertDialogHeader> <DialogTitle>Druckauftrag abschließen?</DialogTitle> <DialogDescription> Du bist dabei, den Druckauftrag als abgeschlossen zu markieren. Dies führt dazu, dass der Drucker automatisch herunterfährt. </DialogDescription> </AlertDialogHeader> <div className="flex flex-col gap-4"> <p className="text-sm text-red-500 font-medium bg-red-500/20 px-4 py-2 rounded-md"> Bitte bestätige nur, wenn der Druckauftrag tatsächlich erfolgreich abgeschlossen wurde. </p> <div className="flex flex-row justify-between"> <DialogClose asChild> <Button variant={"secondary"}>Abbrechen</Button> </DialogClose> <DialogClose asChild onClick={onClick}> <Button variant={"default"}>Bestätigen</Button> </DialogClose> </div> </div> </DialogContent> </Dialog> ); } ================ File: src/app/job/[jobId]/page.tsx ================ import { CancelForm } from "@/app/job/[jobId]/cancel-form"; import { EditComments } from "@/app/job/[jobId]/edit-comments"; import { ExtendForm } from "@/app/job/[jobId]/extend-form"; import { FinishForm } from "@/app/job/[jobId]/finish-form"; import { Countdown } from "@/components/printer-card/countdown"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { validateRequest } from "@/server/auth"; import { UserRole } from "@/server/auth/permissions"; import { db } from "@/server/db"; import { printJobs } from "@/server/db/schema"; import { eq } from "drizzle-orm"; import { ArchiveIcon } from "lucide-react"; import type { Metadata } from "next"; export const metadata: Metadata = { title: "Druckauftrag", }; interface JobDetailsPageProps { params: { jobId: string; }; } export default async function JobDetailsPage(props: JobDetailsPageProps) { const { jobId } = props.params; const { user } = await validateRequest(); const jobDetails = await db.query.printJobs.findFirst({ where: eq(printJobs.id, jobId), with: { user: true, printer: true, }, }); if (!jobDetails) { return <div>Druckauftrag wurde nicht gefunden.</div>; } const jobIsOnGoing = new Date(jobDetails.startAt).getTime() + jobDetails.durationInMinutes * 60 * 1000 > Date.now(); const jobIsAborted = jobDetails.aborted; const userOwnsJob = jobDetails.userId === user?.id; const userIsAdmin = user?.role === UserRole.ADMIN; const userMayEditJob = userOwnsJob || userIsAdmin; return ( <div className="flex flex-col gap-4"> <h1 className="text-3xl font-semibold"> Druckauftrag vom{" "} {new Date(jobDetails.startAt).toLocaleString("de-DE", { dateStyle: "medium", timeStyle: "medium", })} </h1> {!jobIsOnGoing || jobIsAborted ? ( <Alert className="bg-yellow-200 border-yellow-500 text-yellow-700 shadow-sm"> <ArchiveIcon className="h-4 w-4" /> <AlertTitle>Hinweis</AlertTitle> <AlertDescription> Dieser Druckauftrag wurde bereits abgeschlossen und kann nicht mehr bearbeitet werden. </AlertDescription> </Alert> ) : null} <div className="flex flex-col lg:flex-row gap-4"> <Card className="w-full"> <CardContent className="p-4 flex flex-col gap-4"> <div className="flex flex-row justify-between"> <div> <h2 className="font-semibold">Ansprechpartner</h2> <p className="text-sm">{jobDetails.user.displayName}</p> <p className="text-sm">{jobDetails.user.email}</p> </div> <div className="text-right"> {jobIsAborted && ( <> <h2 className="font-semibold text-red-500">Abbruchsgrund</h2> <p className="text-sm text-red-500">{jobDetails.abortReason}</p> </> )} {jobIsOnGoing && ( <> <h2 className="font-semibold">Verbleibende Zeit</h2> <p className="text-sm"> <Countdown jobId={jobDetails.id} /> </p> </> )} </div> </div> <EditComments defaultValue={jobDetails.comments} jobId={jobDetails.id} disabled={!userMayEditJob || jobIsAborted || !jobIsOnGoing} /> </CardContent> </Card> {userMayEditJob && jobIsOnGoing && ( <Card className="w-full lg:w-96 ml-auto"> <CardHeader> <CardTitle>Aktionen</CardTitle> </CardHeader> <CardContent> <div className="flex w-full flex-col -ml-4 -mt-2"> <FinishForm jobId={jobDetails.id} /> <ExtendForm jobId={jobDetails.id} /> <CancelForm jobId={jobDetails.id} /> </div> </CardContent> </Card> )} </div> </div> ); } /** * durationInMinutes: integer("durationInMinutes").notNull(), comments: text("comments"), aborted: integer("aborted", { mode: "boolean" }).notNull().default(false), abortReason: text("abortReason"), */ ================ File: src/app/layout.tsx ================ import { Header } from "@/components/header"; import { Toaster } from "@/components/ui/toaster"; import type { Metadata } from "next"; import "@/app/globals.css"; export const metadata: Metadata = { title: { default: "MYP", template: "%s | MYP", }, description: "Generated by create next app", }; interface RootLayoutProps { children: React.ReactNode; } export const dynamic = "force-dynamic"; export default function RootLayout(props: RootLayoutProps) { const { children } = props; return ( <html lang="de" suppressHydrationWarning> <head /> <body className={"min-h-dvh bg-neutral-200 font-sans antialiased"}> <Header /> <main className="flex-grow max-w-screen-2xl w-full mx-auto flex flex-col p-8 gap-4 text-foreground"> {children} </main> <Toaster /> </body> </html> ); } ================ File: src/app/my/jobs/columns.tsx ================ "use client"; import type { InferResultType } from "@/utils/drizzle"; import type { ColumnDef } from "@tanstack/react-table"; import { BadgeCheckIcon, EyeIcon, HourglassIcon, MoreHorizontal, OctagonXIcon, ShareIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useToast } from "@/components/ui/use-toast"; import type { printers } from "@/server/db/schema"; import type { InferSelectModel } from "drizzle-orm"; import Link from "next/link"; export const columns: ColumnDef< InferResultType< "printJobs", { printer: true; } > >[] = [ { accessorKey: "printer", header: "Drucker", cell: ({ row }) => { const printer: InferSelectModel<typeof printers> = row.getValue("printer"); return printer.name; }, }, { accessorKey: "startAt", header: "Startzeitpunkt", cell: ({ row }) => { const startAt = new Date(row.original.startAt); return `${startAt.toLocaleDateString("de-DE", { dateStyle: "medium", })} ${startAt.toLocaleTimeString("de-DE")}`; }, }, { accessorKey: "durationInMinutes", header: "Dauer (Minuten)", }, { accessorKey: "comments", header: "Anmerkungen", cell: ({ row }) => { const comments = row.original.comments; if (comments) { return <span className="text-sm">{comments.slice(0, 50)}</span>; } return <span className="text-muted-foreground text-sm">Keine Anmerkungen</span>; }, }, { accessorKey: "status", header: "Status", cell: ({ row }) => { const aborted = row.original.aborted; if (aborted) { return ( <div className="flex items-center gap-2"> <OctagonXIcon className="w-4 h-4 text-red-500" /> <span className="text-red-600">Abgebrochen</span> </div> ); } const startAt = new Date(row.original.startAt).getTime(); const endAt = startAt + row.original.durationInMinutes * 60 * 1000; if (Date.now() < endAt) { return ( <div className="flex items-center gap-2"> <HourglassIcon className="w-4 h-4 text-yellow-500" /> <span className="text-yellow-600">Läuft...</span> </div> ); } return ( <div className="flex items-center gap-2"> <BadgeCheckIcon className="w-4 h-4 text-green-500" /> <span className="text-green-600">Abgeschlossen</span> </div> ); }, }, { id: "actions", cell: ({ row }) => { const job = row.original; const { toast } = useToast(); return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" className="h-8 w-8 p-0"> <span className="sr-only">Menu öffnen</span> <MoreHorizontal className="h-4 w-4" /> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuLabel>Aktionen</DropdownMenuLabel> <DropdownMenuItem className="flex items-center gap-2" onClick={() => { const baseUrl = new URL(window.location.href); baseUrl.pathname = `/job/${job.id}`; navigator.clipboard.writeText(baseUrl.toString()); toast({ description: "URL zum Druckauftrag in die Zwischenablage kopiert.", }); }} > <ShareIcon className="w-4 h-4" /> <span>Teilen</span> </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem> <Link href={`/job/${job.id}`} className="flex items-center gap-2"> <EyeIcon className="w-4 h-4" /> <span>Details anzeigen</span> </Link> </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ); }, }, ]; ================ File: src/app/my/jobs/data-table.tsx ================ "use client"; import { type ColumnDef, flexRender, getCoreRowModel, getPaginationRowModel, useReactTable, } from "@tanstack/react-table"; import { Button } from "@/components/ui/button"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; interface DataTableProps<TData, TValue> { columns: ColumnDef<TData, TValue>[]; data: TData[]; } export function JobsTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) { const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel(), getPaginationRowModel: getPaginationRowModel(), }); return ( <div> <div className="rounded-md border"> <Table> <TableHeader> {table.getHeaderGroups().map((headerGroup) => ( <TableRow key={headerGroup.id}> {headerGroup.headers.map((header) => { return ( <TableHead key={header.id}> {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} </TableHead> ); })} </TableRow> ))} </TableHeader> <TableBody> {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( <TableRow key={row.id} data-state={row.getIsSelected() && "selected"}> {row.getVisibleCells().map((cell) => ( <TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell> ))} </TableRow> )) ) : ( <TableRow> <TableCell colSpan={columns.length} className="h-24 text-center"> Keine Ergebnisse gefunden </TableCell> </TableRow> )} </TableBody> </Table> </div> <div className="flex items-center justify-end space-x-2 py-4 select-none"> <Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}> Vorherige Seite </Button> <Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}> Nächste Seite </Button> </div> </div> ); } ================ File: src/app/my/profile/page.tsx ================ import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { validateRequest } from "@/server/auth"; import { UserRole, translateUserRole } from "@/server/auth/permissions"; import type { Metadata } from "next"; import { redirect } from "next/navigation"; export const metadata: Metadata = { title: "Dein Profil", }; export default async function ProfilePage() { const { user } = await validateRequest(); if (!user) { redirect("/"); } const badgeVariant = { [UserRole.ADMIN]: "destructive" as const, [UserRole.USER]: "default" as const, [UserRole.GUEST]: "secondary" as const, }; return ( <Card> <CardHeader className="flex flex-row justify-between items-center"> <div> <CardTitle>{user?.displayName}</CardTitle> <CardDescription> {user?.username} — {user?.email} </CardDescription> </div> <Badge variant={badgeVariant[user?.role]}>{translateUserRole(user?.role)}</Badge> </CardHeader> <CardContent> <p> Deine Daten wurden vom <abbr>GitHub Enterprise Server</abbr> importiert und können hier nur angezeigt werden. </p> <p> Solltest Du Änderungen oder eine Löschung deiner Daten von unserem Dienst beantragen wollen, so wende dich bitte an einen Administrator. </p> </CardContent> </Card> ); } ================ File: src/app/not-found.tsx ================ import Link from "next/link"; export default function NotFound() { return ( <div> <h2>Nicht gefunden</h2> <p>Die angefragte Seite konnte nicht gefunden werden.</p> <Link href="/">Zurück zur Startseite</Link> </div> ); } ================ File: src/app/page.tsx ================ import { columns } from "@/app/my/jobs/columns"; import { JobsTable } from "@/app/my/jobs/data-table"; import { DynamicPrinterCards } from "@/components/dynamic-printer-cards"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { validateRequest } from "@/server/auth"; import { db } from "@/server/db"; import { printJobs } from "@/server/db/schema"; import { desc, eq } from "drizzle-orm"; import { BoxesIcon, NewspaperIcon } from "lucide-react"; import type { Metadata } from "next"; export const metadata: Metadata = { title: "Dashboard | MYP", }; export default async function HomePage() { const { user } = await validateRequest(); const userIsLoggedIn = Boolean(user); const printers = await db.query.printers.findMany({ with: { printJobs: { limit: 1, orderBy: (printJobs, { desc }) => [desc(printJobs.startAt)], }, }, }); // biome-ignore lint/suspicious/noExplicitAny: temp. fix for jobs let jobs: any[] = []; if (userIsLoggedIn) { jobs = await db.query.printJobs.findMany({ // biome-ignore lint/style/noNonNullAssertion: User exists if userIsLoggedIn is true where: eq(printJobs.userId, user!.id), orderBy: [desc(printJobs.startAt)], with: { printer: true, }, }); } return ( <> {/* NEEDS TO BE FIXED FOR A NEW / EMPTY USER {isLoggedIn && <PersonalizedCards />} */} <Card> <CardHeader> <CardTitle className="flex flex-row items-center gap-x-1"> <BoxesIcon className="w-5 h-5" /> <span className="text-lg">Druckerbelegung</span> </CardTitle> </CardHeader> <CardContent className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"> <DynamicPrinterCards user={user} /> </CardContent> </Card> {userIsLoggedIn && ( <Card> <CardHeader> <CardTitle className="flex flex-row items-center gap-x-1"> <NewspaperIcon className="w-5 h-5" /> <span className="text-lg">Druckaufträge</span> </CardTitle> </CardHeader> <CardContent> <JobsTable columns={columns} data={jobs} /> </CardContent> </Card> )} </> ); } ================ File: src/app/printer/[printerId]/reserve/form.tsx ================ "use client"; 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 { Textarea } from "@/components/ui/textarea"; import { useToast } from "@/components/ui/use-toast"; import { createPrintJob } from "@/server/actions/printJobs"; import { zodResolver } from "@hookform/resolvers/zod"; import { CalendarPlusIcon, XCircleIcon } from "lucide-react"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { useForm } from "react-hook-form"; import { If, Then } from "react-if"; import { z } from "zod"; export const formSchema = z.object({ hours: z.coerce.number().int().min(0).max(96, { message: "Die Stunden müssen zwischen 0 und 96 liegen.", }), minutes: z.coerce.number().int().min(0).max(59, { message: "Die Minuten müssen zwischen 0 und 59 liegen.", }), comments: z.string().optional(), }); interface PrinterReserveFormProps { userId: string; printerId: string; isDialog?: boolean; } export function PrinterReserveForm(props: PrinterReserveFormProps) { const { userId, printerId, isDialog } = props; const router = useRouter(); const { toast } = useToast(); const [isLocked, setLocked] = useState(false); const form = useForm<z.infer<typeof formSchema>>({ resolver: zodResolver(formSchema), defaultValues: { hours: 0, minutes: 0, comments: "", }, }); async function onSubmit(values: z.infer<typeof formSchema>) { if (!isLocked) { setLocked(true); setTimeout(() => { setLocked(false); }, 1000 * 5); } else { toast({ description: "Bitte warte ein wenig, bevor du eine weitere Reservierung tätigst...", variant: "default", }); return; } if (values.hours === 0 && values.minutes === 0) { form.setError("hours", { message: "", }); form.setError("minutes", { message: "Die Dauer des Druckauftrags muss mindestens 1 Minute betragen.", }); return; } try { const jobId = await createPrintJob({ durationInMinutes: values.hours * 60 + values.minutes, comments: values.comments, userId: userId, printerId: printerId, }); if (typeof jobId === "object") { toast({ description: jobId.error, variant: "destructive", }); } router.push(`/job/${jobId}`); } catch (error) { if (error instanceof Error) { toast({ variant: "destructive", description: error.message }); } else { toast({ variant: "destructive", description: "Ein unbekannter Fehler ist aufgetreten.", }); } return; } toast({ description: "Druckauftrag wurde erfolgreich erstellt." }); } return ( <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8"> <div className="flex flex-row gap-2"> <FormField control={form.control} name="hours" render={({ field }) => ( <FormItem className="w-1/2"> <FormLabel>Stunden</FormLabel> <FormControl> <Input placeholder="0" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="minutes" render={({ field }) => ( <FormItem className="w-1/2"> <FormLabel>Minuten</FormLabel> <FormControl> <Input placeholder="0" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> </div> <FormField control={form.control} name="comments" render={({ field }) => ( <FormItem> <FormLabel>Anmerkungen</FormLabel> <FormControl> <Textarea placeholder="" {...field} /> </FormControl> <FormDescription> In dieses Feld kannst du Anmerkungen zu deinem Druckauftrag hinzufügen. Sie können beispielsweise Informationen über das Druckmaterial, die Druckqualität oder die Farbe enthalten. </FormDescription> <FormMessage /> </FormItem> )} /> <div className="flex justify-between items-center"> <If condition={isDialog}> <Then> <DialogClose asChild> <Button variant={"secondary"} className="gap-2 flex items-center"> <XCircleIcon className="w-4 h-4" /> <span>Abbrechen</span> </Button> </DialogClose> </Then> </If> <Button type="submit" className="gap-2 flex items-center" disabled={isLocked}> <CalendarPlusIcon className="w-4 h-4" /> <span>Reservieren</span> </Button> </div> </form> </Form> ); } ================ File: src/app/printer/[printerId]/reserve/page.tsx ================ import { PrinterReserveForm } from "@/app/printer/[printerId]/reserve/form"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { validateRequest } from "@/server/auth"; import { redirect } from "next/navigation"; import type { Metadata } from "next"; export const metadata: Metadata = { title: "Drucker reservieren", }; interface PrinterReservePageProps { params: { printerId: string; }; } export default async function PrinterReservePage(props: PrinterReservePageProps) { const { user } = await validateRequest(); const { printerId } = props.params; if (!user) { return redirect("/"); } return ( <Card> <CardHeader> <CardTitle>Drucker reservieren</CardTitle> </CardHeader> <CardContent> <PrinterReserveForm userId={user?.id} printerId={printerId} /> </CardContent> </Card> ); } ================ File: src/components/data-card.tsx ================ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { icons } from "lucide-react"; interface GenericIconProps { name: keyof typeof icons; className: string; } function GenericIcon(props: GenericIconProps) { const { name, className } = props; const LucideIcon = icons[name]; return <LucideIcon className={className} />; } interface DataCardProps { title: string; description?: string; value: string | number; icon: keyof typeof icons; } export function DataCard(props: DataCardProps) { const { title, description, value, icon } = props; return ( <Card className="w-full"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardTitle className="text-sm font-medium">{title}</CardTitle> <GenericIcon name={icon} className="h-4 w-4 text-muted-foreground" /> </CardHeader> <CardContent> <div className="text-2xl font-bold">{value}</div> <p className="text-xs text-muted-foreground"> </p> </CardContent> </Card> ); } ================ File: src/components/dynamic-printer-cards.tsx ================ "use client"; import { PrinterCard } from "@/components/printer-card"; import { Skeleton } from "@/components/ui/skeleton"; import type { InferResultType } from "@/utils/drizzle"; import { fetcher } from "@/utils/fetch"; import type { RegisteredDatabaseUserAttributes } from "lucia"; import useSWR from "swr"; interface DynamicPrinterCardsProps { user: RegisteredDatabaseUserAttributes | null; } export function DynamicPrinterCards(props: DynamicPrinterCardsProps) { const { user } = props; const { data, error, isLoading } = useSWR("/api/printers", fetcher, { refreshInterval: 1000 * 15, }); if (error) { return <div>Ein Fehler ist aufgetreten.</div>; } if (isLoading) { return ( <> {new Array(6).fill(null).map((_, index) => ( // biome-ignore lint/suspicious/noArrayIndexKey: <explanation> <Skeleton key={index} className="w-auto h-36 animate-pulse" /> ))} </> ); } return data.map((printer: InferResultType<"printers", { printJobs: true }>) => { return <PrinterCard key={printer.id} printer={printer} user={user} />; }); } ================ File: src/components/header/index.tsx ================ import { HeaderNavigation } from "@/components/header/navigation"; import { LoginButton } from "@/components/login-button"; import { LogoutButton } from "@/components/logout-button"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { validateRequest } from "@/server/auth"; import { UserRole, hasRole } from "@/server/auth/permissions"; import { StickerIcon, UserIcon, WrenchIcon } from "lucide-react"; import Link from "next/link"; import { If, Then } from "react-if"; function getInitials(name: string | undefined) { if (!name) return ""; const parts = name.split(" "); if (parts.length === 1) return parts[0].slice(0, 2); return parts[0].charAt(0) + parts[parts.length - 1].charAt(0); } export async function Header() { const { user } = await validateRequest(); return ( <header className="h-16 bg-neutral-900 border-b-4 border-neutral-600 text-white select-none shadow-md"> <div className="px-8 h-full max-w-screen-2xl w-full mx-auto flex items-center justify-between"> <div className="flex flex-row items-center gap-8"> <Link href="/" className="flex items-center gap-2"> <StickerIcon size={20} /> <h1 className="text-lg font-mono">MYP</h1> </Link> <HeaderNavigation /> </div> {user != null && ( <DropdownMenu> <DropdownMenuTrigger> <Avatar> <AvatarFallback className="bg-neutral-700"> <span className="font-semibold">{getInitials(user?.displayName)}</span> </AvatarFallback> </Avatar> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-56"> <DropdownMenuGroup> <DropdownMenuLabel>Mein Account</DropdownMenuLabel> <DropdownMenuSeparator /> <DropdownMenuItem asChild> <Link href="/my/profile/" className="flex items-center gap-2"> <UserIcon className="w-4 h-4" /> <span>Mein Profil</span> </Link> </DropdownMenuItem> <If condition={hasRole(user, UserRole.ADMIN)}> <Then> <DropdownMenuItem asChild> <Link href="/admin/" className="flex items-center gap-2"> <WrenchIcon className="w-4 h-4" /> <span>Adminbereich</span> </Link> </DropdownMenuItem> </Then> </If> <DropdownMenuSeparator /> <DropdownMenuItem> <LogoutButton /> </DropdownMenuItem> </DropdownMenuGroup> </DropdownMenuContent> </DropdownMenu> )} {user == null && <LoginButton />} </div> </header> ); } ================ File: src/components/header/navigation.tsx ================ "use client"; import { cn } from "@/utils/styles"; import { ContactRoundIcon, LayersIcon } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; interface Site { name: string; icon: JSX.Element; path: string; } export function HeaderNavigation() { const pathname = usePathname(); const sites: Site[] = [ { name: "Dashboard", icon: <LayersIcon className="w-4 h-4" />, path: "/", }, /* { name: "Meine Druckaufträge", path: "/my/jobs", }, */ { name: "Mein Profil", icon: <ContactRoundIcon className="w-4 h-4" />, path: "/my/profile", }, ]; return ( <nav className="font-medium text-sm flex items-center gap-4 flex-row"> {sites.map((site) => ( <Link key={site.path} href={site.path} className={cn("transition-colors hover:text-neutral-50 flex items-center gap-x-1", { "text-primary-foreground font-semibold": pathname === site.path, "text-neutral-500": pathname !== site.path, })} > {site.icon} <span>{site.name}</span> </Link> ))} </nav> ); } ================ File: src/components/login-button.tsx ================ "use client"; import { Button } from "@/components/ui/button"; import { useToast } from "@/components/ui/use-toast"; import { ScanFaceIcon } from "lucide-react"; import Link from "next/link"; import { useState } from "react"; export function LoginButton() { const { toast } = useToast(); const [isLocked, setLocked] = useState(false); function onClick() { if (!isLocked) { toast({ description: "Du wirst angemeldet...", }); // Prevent multiple clicks because of login delay... setLocked(true); setTimeout(() => { setLocked(false); }, 1000 * 5); } toast({ description: "Bitte warte einen Moment...", }); } return ( <Button onClick={onClick} variant={"ghost"} className="gap-2 flex items-center" asChild disabled={isLocked}> <Link href="/auth/login"> <ScanFaceIcon className="w-4 h-4" /> <span>Anmelden</span> </Link> </Button> ); } ================ File: src/components/logout-button.tsx ================ "use client"; import { useToast } from "@/components/ui/use-toast"; import { logout } from "@/server/actions/authentication/logout"; import { LogOutIcon } from "lucide-react"; import Link from "next/link"; export function LogoutButton() { const { toast } = useToast(); function onClick() { toast({ description: "Du wirst nun abgemeldet...", }); logout(); } return ( <Link href="/" onClick={onClick} className="flex items-center gap-2"> <LogOutIcon className="w-4 h-4" /> <span>Abmelden</span> </Link> ); } ================ File: src/components/personalized-cards.tsx ================ import { DataCard } from "@/components/data-card"; import { validateRequest } from "@/server/auth"; import { db } from "@/server/db"; import { eq } from "drizzle-orm"; export default async function PersonalizedCards() { const { user } = await validateRequest(); if (!user) { return null; } const allPrintJobs = await db.query.printJobs.findMany({ with: { printer: true, }, where: (printJobs) => eq(printJobs.userId, user.id), }); const totalPrintingMinutes = allPrintJobs .filter((job) => !job.aborted) .reduce((acc, curr) => acc + curr.durationInMinutes, 0); const averagePrintingHoursPerWeek = totalPrintingMinutes / 60 / 52; const mostUsedPrinters = {printer:{name:'-'}}; /*allPrintJobs .map((job) => job.printer.name) .reduce((acc, curr) => { acc[curr] = (acc[curr] || 0) + 1; return acc; }, {});*/ const mostUsedPrinter = 0; /*Object.keys(mostUsedPrinters).reduce((a, b) => mostUsedPrinters[a] > mostUsedPrinters[b] ? a : b, );*/ const printerSuccessRate = (allPrintJobs.filter((job) => job.aborted).length / allPrintJobs.length) * 100; const mostUsedWeekday = {printer:{name:'-'}}; /*allPrintJobs .map((job) => job.startAt.getDay()) .reduce((acc, curr) => { acc[curr] = (acc[curr] || 0) + 1; return acc; }, {});*/ const mostUsedWeekdayIndex = ""; /*Object.keys(mostUsedWeekday).reduce((a, b) => mostUsedWeekday[a] > mostUsedWeekday[b] ? a : b, );*/ const mostUsedWeekdayName = new Intl.DateTimeFormat("de-DE", { weekday: "long", }).format(new Date(0, 0, Number.parseInt(mostUsedWeekdayIndex))); return ( <div className="flex flex-col lg:flex-row gap-4"> <DataCard icon="Clock10" title="Druckstunden" description="insgesamt" value={`${(totalPrintingMinutes / 60).toFixed(2)}h`} /> <DataCard icon="Calendar" title="Aktivster Tag" description="(nach Anzahl der Aufträgen)" value={mostUsedWeekdayName} /> <DataCard icon="Heart" title="Lieblingsdrucker" description="" value={mostUsedPrinter} /> <DataCard icon="Check" title="Druckerfolgsquote" description="" value={`${printerSuccessRate.toFixed(2)}%`} /> </div> ); } ================ File: src/components/printer-availability-badge.tsx ================ import { Badge } from "@/components/ui/badge"; import { PrinterStatus, translatePrinterStatus } from "@/utils/printers"; import { cn } from "@/utils/styles"; interface PrinterAvailabilityBadgeProps { status: PrinterStatus; } export function PrinterAvailabilityBadge(props: PrinterAvailabilityBadgeProps) { const { status } = props; return ( <Badge className={cn("pointer-events-none select-none", { "bg-green-500 hover:bg-green-500 animate-pulse": status === PrinterStatus.IDLE, "bg-red-500 hover:bg-red-500 opacity-50": status === PrinterStatus.OUT_OF_ORDER, "bg-orange-500 hover:bg-orange-500": status === PrinterStatus.RESERVED, })} > {translatePrinterStatus(status)} </Badge> ); } ================ File: src/components/printer-card/countdown.tsx ================ "use client"; import { revalidate } from "@/server/actions/timer"; import { fetcher } from "@/utils/fetch"; import useSWR from "swr"; interface CountdownProps { jobId: string; } export function Countdown(props: CountdownProps) { const { jobId } = props; const { data, error, isLoading } = useSWR(`/api/job/${jobId}/remaining-time`, fetcher, { refreshInterval: 1000 * 30, }); if (error) { return <span className="text-red-500">Ein Fehler ist aufgetreten.</span>; } if (isLoading) { return <>...</>; } const days = Math.floor(data.remainingTime / (1000 * 60 * 60 * 24)); const hours = Math.floor((data.remainingTime % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); const minutes = Math.floor((data.remainingTime % (1000 * 60 * 60)) / (1000 * 60)); const seconds = Math.floor((data.remainingTime % (1000 * 60)) / 1000); if (days <= 0 && hours <= 0 && minutes <= 0 && seconds <= 0) { revalidate(); } return ( <span className="tabular-nums" suppressHydrationWarning> {days > 0 && <>{`${days}`.padStart(2, "0")}d </>} {hours === 0 && minutes === 0 ? ( <>{`${seconds}`.padStart(2, "0")}s</> ) : ( <> {`${hours}`.padStart(2, "0")}h {`${minutes}`.padStart(2, "0")}min </> )} </span> ); } ================ File: src/components/printer-card/index.tsx ================ "use client"; import { PrinterReserveForm } from "@/app/printer/[printerId]/reserve/form"; import { Countdown } from "@/components/printer-card/countdown"; import { AlertDialogHeader } from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { UserRole, hasRole } from "@/server/auth/permissions"; import type { InferResultType } from "@/utils/drizzle"; import { PrinterStatus, derivePrinterStatus, translatePrinterStatus } from "@/utils/printers"; import { cn } from "@/utils/styles"; import type { RegisteredDatabaseUserAttributes } from "lucia"; import { CalendarPlusIcon, ChevronRightIcon } from "lucide-react"; import Link from "next/link"; import { Else, If, Then } from "react-if"; interface PrinterCardProps { printer: InferResultType<"printers", { printJobs: true }>; user?: RegisteredDatabaseUserAttributes | null; } export function PrinterCard(props: PrinterCardProps) { const { printer, user } = props; const status = derivePrinterStatus(printer); const userIsLoggedIn = Boolean(user); return ( <Card className={cn("w-auto h-36", { "opacity-50 select-none cursor-not-allowed": status === PrinterStatus.OUT_OF_ORDER, })} > <CardHeader> <div className="flex flex-row items-start justify-between"> <div> <CardTitle>{printer.name}</CardTitle> <CardDescription>{printer.description}</CardDescription> </div> <Badge className={cn({ "bg-green-500 hover:bg-green-400": status === PrinterStatus.IDLE, "bg-red-500 hover:bg-red-500": status === PrinterStatus.OUT_OF_ORDER, "bg-yellow-500 hover:bg-yellow-400": status === PrinterStatus.RESERVED, })} > {status === PrinterStatus.RESERVED && <Countdown jobId={printer.printJobs[0].id} />} <If condition={status === PrinterStatus.RESERVED}> <Else>{translatePrinterStatus(status)}</Else> </If> </Badge> </div> </CardHeader> <CardContent className="flex justify-end"> <If condition={status === PrinterStatus.IDLE && userIsLoggedIn && !hasRole(user, UserRole.GUEST)}> <Then> <Dialog> <DialogTrigger asChild> <Button variant={"default"} className="flex items-center gap-2 w-full"> <CalendarPlusIcon className="w-4 h-4" /> <span>Reservieren</span> </Button> </DialogTrigger> <DialogContent> <AlertDialogHeader> <DialogTitle>{printer.name} reservieren</DialogTitle> <DialogDescription>Gebe die geschätzte Druckdauer an.</DialogDescription> </AlertDialogHeader> <PrinterReserveForm isDialog={true} printerId={printer.id} userId={user?.id ?? ""} /> </DialogContent> </Dialog> </Then> </If> {status === PrinterStatus.RESERVED && ( <Button asChild variant={"secondary"}> <Link href={`/job/${printer.printJobs[0].id}`} className="flex items-center gap-2 w-full"> <ChevronRightIcon className="w-4 h-4" /> <span>Details anzeigen</span> </Link> </Button> )} </CardContent> </Card> ); } ================ File: src/components/ui/alert-dialog.tsx ================ "use client" import * as React from "react" import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" import { cn } from "@/utils/styles" import { buttonVariants } from "@/components/ui/button" const AlertDialog = AlertDialogPrimitive.Root const AlertDialogTrigger = AlertDialogPrimitive.Trigger const AlertDialogPortal = AlertDialogPrimitive.Portal const AlertDialogOverlay = React.forwardRef< React.ElementRef<typeof AlertDialogPrimitive.Overlay>, React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay> >(({ className, ...props }, ref) => ( <AlertDialogPrimitive.Overlay className={cn( "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", className )} {...props} ref={ref} /> )) AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName const AlertDialogContent = React.forwardRef< React.ElementRef<typeof AlertDialogPrimitive.Content>, React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content> >(({ className, ...props }, ref) => ( <AlertDialogPortal> <AlertDialogOverlay /> <AlertDialogPrimitive.Content ref={ref} className={cn( "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", className )} {...props} /> </AlertDialogPortal> )) AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( <div className={cn( "flex flex-col space-y-2 text-center sm:text-left", className )} {...props} /> ) AlertDialogHeader.displayName = "AlertDialogHeader" const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( <div className={cn( "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className )} {...props} /> ) AlertDialogFooter.displayName = "AlertDialogFooter" const AlertDialogTitle = React.forwardRef< React.ElementRef<typeof AlertDialogPrimitive.Title>, React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title> >(({ className, ...props }, ref) => ( <AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} /> )) AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName const AlertDialogDescription = React.forwardRef< React.ElementRef<typeof AlertDialogPrimitive.Description>, React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description> >(({ className, ...props }, ref) => ( <AlertDialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} /> )) AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName const AlertDialogAction = React.forwardRef< React.ElementRef<typeof AlertDialogPrimitive.Action>, React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action> >(({ className, ...props }, ref) => ( <AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} /> )) AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName const AlertDialogCancel = React.forwardRef< React.ElementRef<typeof AlertDialogPrimitive.Cancel>, React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel> >(({ className, ...props }, ref) => ( <AlertDialogPrimitive.Cancel ref={ref} className={cn( buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className )} {...props} /> )) AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName export { AlertDialog, AlertDialogPortal, AlertDialogOverlay, AlertDialogTrigger, AlertDialogContent, AlertDialogHeader, AlertDialogFooter, AlertDialogTitle, AlertDialogDescription, AlertDialogAction, AlertDialogCancel, } ================ File: src/components/ui/alert.tsx ================ import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/utils/styles" const alertVariants = cva( "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", { variants: { variant: { default: "bg-background text-foreground", destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", }, }, defaultVariants: { variant: "default", }, } ) const Alert = React.forwardRef< HTMLDivElement, React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants> >(({ className, variant, ...props }, ref) => ( <div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} /> )) Alert.displayName = "Alert" const AlertTitle = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement> >(({ className, ...props }, ref) => ( <h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} /> )) AlertTitle.displayName = "AlertTitle" const AlertDescription = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement> >(({ className, ...props }, ref) => ( <div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} /> )) AlertDescription.displayName = "AlertDescription" export { Alert, AlertTitle, AlertDescription } ================ File: src/components/ui/avatar.tsx ================ "use client" import * as React from "react" import * as AvatarPrimitive from "@radix-ui/react-avatar" import { cn } from "@/utils/styles" const Avatar = React.forwardRef< React.ElementRef<typeof AvatarPrimitive.Root>, React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> >(({ className, ...props }, ref) => ( <AvatarPrimitive.Root ref={ref} className={cn( "relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className )} {...props} /> )) Avatar.displayName = AvatarPrimitive.Root.displayName const AvatarImage = React.forwardRef< React.ElementRef<typeof AvatarPrimitive.Image>, React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> >(({ className, ...props }, ref) => ( <AvatarPrimitive.Image ref={ref} className={cn("aspect-square h-full w-full", className)} {...props} /> )) AvatarImage.displayName = AvatarPrimitive.Image.displayName const AvatarFallback = React.forwardRef< React.ElementRef<typeof AvatarPrimitive.Fallback>, React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback> >(({ className, ...props }, ref) => ( <AvatarPrimitive.Fallback ref={ref} className={cn( "flex h-full w-full items-center justify-center rounded-full bg-muted", className )} {...props} /> )) AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName export { Avatar, AvatarImage, AvatarFallback } ================ File: src/components/ui/badge.tsx ================ import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/utils/styles" const badgeVariants = cva( "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", { variants: { variant: { default: "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", destructive: "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", outline: "text-foreground", }, }, defaultVariants: { variant: "default", }, } ) export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {} function Badge({ className, variant, ...props }: BadgeProps) { return ( <div className={cn(badgeVariants({ variant }), className)} {...props} /> ) } export { Badge, badgeVariants } ================ File: src/components/ui/breadcrumb.tsx ================ import * as React from "react" import { ChevronRightIcon, DotsHorizontalIcon } from "@radix-ui/react-icons" import { Slot } from "@radix-ui/react-slot" import { cn } from "@/utils/styles" const Breadcrumb = React.forwardRef< HTMLElement, React.ComponentPropsWithoutRef<"nav"> & { separator?: React.ReactNode } >(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />) Breadcrumb.displayName = "Breadcrumb" const BreadcrumbList = React.forwardRef< HTMLOListElement, React.ComponentPropsWithoutRef<"ol"> >(({ className, ...props }, ref) => ( <ol ref={ref} className={cn( "flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5", className )} {...props} /> )) BreadcrumbList.displayName = "BreadcrumbList" const BreadcrumbItem = React.forwardRef< HTMLLIElement, React.ComponentPropsWithoutRef<"li"> >(({ className, ...props }, ref) => ( <li ref={ref} className={cn("inline-flex items-center gap-1.5", className)} {...props} /> )) BreadcrumbItem.displayName = "BreadcrumbItem" const BreadcrumbLink = React.forwardRef< HTMLAnchorElement, React.ComponentPropsWithoutRef<"a"> & { asChild?: boolean } >(({ asChild, className, ...props }, ref) => { const Comp = asChild ? Slot : "a" return ( <Comp ref={ref} className={cn("transition-colors hover:text-foreground", className)} {...props} /> ) }) BreadcrumbLink.displayName = "BreadcrumbLink" const BreadcrumbPage = React.forwardRef< HTMLSpanElement, React.ComponentPropsWithoutRef<"span"> >(({ className, ...props }, ref) => ( <span ref={ref} role="link" aria-disabled="true" aria-current="page" className={cn("font-normal text-foreground", className)} {...props} /> )) BreadcrumbPage.displayName = "BreadcrumbPage" const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => ( <li role="presentation" aria-hidden="true" className={cn("[&>svg]:size-3.5", className)} {...props} > {children ?? <ChevronRightIcon />} </li> ) BreadcrumbSeparator.displayName = "BreadcrumbSeparator" const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => ( <span role="presentation" aria-hidden="true" className={cn("flex h-9 w-9 items-center justify-center", className)} {...props} > <DotsHorizontalIcon className="h-4 w-4" /> <span className="sr-only">More</span> </span> ) BreadcrumbEllipsis.displayName = "BreadcrumbElipssis" export { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator, BreadcrumbEllipsis, } ================ File: src/components/ui/button.tsx ================ import * as React from "react" import { Slot } from "@radix-ui/react-slot" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/utils/styles" const buttonVariants = cva( "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", { variants: { variant: { default: "bg-primary text-primary-foreground shadow hover:bg-primary/90", destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline", }, size: { default: "h-9 px-4 py-2", sm: "h-8 rounded-md px-3 text-xs", lg: "h-10 rounded-md px-8", icon: "h-9 w-9", }, }, defaultVariants: { variant: "default", size: "default", }, } ) export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> { asChild?: boolean } const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( ({ className, variant, size, asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : "button" return ( <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} /> ) } ) Button.displayName = "Button" export { Button, buttonVariants } ================ File: src/components/ui/card.tsx ================ import * as React from "react" import { cn } from "@/utils/styles" const Card = React.forwardRef< HTMLDivElement, React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => ( <div ref={ref} className={cn( "rounded-xl border bg-card text-card-foreground shadow", className )} {...props} /> )) Card.displayName = "Card" const CardHeader = React.forwardRef< HTMLDivElement, React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => ( <div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} /> )) CardHeader.displayName = "CardHeader" const CardTitle = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement> >(({ className, ...props }, ref) => ( <h3 ref={ref} className={cn("font-semibold leading-none tracking-tight", className)} {...props} /> )) CardTitle.displayName = "CardTitle" const CardDescription = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement> >(({ className, ...props }, ref) => ( <p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} /> )) CardDescription.displayName = "CardDescription" const CardContent = React.forwardRef< HTMLDivElement, React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => ( <div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> )) CardContent.displayName = "CardContent" const CardFooter = React.forwardRef< HTMLDivElement, React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => ( <div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} /> )) CardFooter.displayName = "CardFooter" export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } ================ File: src/components/ui/chart.tsx ================ "use client" import * as React from "react" import * as RechartsPrimitive from "recharts" import { NameType, Payload, ValueType, } from "recharts/types/component/DefaultTooltipContent" import { cn } from "@/utils/styles" // Format: { THEME_NAME: CSS_SELECTOR } const THEMES = { light: "", dark: ".dark" } as const export type ChartConfig = { [k in string]: { label?: React.ReactNode icon?: React.ComponentType } & ( | { color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> } ) } type ChartContextProps = { config: ChartConfig } const ChartContext = React.createContext<ChartContextProps | null>(null) function useChart() { const context = React.useContext(ChartContext) if (!context) { throw new Error("useChart must be used within a <ChartContainer />") } return context } const ChartContainer = React.forwardRef< HTMLDivElement, React.ComponentProps<"div"> & { config: ChartConfig children: React.ComponentProps< typeof RechartsPrimitive.ResponsiveContainer >["children"] } >(({ id, className, children, config, ...props }, ref) => { const uniqueId = React.useId() const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` return ( <ChartContext.Provider value={{ config }}> <div data-chart={chartId} ref={ref} className={cn( "flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none", className )} {...props} > <ChartStyle id={chartId} config={config} /> <RechartsPrimitive.ResponsiveContainer> {children} </RechartsPrimitive.ResponsiveContainer> </div> </ChartContext.Provider> ) }) ChartContainer.displayName = "Chart" const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { const colorConfig = Object.entries(config).filter( ([_, config]) => config.theme || config.color ) if (!colorConfig.length) { return null } return ( <style dangerouslySetInnerHTML={{ __html: Object.entries(THEMES) .map( ([theme, prefix]) => ` ${prefix} [data-chart=${id}] { ${colorConfig .map(([key, itemConfig]) => { const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color return color ? ` --color-${key}: ${color};` : null }) .join("\n")} } ` ) .join("\n"), }} /> ) } const ChartTooltip = RechartsPrimitive.Tooltip const ChartTooltipContent = React.forwardRef< HTMLDivElement, React.ComponentProps<typeof RechartsPrimitive.Tooltip> & React.ComponentProps<"div"> & { hideLabel?: boolean hideIndicator?: boolean indicator?: "line" | "dot" | "dashed" nameKey?: string labelKey?: string } >( ( { active, payload, className, indicator = "dot", hideLabel = false, hideIndicator = false, label, labelFormatter, labelClassName, formatter, color, nameKey, labelKey, }, ref ) => { const { config } = useChart() const tooltipLabel = React.useMemo(() => { if (hideLabel || !payload?.length) { return null } const [item] = payload const key = `${labelKey || item.dataKey || item.name || "value"}` const itemConfig = getPayloadConfigFromPayload(config, item, key) const value = !labelKey && typeof label === "string" ? config[label as keyof typeof config]?.label || label : itemConfig?.label if (labelFormatter) { return ( <div className={cn("font-medium", labelClassName)}> {labelFormatter(value, payload)} </div> ) } if (!value) { return null } return <div className={cn("font-medium", labelClassName)}>{value}</div> }, [ label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey, ]) if (!active || !payload?.length) { return null } const nestLabel = payload.length === 1 && indicator !== "dot" return ( <div ref={ref} className={cn( "grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl", className )} > {!nestLabel ? tooltipLabel : null} <div className="grid gap-1.5"> {payload.map((item, index) => { const key = `${nameKey || item.name || item.dataKey || "value"}` const itemConfig = getPayloadConfigFromPayload(config, item, key) const indicatorColor = color || item.payload.fill || item.color return ( <div key={item.dataKey} className={cn( "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground", indicator === "dot" && "items-center" )} > {formatter && item?.value !== undefined && item.name ? ( formatter(item.value, item.name, item, index, item.payload) ) : ( <> {itemConfig?.icon ? ( <itemConfig.icon /> ) : ( !hideIndicator && ( <div className={cn( "shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", { "h-2.5 w-2.5": indicator === "dot", "w-1": indicator === "line", "w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed", "my-0.5": nestLabel && indicator === "dashed", } )} style={ { "--color-bg": indicatorColor, "--color-border": indicatorColor, } as React.CSSProperties } /> ) )} <div className={cn( "flex flex-1 justify-between leading-none", nestLabel ? "items-end" : "items-center" )} > <div className="grid gap-1.5"> {nestLabel ? tooltipLabel : null} <span className="text-muted-foreground"> {itemConfig?.label || item.name} </span> </div> {item.value && ( <span className="font-mono font-medium tabular-nums text-foreground"> {item.value.toLocaleString()} </span> )} </div> </> )} </div> ) })} </div> </div> ) } ) ChartTooltipContent.displayName = "ChartTooltip" const ChartLegend = RechartsPrimitive.Legend const ChartLegendContent = React.forwardRef< HTMLDivElement, React.ComponentProps<"div"> & Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & { hideIcon?: boolean nameKey?: string } >( ( { className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref ) => { const { config } = useChart() if (!payload?.length) { return null } return ( <div ref={ref} className={cn( "flex items-center justify-center gap-4", verticalAlign === "top" ? "pb-3" : "pt-3", className )} > {payload.map((item) => { const key = `${nameKey || item.dataKey || "value"}` const itemConfig = getPayloadConfigFromPayload(config, item, key) return ( <div key={item.value} className={cn( "flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground" )} > {itemConfig?.icon && !hideIcon ? ( <itemConfig.icon /> ) : ( <div className="h-2 w-2 shrink-0 rounded-[2px]" style={{ backgroundColor: item.color, }} /> )} {itemConfig?.label} </div> ) })} </div> ) } ) ChartLegendContent.displayName = "ChartLegend" // Helper to extract item config from a payload. function getPayloadConfigFromPayload( config: ChartConfig, payload: unknown, key: string ) { if (typeof payload !== "object" || payload === null) { return undefined } const payloadPayload = "payload" in payload && typeof payload.payload === "object" && payload.payload !== null ? payload.payload : undefined let configLabelKey: string = key if ( key in payload && typeof payload[key as keyof typeof payload] === "string" ) { configLabelKey = payload[key as keyof typeof payload] as string } else if ( payloadPayload && key in payloadPayload && typeof payloadPayload[key as keyof typeof payloadPayload] === "string" ) { configLabelKey = payloadPayload[ key as keyof typeof payloadPayload ] as string } return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config] } export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle, } ================ File: src/components/ui/dialog.tsx ================ "use client" import * as React from "react" import * as DialogPrimitive from "@radix-ui/react-dialog" import { Cross2Icon } from "@radix-ui/react-icons" import { cn } from "@/utils/styles" const Dialog = DialogPrimitive.Root const DialogTrigger = DialogPrimitive.Trigger const DialogPortal = DialogPrimitive.Portal const DialogClose = DialogPrimitive.Close const DialogOverlay = React.forwardRef< React.ElementRef<typeof DialogPrimitive.Overlay>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> >(({ className, ...props }, ref) => ( <DialogPrimitive.Overlay ref={ref} className={cn( "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", className )} {...props} /> )) DialogOverlay.displayName = DialogPrimitive.Overlay.displayName const DialogContent = React.forwardRef< React.ElementRef<typeof DialogPrimitive.Content>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> >(({ className, children, ...props }, ref) => ( <DialogPortal> <DialogOverlay /> <DialogPrimitive.Content ref={ref} className={cn( "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", className )} {...props} > {children} <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> <Cross2Icon className="h-4 w-4" /> <span className="sr-only">Close</span> </DialogPrimitive.Close> </DialogPrimitive.Content> </DialogPortal> )) DialogContent.displayName = DialogPrimitive.Content.displayName const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( <div className={cn( "flex flex-col space-y-1.5 text-center sm:text-left", className )} {...props} /> ) DialogHeader.displayName = "DialogHeader" const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( <div className={cn( "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className )} {...props} /> ) DialogFooter.displayName = "DialogFooter" const DialogTitle = React.forwardRef< React.ElementRef<typeof DialogPrimitive.Title>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> >(({ className, ...props }, ref) => ( <DialogPrimitive.Title ref={ref} className={cn( "text-lg font-semibold leading-none tracking-tight", className )} {...props} /> )) DialogTitle.displayName = DialogPrimitive.Title.displayName const DialogDescription = React.forwardRef< React.ElementRef<typeof DialogPrimitive.Description>, React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> >(({ className, ...props }, ref) => ( <DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} /> )) DialogDescription.displayName = DialogPrimitive.Description.displayName export { Dialog, DialogPortal, DialogOverlay, DialogTrigger, DialogClose, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription, } ================ File: src/components/ui/dropdown-menu.tsx ================ "use client" import * as React from "react" import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" import { CheckIcon, ChevronRightIcon, DotFilledIcon, } from "@radix-ui/react-icons" import { cn } from "@/utils/styles" const DropdownMenu = DropdownMenuPrimitive.Root const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger const DropdownMenuGroup = DropdownMenuPrimitive.Group const DropdownMenuPortal = DropdownMenuPrimitive.Portal const DropdownMenuSub = DropdownMenuPrimitive.Sub const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup const DropdownMenuSubTrigger = React.forwardRef< React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean } >(({ className, inset, children, ...props }, ref) => ( <DropdownMenuPrimitive.SubTrigger ref={ref} className={cn( "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent", inset && "pl-8", className )} {...props} > {children} <ChevronRightIcon className="ml-auto h-4 w-4" /> </DropdownMenuPrimitive.SubTrigger> )) DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName const DropdownMenuSubContent = React.forwardRef< React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> >(({ className, ...props }, ref) => ( <DropdownMenuPrimitive.SubContent ref={ref} className={cn( "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className )} {...props} /> )) DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName const DropdownMenuContent = React.forwardRef< React.ElementRef<typeof DropdownMenuPrimitive.Content>, React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> >(({ className, sideOffset = 4, ...props }, ref) => ( <DropdownMenuPrimitive.Portal> <DropdownMenuPrimitive.Content ref={ref} sideOffset={sideOffset} className={cn( "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className )} {...props} /> </DropdownMenuPrimitive.Portal> )) DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName const DropdownMenuItem = React.forwardRef< React.ElementRef<typeof DropdownMenuPrimitive.Item>, React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean } >(({ className, inset, ...props }, ref) => ( <DropdownMenuPrimitive.Item ref={ref} className={cn( "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", inset && "pl-8", className )} {...props} /> )) DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName const DropdownMenuCheckboxItem = React.forwardRef< React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> >(({ className, children, checked, ...props }, ref) => ( <DropdownMenuPrimitive.CheckboxItem ref={ref} className={cn( "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className )} checked={checked} {...props} > <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> <DropdownMenuPrimitive.ItemIndicator> <CheckIcon className="h-4 w-4" /> </DropdownMenuPrimitive.ItemIndicator> </span> {children} </DropdownMenuPrimitive.CheckboxItem> )) DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName const DropdownMenuRadioItem = React.forwardRef< React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> >(({ className, children, ...props }, ref) => ( <DropdownMenuPrimitive.RadioItem ref={ref} className={cn( "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className )} {...props} > <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> <DropdownMenuPrimitive.ItemIndicator> <DotFilledIcon className="h-4 w-4 fill-current" /> </DropdownMenuPrimitive.ItemIndicator> </span> {children} </DropdownMenuPrimitive.RadioItem> )) DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName const DropdownMenuLabel = React.forwardRef< React.ElementRef<typeof DropdownMenuPrimitive.Label>, React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { inset?: boolean } >(({ className, inset, ...props }, ref) => ( <DropdownMenuPrimitive.Label ref={ref} className={cn( "px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className )} {...props} /> )) DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName const DropdownMenuSeparator = React.forwardRef< React.ElementRef<typeof DropdownMenuPrimitive.Separator>, React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> >(({ className, ...props }, ref) => ( <DropdownMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} /> )) DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => { return ( <span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} /> ) } DropdownMenuShortcut.displayName = "DropdownMenuShortcut" export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuGroup, DropdownMenuPortal, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuRadioGroup, } ================ File: src/components/ui/form.tsx ================ import * as React from "react" import * as LabelPrimitive from "@radix-ui/react-label" import { Slot } from "@radix-ui/react-slot" import { Controller, ControllerProps, FieldPath, FieldValues, FormProvider, useFormContext, } from "react-hook-form" import { cn } from "@/utils/styles" import { Label } from "@/components/ui/label" const Form = FormProvider type FormFieldContextValue< TFieldValues extends FieldValues = FieldValues, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> > = { name: TName } const FormFieldContext = React.createContext<FormFieldContextValue>( {} as FormFieldContextValue ) const FormField = < TFieldValues extends FieldValues = FieldValues, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> >({ ...props }: ControllerProps<TFieldValues, TName>) => { return ( <FormFieldContext.Provider value={{ name: props.name }}> <Controller {...props} /> </FormFieldContext.Provider> ) } const useFormField = () => { const fieldContext = React.useContext(FormFieldContext) const itemContext = React.useContext(FormItemContext) const { getFieldState, formState } = useFormContext() const fieldState = getFieldState(fieldContext.name, formState) if (!fieldContext) { throw new Error("useFormField should be used within <FormField>") } const { id } = itemContext return { id, name: fieldContext.name, formItemId: `${id}-form-item`, formDescriptionId: `${id}-form-item-description`, formMessageId: `${id}-form-item-message`, ...fieldState, } } type FormItemContextValue = { id: string } const FormItemContext = React.createContext<FormItemContextValue>( {} as FormItemContextValue ) const FormItem = React.forwardRef< HTMLDivElement, React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => { const id = React.useId() return ( <FormItemContext.Provider value={{ id }}> <div ref={ref} className={cn("space-y-2", className)} {...props} /> </FormItemContext.Provider> ) }) FormItem.displayName = "FormItem" const FormLabel = React.forwardRef< React.ElementRef<typeof LabelPrimitive.Root>, React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> >(({ className, ...props }, ref) => { const { error, formItemId } = useFormField() return ( <Label ref={ref} className={cn(error && "text-destructive", className)} htmlFor={formItemId} {...props} /> ) }) FormLabel.displayName = "FormLabel" const FormControl = React.forwardRef< React.ElementRef<typeof Slot>, React.ComponentPropsWithoutRef<typeof Slot> >(({ ...props }, ref) => { const { error, formItemId, formDescriptionId, formMessageId } = useFormField() return ( <Slot ref={ref} id={formItemId} aria-describedby={ !error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}` } aria-invalid={!!error} {...props} /> ) }) FormControl.displayName = "FormControl" const FormDescription = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement> >(({ className, ...props }, ref) => { const { formDescriptionId } = useFormField() return ( <p ref={ref} id={formDescriptionId} className={cn("text-[0.8rem] text-muted-foreground", className)} {...props} /> ) }) FormDescription.displayName = "FormDescription" const FormMessage = React.forwardRef< HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement> >(({ className, children, ...props }, ref) => { const { error, formMessageId } = useFormField() const body = error ? String(error?.message) : children if (!body) { return null } return ( <p ref={ref} id={formMessageId} className={cn("text-[0.8rem] font-medium text-destructive", className)} {...props} > {body} </p> ) }) FormMessage.displayName = "FormMessage" export { useFormField, Form, FormItem, FormLabel, FormControl, FormDescription, FormMessage, FormField, } ================ File: src/components/ui/hover-card.tsx ================ "use client" import * as React from "react" import * as HoverCardPrimitive from "@radix-ui/react-hover-card" import { cn } from "@/utils/styles" const HoverCard = HoverCardPrimitive.Root const HoverCardTrigger = HoverCardPrimitive.Trigger const HoverCardContent = React.forwardRef< React.ElementRef<typeof HoverCardPrimitive.Content>, React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content> >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( <HoverCardPrimitive.Content ref={ref} align={align} sideOffset={sideOffset} className={cn( "z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className )} {...props} /> )) HoverCardContent.displayName = HoverCardPrimitive.Content.displayName export { HoverCard, HoverCardTrigger, HoverCardContent } ================ File: src/components/ui/input.tsx ================ import * as React from "react" import { cn } from "@/utils/styles" export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {} const Input = React.forwardRef<HTMLInputElement, InputProps>( ({ className, type, ...props }, ref) => { return ( <input type={type} className={cn( "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", className )} ref={ref} {...props} /> ) } ) Input.displayName = "Input" export { Input } ================ File: src/components/ui/label.tsx ================ "use client" import * as React from "react" import * as LabelPrimitive from "@radix-ui/react-label" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/utils/styles" const labelVariants = cva( "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" ) const Label = React.forwardRef< React.ElementRef<typeof LabelPrimitive.Root>, React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants> >(({ className, ...props }, ref) => ( <LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} /> )) Label.displayName = LabelPrimitive.Root.displayName export { Label } ================ File: src/components/ui/scroll-area.tsx ================ "use client" import * as React from "react" import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" import { cn } from "@/utils/styles" const ScrollArea = React.forwardRef< React.ElementRef<typeof ScrollAreaPrimitive.Root>, React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> >(({ className, children, ...props }, ref) => ( <ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props} > <ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]"> {children} </ScrollAreaPrimitive.Viewport> <ScrollBar /> <ScrollAreaPrimitive.Corner /> </ScrollAreaPrimitive.Root> )) ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName const ScrollBar = React.forwardRef< React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>, React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar> >(({ className, orientation = "vertical", ...props }, ref) => ( <ScrollAreaPrimitive.ScrollAreaScrollbar ref={ref} orientation={orientation} className={cn( "flex touch-none select-none transition-colors", orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]", orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]", className )} {...props} > <ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" /> </ScrollAreaPrimitive.ScrollAreaScrollbar> )) ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName export { ScrollArea, ScrollBar } ================ File: src/components/ui/select.tsx ================ "use client" import * as React from "react" import { CaretSortIcon, CheckIcon, ChevronDownIcon, ChevronUpIcon, } from "@radix-ui/react-icons" import * as SelectPrimitive from "@radix-ui/react-select" import { cn } from "@/utils/styles" const Select = SelectPrimitive.Root const SelectGroup = SelectPrimitive.Group const SelectValue = SelectPrimitive.Value const SelectTrigger = React.forwardRef< React.ElementRef<typeof SelectPrimitive.Trigger>, React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> >(({ className, children, ...props }, ref) => ( <SelectPrimitive.Trigger ref={ref} className={cn( "flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", className )} {...props} > {children} <SelectPrimitive.Icon asChild> <CaretSortIcon className="h-4 w-4 opacity-50" /> </SelectPrimitive.Icon> </SelectPrimitive.Trigger> )) SelectTrigger.displayName = SelectPrimitive.Trigger.displayName const SelectScrollUpButton = React.forwardRef< React.ElementRef<typeof SelectPrimitive.ScrollUpButton>, React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton> >(({ className, ...props }, ref) => ( <SelectPrimitive.ScrollUpButton ref={ref} className={cn( "flex cursor-default items-center justify-center py-1", className )} {...props} > <ChevronUpIcon /> </SelectPrimitive.ScrollUpButton> )) SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName const SelectScrollDownButton = React.forwardRef< React.ElementRef<typeof SelectPrimitive.ScrollDownButton>, React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton> >(({ className, ...props }, ref) => ( <SelectPrimitive.ScrollDownButton ref={ref} className={cn( "flex cursor-default items-center justify-center py-1", className )} {...props} > <ChevronDownIcon /> </SelectPrimitive.ScrollDownButton> )) SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName const SelectContent = React.forwardRef< React.ElementRef<typeof SelectPrimitive.Content>, React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> >(({ className, children, position = "popper", ...props }, ref) => ( <SelectPrimitive.Portal> <SelectPrimitive.Content ref={ref} className={cn( "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", position === "popper" && "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )} position={position} {...props} > <SelectScrollUpButton /> <SelectPrimitive.Viewport className={cn( "p-1", position === "popper" && "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]" )} > {children} </SelectPrimitive.Viewport> <SelectScrollDownButton /> </SelectPrimitive.Content> </SelectPrimitive.Portal> )) SelectContent.displayName = SelectPrimitive.Content.displayName const SelectLabel = React.forwardRef< React.ElementRef<typeof SelectPrimitive.Label>, React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label> >(({ className, ...props }, ref) => ( <SelectPrimitive.Label ref={ref} className={cn("px-2 py-1.5 text-sm font-semibold", className)} {...props} /> )) SelectLabel.displayName = SelectPrimitive.Label.displayName const SelectItem = React.forwardRef< React.ElementRef<typeof SelectPrimitive.Item>, React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> >(({ className, children, ...props }, ref) => ( <SelectPrimitive.Item ref={ref} className={cn( "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className )} {...props} > <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center"> <SelectPrimitive.ItemIndicator> <CheckIcon className="h-4 w-4" /> </SelectPrimitive.ItemIndicator> </span> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> </SelectPrimitive.Item> )) SelectItem.displayName = SelectPrimitive.Item.displayName const SelectSeparator = React.forwardRef< React.ElementRef<typeof SelectPrimitive.Separator>, React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> >(({ className, ...props }, ref) => ( <SelectPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} /> )) SelectSeparator.displayName = SelectPrimitive.Separator.displayName export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectLabel, SelectItem, SelectSeparator, SelectScrollUpButton, SelectScrollDownButton, } ================ File: src/components/ui/skeleton.tsx ================ import { cn } from "@/utils/styles" function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) { return ( <div className={cn("animate-pulse rounded-md bg-primary/10", className)} {...props} /> ) } export { Skeleton } ================ File: src/components/ui/sonner.tsx ================ "use client" import { useTheme } from "next-themes" import { Toaster as Sonner } from "sonner" type ToasterProps = React.ComponentProps<typeof Sonner> const Toaster = ({ ...props }: ToasterProps) => { const { theme = "system" } = useTheme() return ( <Sonner theme={theme as ToasterProps["theme"]} className="toaster group" toastOptions={{ classNames: { toast: "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg", description: "group-[.toast]:text-muted-foreground", actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground", cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground", }, }} {...props} /> ) } export { Toaster } ================ File: src/components/ui/table.tsx ================ import * as React from "react" import { cn } from "@/utils/styles" const Table = React.forwardRef< HTMLTableElement, React.HTMLAttributes<HTMLTableElement> >(({ className, ...props }, ref) => ( <div className="relative w-full overflow-auto"> <table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} /> </div> )) Table.displayName = "Table" const TableHeader = React.forwardRef< HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement> >(({ className, ...props }, ref) => ( <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} /> )) TableHeader.displayName = "TableHeader" const TableBody = React.forwardRef< HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement> >(({ className, ...props }, ref) => ( <tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} /> )) TableBody.displayName = "TableBody" const TableFooter = React.forwardRef< HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement> >(({ className, ...props }, ref) => ( <tfoot ref={ref} className={cn( "border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className )} {...props} /> )) TableFooter.displayName = "TableFooter" const TableRow = React.forwardRef< HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement> >(({ className, ...props }, ref) => ( <tr ref={ref} className={cn( "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", className )} {...props} /> )) TableRow.displayName = "TableRow" const TableHead = React.forwardRef< HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement> >(({ className, ...props }, ref) => ( <th ref={ref} className={cn( "h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", className )} {...props} /> )) TableHead.displayName = "TableHead" const TableCell = React.forwardRef< HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement> >(({ className, ...props }, ref) => ( <td ref={ref} className={cn( "p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", className )} {...props} /> )) TableCell.displayName = "TableCell" const TableCaption = React.forwardRef< HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement> >(({ className, ...props }, ref) => ( <caption ref={ref} className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} /> )) TableCaption.displayName = "TableCaption" export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption, } ================ File: src/components/ui/tabs.tsx ================ "use client" import * as React from "react" import * as TabsPrimitive from "@radix-ui/react-tabs" import { cn } from "@/utils/styles" const Tabs = TabsPrimitive.Root const TabsList = React.forwardRef< React.ElementRef<typeof TabsPrimitive.List>, React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> >(({ className, ...props }, ref) => ( <TabsPrimitive.List ref={ref} className={cn( "inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground", className )} {...props} /> )) TabsList.displayName = TabsPrimitive.List.displayName const TabsTrigger = React.forwardRef< React.ElementRef<typeof TabsPrimitive.Trigger>, React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> >(({ className, ...props }, ref) => ( <TabsPrimitive.Trigger ref={ref} className={cn( "inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow", className )} {...props} /> )) TabsTrigger.displayName = TabsPrimitive.Trigger.displayName const TabsContent = React.forwardRef< React.ElementRef<typeof TabsPrimitive.Content>, React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> >(({ className, ...props }, ref) => ( <TabsPrimitive.Content ref={ref} className={cn( "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", className )} {...props} /> )) TabsContent.displayName = TabsPrimitive.Content.displayName export { Tabs, TabsList, TabsTrigger, TabsContent } ================ File: src/components/ui/textarea.tsx ================ import * as React from "react" import { cn } from "@/utils/styles" export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {} const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( ({ className, ...props }, ref) => { return ( <textarea className={cn( "flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", className )} ref={ref} {...props} /> ) } ) Textarea.displayName = "Textarea" export { Textarea } ================ File: src/components/ui/toast.tsx ================ "use client" import * as React from "react" import { Cross2Icon } from "@radix-ui/react-icons" import * as ToastPrimitives from "@radix-ui/react-toast" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/utils/styles" const ToastProvider = ToastPrimitives.Provider const ToastViewport = React.forwardRef< React.ElementRef<typeof ToastPrimitives.Viewport>, React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport> >(({ className, ...props }, ref) => ( <ToastPrimitives.Viewport ref={ref} className={cn( "fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]", className )} {...props} /> )) ToastViewport.displayName = ToastPrimitives.Viewport.displayName const toastVariants = cva( "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", { variants: { variant: { default: "border bg-background text-foreground", destructive: "destructive group border-destructive bg-destructive text-destructive-foreground", }, }, defaultVariants: { variant: "default", }, } ) const Toast = React.forwardRef< React.ElementRef<typeof ToastPrimitives.Root>, React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants> >(({ className, variant, ...props }, ref) => { return ( <ToastPrimitives.Root ref={ref} className={cn(toastVariants({ variant }), className)} {...props} /> ) }) Toast.displayName = ToastPrimitives.Root.displayName const ToastAction = React.forwardRef< React.ElementRef<typeof ToastPrimitives.Action>, React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action> >(({ className, ...props }, ref) => ( <ToastPrimitives.Action ref={ref} className={cn( "inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive", className )} {...props} /> )) ToastAction.displayName = ToastPrimitives.Action.displayName const ToastClose = React.forwardRef< React.ElementRef<typeof ToastPrimitives.Close>, React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close> >(({ className, ...props }, ref) => ( <ToastPrimitives.Close ref={ref} className={cn( "absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600", className )} toast-close="" {...props} > <Cross2Icon className="h-4 w-4" /> </ToastPrimitives.Close> )) ToastClose.displayName = ToastPrimitives.Close.displayName const ToastTitle = React.forwardRef< React.ElementRef<typeof ToastPrimitives.Title>, React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title> >(({ className, ...props }, ref) => ( <ToastPrimitives.Title ref={ref} className={cn("text-sm font-semibold [&+div]:text-xs", className)} {...props} /> )) ToastTitle.displayName = ToastPrimitives.Title.displayName const ToastDescription = React.forwardRef< React.ElementRef<typeof ToastPrimitives.Description>, React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description> >(({ className, ...props }, ref) => ( <ToastPrimitives.Description ref={ref} className={cn("text-sm opacity-90", className)} {...props} /> )) ToastDescription.displayName = ToastPrimitives.Description.displayName type ToastProps = React.ComponentPropsWithoutRef<typeof Toast> type ToastActionElement = React.ReactElement<typeof ToastAction> export { type ToastProps, type ToastActionElement, ToastProvider, ToastViewport, Toast, ToastTitle, ToastDescription, ToastClose, ToastAction, } ================ File: src/components/ui/toaster.tsx ================ "use client" import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport, } from "@/components/ui/toast" import { useToast } from "@/components/ui/use-toast" export function Toaster() { const { toasts } = useToast() return ( <ToastProvider> {toasts.map(function ({ id, title, description, action, ...props }) { return ( <Toast key={id} {...props}> <div className="grid gap-1"> {title && <ToastTitle>{title}</ToastTitle>} {description && ( <ToastDescription>{description}</ToastDescription> )} </div> {action} <ToastClose /> </Toast> ) })} <ToastViewport /> </ToastProvider> ) } ================ File: src/components/ui/use-toast.ts ================ "use client" // Inspired by react-hot-toast library import * as React from "react" import type { ToastActionElement, ToastProps, } from "@/components/ui/toast" const TOAST_LIMIT = 1 const TOAST_REMOVE_DELAY = 1000000 type ToasterToast = ToastProps & { id: string title?: React.ReactNode description?: React.ReactNode action?: ToastActionElement } const actionTypes = { ADD_TOAST: "ADD_TOAST", UPDATE_TOAST: "UPDATE_TOAST", DISMISS_TOAST: "DISMISS_TOAST", REMOVE_TOAST: "REMOVE_TOAST", } as const let count = 0 function genId() { count = (count + 1) % Number.MAX_SAFE_INTEGER return count.toString() } type ActionType = typeof actionTypes type Action = | { type: ActionType["ADD_TOAST"] toast: ToasterToast } | { type: ActionType["UPDATE_TOAST"] toast: Partial<ToasterToast> } | { type: ActionType["DISMISS_TOAST"] toastId?: ToasterToast["id"] } | { type: ActionType["REMOVE_TOAST"] toastId?: ToasterToast["id"] } interface State { toasts: ToasterToast[] } const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>() const addToRemoveQueue = (toastId: string) => { if (toastTimeouts.has(toastId)) { return } const timeout = setTimeout(() => { toastTimeouts.delete(toastId) dispatch({ type: "REMOVE_TOAST", toastId: toastId, }) }, TOAST_REMOVE_DELAY) toastTimeouts.set(toastId, timeout) } export const reducer = (state: State, action: Action): State => { switch (action.type) { case "ADD_TOAST": return { ...state, toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), } case "UPDATE_TOAST": return { ...state, toasts: state.toasts.map((t) => t.id === action.toast.id ? { ...t, ...action.toast } : t ), } case "DISMISS_TOAST": { const { toastId } = action // ! Side effects ! - This could be extracted into a dismissToast() action, // but I'll keep it here for simplicity if (toastId) { addToRemoveQueue(toastId) } else { state.toasts.forEach((toast) => { addToRemoveQueue(toast.id) }) } return { ...state, toasts: state.toasts.map((t) => t.id === toastId || toastId === undefined ? { ...t, open: false, } : t ), } } case "REMOVE_TOAST": if (action.toastId === undefined) { return { ...state, toasts: [], } } return { ...state, toasts: state.toasts.filter((t) => t.id !== action.toastId), } } } const listeners: Array<(state: State) => void> = [] let memoryState: State = { toasts: [] } function dispatch(action: Action) { memoryState = reducer(memoryState, action) listeners.forEach((listener) => { listener(memoryState) }) } type Toast = Omit<ToasterToast, "id"> function toast({ ...props }: Toast) { const id = genId() const update = (props: ToasterToast) => dispatch({ type: "UPDATE_TOAST", toast: { ...props, id }, }) const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) dispatch({ type: "ADD_TOAST", toast: { ...props, id, open: true, onOpenChange: (open) => { if (!open) dismiss() }, }, }) return { id: id, dismiss, update, } } function useToast() { const [state, setState] = React.useState<State>(memoryState) React.useEffect(() => { listeners.push(setState) return () => { const index = listeners.indexOf(setState) if (index > -1) { listeners.splice(index, 1) } } }, [state]) return { ...state, toast, dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), } } export { useToast, toast } ================ File: src/server/actions/authentication/logout.ts ================ "use server"; import { lucia, validateRequest } from "@/server/auth"; import strings from "@/utils/strings"; import { revalidatePath } from "next/cache"; import { cookies } from "next/headers"; export async function logout(path?: string) { const { session } = await validateRequest(); if (!session) { return { error: strings.ERROR.NO_SESSION, }; } try { await lucia.invalidateSession(session.id); } catch (error) { return { error: strings.ERROR.NO_SESSION, }; } const sessionCookie = lucia.createBlankSessionCookie(); cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); revalidatePath(path ?? "/"); } ================ File: src/server/actions/printers.ts ================ "use server"; import { validateRequest } from "@/server/auth"; import { UserRole } from "@/server/auth/permissions"; import { db } from "@/server/db"; import { printers, users } from "@/server/db/schema"; import { IS_NOT, guard } from "@/utils/guard"; import strings from "@/utils/strings"; import { type InferInsertModel, eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; export async function createPrinter(printer: InferInsertModel<typeof printers>) { const { user } = await validateRequest(); if (guard(user, IS_NOT, UserRole.ADMIN)) { return { error: strings.ERROR.PERMISSION, }; } const dbUser = await db.query.users.findFirst({ // biome-ignore lint/style/noNonNullAssertion: guard already checks against null where: eq(users.id, user!.id), }); if (guard(dbUser, IS_NOT, UserRole.ADMIN)) { return { error: strings.ERROR.PERMISSION, }; } if (!printer) { return { error: "Druckerdaten sind erforderlich.", }; } try { await db.insert(printers).values(printer); } catch (error) { return { error: "Drucker konnte nicht hinzugefügt werden.", }; } revalidatePath("/"); } export async function updatePrinter(id: string, data: InferInsertModel<typeof printers>) { const { user } = await validateRequest(); if (guard(user, IS_NOT, UserRole.ADMIN)) { return { error: strings.ERROR.PERMISSION, }; } const dbUser = await db.query.users.findFirst({ // biome-ignore lint/style/noNonNullAssertion: guard already checks against null where: eq(users.id, user!.id), }); if (guard(dbUser, IS_NOT, UserRole.ADMIN)) { return { error: strings.ERROR.PERMISSION, }; } if (!data) { return { error: "Druckerdaten sind erforderlich.", }; } try { await db.update(printers).set(data).where(eq(printers.id, id)); } catch (error) { return { error: "Druckerdaten sind erforderlich.", }; } revalidatePath("/"); } export async function deletePrinter(id: string) { const { user } = await validateRequest(); if (guard(user, IS_NOT, UserRole.ADMIN)) { return { error: strings.ERROR.PERMISSION, }; } const dbUser = await db.query.users.findFirst({ // biome-ignore lint/style/noNonNullAssertion: guard already checks against null where: eq(users.id, user!.id), }); if (guard(dbUser, IS_NOT, UserRole.ADMIN)) { return { error: strings.ERROR.PERMISSION, }; } try { await db.delete(printers).where(eq(printers.id, id)); } catch (error) { if (error instanceof Error) { return { error: error.message, }; } return { error: "Ein unbekannter Fehler ist aufgetreten.", }; } revalidatePath("/"); } export async function getPrinters() { return await db.query.printers.findMany({ with: { printJobs: { limit: 1, orderBy: (printJobs, { desc }) => [desc(printJobs.startAt)], }, }, }); } ================ File: src/server/actions/printJobs.ts ================ "use server"; import { validateRequest } from "@/server/auth"; import { UserRole } from "@/server/auth/permissions"; import { db } from "@/server/db"; import { printJobs, users } from "@/server/db/schema"; import { IS, guard } from "@/utils/guard"; import strings from "@/utils/strings"; import { type InferInsertModel, eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; export async function createPrintJob(printJob: InferInsertModel<typeof printJobs>) { const { user } = await validateRequest(); if (guard(user, IS, UserRole.GUEST)) { return { error: strings.ERROR.PERMISSION, }; } const dbUser = await db.query.users.findFirst({ // biome-ignore lint/style/noNonNullAssertion: guard already checks against null where: eq(users.id, user!.id), }); if (guard(dbUser, IS, UserRole.GUEST)) { return { error: strings.ERROR.PERMISSION, }; } try { const result = await db.insert(printJobs).values(printJob).returning({ jobId: printJobs.id, }); return result[0].jobId; } catch (error) { return { error: "Druckauftrag konnte nicht hinzugefügt werden.", }; } } /* async function updatePrintJob(jobId: string, printJob: InferInsertModel<typeof printJobs>) { const { user } = await validateRequest(); if (guard(user, is, UserRole.GUEST)) { return { error: strings.ERROR.PERMISSION } } const dbUser = await db.query.users.findFirst({ // biome-ignore lint/style/noNonNullAssertion: guard already checks against null where: eq(users.id, user!.id), }); if (guard(dbUser, is, UserRole.GUEST)) { return { error: strings.ERROR.PERMISSION } } await db.update(printJobs).set(printJob).where(eq(printJobs.id, jobId)); } */ export async function abortPrintJob(jobId: string, reason: string) { const { user } = await validateRequest(); if (guard(user, IS, UserRole.GUEST)) { return { error: strings.ERROR.PERMISSION, }; } const dbUser = await db.query.users.findFirst({ // biome-ignore lint/style/noNonNullAssertion: guard already checks against null where: eq(users.id, user!.id), }); if (guard(dbUser, IS, UserRole.GUEST)) { return { error: strings.ERROR.PERMISSION, }; } // Get the print job const printJob = await db.query.printJobs.findFirst({ where: eq(printJobs.id, jobId), }); if (!printJob) { return { error: "Druckauftrag nicht gefunden", }; } // Check if the print job is already aborted or completed if (printJob.aborted) { return { error: "Druckauftrag wurde bereits abgebrochen" }; } if (new Date(printJob.startAt).getTime() + printJob.durationInMinutes * 60 * 1000 < Date.now()) { return { error: "Druckauftrag ist bereits abgeschlossen" }; } // Check if user is the owner of the print job // biome-ignore lint/style/noNonNullAssertion: guard already checks against null if (printJob.userId !== dbUser!.id && dbUser!.role !== UserRole.ADMIN) { return { error: strings.ERROR.PERMISSION, }; } // Get duration in minutes since startAt const duration = Math.floor((Date.now() - new Date(printJob.startAt).getTime()) / 1000 / 60); await db .update(printJobs) .set({ aborted: true, abortReason: reason, durationInMinutes: duration, comments: `${printJob.comments}\n\n---${dbUser?.username}: Druckauftrag abgebrochen`, }) .where(eq(printJobs.id, jobId)); revalidatePath("/"); } export async function earlyFinishPrintJob(jobId: string) { const { user } = await validateRequest(); if (guard(user, IS, UserRole.GUEST)) { return { error: strings.ERROR.PERMISSION, }; } const dbUser = await db.query.users.findFirst({ // biome-ignore lint/style/noNonNullAssertion: guard already checks against null where: eq(users.id, user!.id), }); if (guard(dbUser, IS, UserRole.GUEST)) { return { error: strings.ERROR.PERMISSION, }; } // Get the print job const printJob = await db.query.printJobs.findFirst({ where: eq(printJobs.id, jobId), }); if (!printJob) { return { error: "Druckauftrag nicht gefunden" }; } // Check if the print job is already aborted or completed if (printJob.aborted) { return { error: "Druckauftrag wurde bereits abgebrochen" }; } if (new Date(printJob.startAt).getTime() + printJob.durationInMinutes * 60 * 1000 < Date.now()) { return { error: "Druckauftrag ist bereits abgeschlossen" }; } // Check if user is the owner of the print job // biome-ignore lint/style/noNonNullAssertion: guard already checks against null if (printJob.userId !== dbUser!.id && dbUser!.role !== UserRole.ADMIN) { return { error: strings.ERROR.PERMISSION, }; } // Get duration in minutes since startAt const duration = Math.floor((Date.now() - new Date(printJob.startAt).getTime()) / 1000 / 60); await db .update(printJobs) .set({ durationInMinutes: duration, comments: `${printJob.comments}\n\n---${dbUser?.username}: Druckauftrag vorzeitig abgeschlossen`, }) .where(eq(printJobs.id, jobId)); revalidatePath("/"); } export async function extendPrintJob(jobId: string, minutes: number, hours: number) { const { user } = await validateRequest(); if (guard(user, IS, UserRole.GUEST)) { return { error: strings.ERROR.PERMISSION, }; } const dbUser = await db.query.users.findFirst({ // biome-ignore lint/style/noNonNullAssertion: guard already checks against null where: eq(users.id, user!.id), }); if (guard(dbUser, IS, UserRole.GUEST)) { return { error: strings.ERROR.PERMISSION, }; } // Get the print job const printJob = await db.query.printJobs.findFirst({ where: eq(printJobs.id, jobId), }); if (!printJob) { return { error: "Druckauftrag nicht gefunden" }; } // Check if the print job is already aborted or completed if (printJob.aborted) { return { error: "Druckauftrag wurde bereits abgebrochen" }; } if (new Date(printJob.startAt).getTime() + printJob.durationInMinutes * 60 * 1000 < Date.now()) { return { error: "Druckauftrag ist bereits abgeschlossen" }; } // Check if user is the owner of the print job // biome-ignore lint/style/noNonNullAssertion: guard already checks against null if (printJob.userId !== dbUser!.id && dbUser!.role !== UserRole.ADMIN) { return { error: strings.ERROR.PERMISSION, }; } const duration = minutes + hours * 60; await db .update(printJobs) .set({ durationInMinutes: printJob.durationInMinutes + duration, comments: `${printJob.comments}\n\n---${dbUser?.username}: Verlängert um ${hours} Stunden und ${minutes} Minuten`, }) .where(eq(printJobs.id, jobId)); revalidatePath("/"); } export async function updatePrintComments(jobId: string, comments: string) { const { user } = await validateRequest(); if (guard(user, IS, UserRole.GUEST)) { return { error: strings.ERROR.PERMISSION, }; } const dbUser = await db.query.users.findFirst({ // biome-ignore lint/style/noNonNullAssertion: guard already checks against null where: eq(users.id, user!.id), }); if (guard(dbUser, IS, UserRole.GUEST)) { return { error: strings.ERROR.PERMISSION, }; } // Get the print job const printJob = await db.query.printJobs.findFirst({ where: eq(printJobs.id, jobId), }); if (!printJob) { return { error: "Druckauftrag nicht gefunden" }; } // Check if the print job is already aborted or completed if (printJob.aborted) { return { error: "Druckauftrag wurde bereits abgebrochen" }; } if (new Date(printJob.startAt).getTime() + printJob.durationInMinutes * 60 * 1000 < Date.now()) { return { error: "Druckauftrag ist bereits abgeschlossen" }; } // Check if user is the owner of the print job // biome-ignore lint/style/noNonNullAssertion: guard already checks against null if (printJob.userId !== dbUser!.id && dbUser!.role !== UserRole.ADMIN) { return { error: strings.ERROR.PERMISSION, }; } await db .update(printJobs) .set({ comments, }) .where(eq(printJobs.id, jobId)); revalidatePath("/"); } ================ File: src/server/actions/timer.ts ================ "use server"; import { revalidatePath } from "next/cache"; export async function revalidate() { revalidatePath("/"); } ================ File: src/server/actions/user/delete.ts ================ "use server"; import { validateRequest } from "@/server/auth"; import { UserRole } from "@/server/auth/permissions"; import { db } from "@/server/db"; import { users } from "@/server/db/schema"; import { IS, IS_NOT, guard } from "@/utils/guard"; import strings from "@/utils/strings"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; /** * Deletes a user from the database * @param userId User ID to delete * @param path Path to revalidate */ export async function deleteUser(userId: string, path?: string) { const { user } = await validateRequest(); if (guard(user, IS_NOT, UserRole.ADMIN)) { return { error: strings.ERROR.PERMISSION, }; } const dbUser = await db.query.users.findFirst({ // biome-ignore lint/style/noNonNullAssertion: guard already checks against null where: eq(users.id, user!.id), }); if (guard(dbUser, IS_NOT, UserRole.ADMIN)) { return { error: strings.ERROR.PERMISSION, }; } const targetUser = await db.query.users.findFirst({ where: eq(users.id, userId), }); if (!targetUser) { return { error: "Benutzer nicht gefunden", }; } if (guard(targetUser, IS, UserRole.ADMIN)) { return { error: "Admins können nicht gelöscht werden.", }; } await db.delete(users).where(eq(users.id, userId)); revalidatePath(path ?? "/admin/users"); } ================ File: src/server/actions/user/update.ts ================ import type { formSchema } from "@/app/admin/users/form"; import { validateRequest } from "@/server/auth"; import { UserRole } from "@/server/auth/permissions"; import { db } from "@/server/db"; import { users } from "@/server/db/schema"; import { IS_NOT, guard } from "@/utils/guard"; import strings from "@/utils/strings"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import type { z } from "zod"; /** * Updates a user in the database * @param userId User ID to update * @param data Updated user data * @param path Path to revalidate */ export async function updateUser(userId: string, data: z.infer<typeof formSchema>, path?: string) { const { user } = await validateRequest(); if (guard(user, IS_NOT, UserRole.ADMIN)) { return { error: strings.ERROR.PERMISSION, }; } const dbUser = await db.query.users.findFirst({ // biome-ignore lint/style/noNonNullAssertion: guard already checks against null where: eq(users.id, user!.id), }); if (guard(dbUser, IS_NOT, UserRole.ADMIN)) { return { error: strings.ERROR.PERMISSION, }; } await db.update(users).set(data).where(eq(users.id, userId)); revalidatePath(path ?? "/admin/users"); } ================ File: src/server/actions/users.ts ================ "use server"; import type { formSchema } from "@/app/admin/users/form"; import { validateRequest } from "@/server/auth"; import { UserRole } from "@/server/auth/permissions"; import { db } from "@/server/db"; import { users } from "@/server/db/schema"; import { IS, IS_NOT, guard } from "@/utils/guard"; import strings from "@/utils/strings"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import type { z } from "zod"; /** * @deprecated */ export async function updateUser(userId: string, data: z.infer<typeof formSchema>) { const { user } = await validateRequest(); if (guard(user, IS_NOT, UserRole.ADMIN)) { return { error: strings.ERROR.PERMISSION, }; } const dbUser = await db.query.users.findFirst({ // biome-ignore lint/style/noNonNullAssertion: guard already checks against null where: eq(users.id, user!.id), }); if (guard(dbUser, IS_NOT, UserRole.ADMIN)) { return { error: strings.ERROR.PERMISSION, }; } await db.update(users).set(data).where(eq(users.id, userId)); revalidatePath("/admin/users"); } /** * @deprecated */ export async function deleteUser(userId: string) { const { user } = await validateRequest(); if (guard(user, IS_NOT, UserRole.ADMIN)) { return { error: strings.ERROR.PERMISSION, }; } const dbUser = await db.query.users.findFirst({ // biome-ignore lint/style/noNonNullAssertion: guard already checks against null where: eq(users.id, user!.id), }); if (guard(dbUser, IS_NOT, UserRole.ADMIN)) { return { error: strings.ERROR.PERMISSION, }; } const targetUser = await db.query.users.findFirst({ where: eq(users.id, userId), }); if (!targetUser) { return { error: "Benutzer nicht gefunden" }; } if (guard(targetUser, IS, UserRole.ADMIN)) { return { error: "Kann keinen Admin löschen" }; } await db.delete(users).where(eq(users.id, userId)); revalidatePath("/admin/users"); } ================ File: src/server/auth/index.ts ================ import type { UserRole } from "@/server/auth/permissions"; import { db } from "@/server/db"; import { sessions, users } from "@/server/db/schema"; import { DrizzleSQLiteAdapter } from "@lucia-auth/adapter-drizzle"; import { Lucia, type RegisteredDatabaseUserAttributes, type Session } from "lucia"; import { cookies } from "next/headers"; import { cache } from "react"; //@ts-ignore const adapter = new DrizzleSQLiteAdapter(db, sessions, users); export const lucia = new Lucia(adapter, { sessionCookie: { expires: false, attributes: { secure: process.env.NODE_ENV === "production", }, }, getUserAttributes: (attributes) => { return { id: attributes.id, username: attributes.username, displayName: attributes.displayName, email: attributes.email, role: attributes.role, }; }, }); export const validateRequest = cache( async (): Promise<{ user: RegisteredDatabaseUserAttributes; session: Session } | { user: null; session: null }> => { const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? null; if (!sessionId) { return { user: null, session: null, }; } const result = await lucia.validateSession(sessionId); // next.js throws when you attempt to set cookie when rendering page try { if (result.session?.fresh) { const sessionCookie = lucia.createSessionCookie(result.session.id); cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); } if (!result.session) { const sessionCookie = lucia.createBlankSessionCookie(); cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); } } catch {} return result as { user: RegisteredDatabaseUserAttributes; session: Session; }; }, ); declare module "lucia" { interface Register { Lucia: typeof Lucia; DatabaseUserAttributes: { id: string; github_id: number; username: string; displayName: string; email: string; role: UserRole; }; } } ================ File: src/server/auth/oauth.ts ================ import { GitHub } from "arctic"; export const github = new GitHub(process.env.OAUTH_CLIENT_ID as string, process.env.OAUTH_CLIENT_SECRET as string, { enterpriseDomain: "https://git.i.mercedes-benz.com", }); export interface GitHubUserResult { id: number; login: string; name: string; email: string; } ================ File: src/server/auth/permissions.ts ================ import type { RegisteredDatabaseUserAttributes } from "lucia"; export enum UserRole { ADMIN = "admin", USER = "user", GUEST = "guest", } /** * @deprecated */ export function hasRole(user: RegisteredDatabaseUserAttributes | null | undefined, role: UserRole) { return user?.role === role; } /** * @deprecated */ export function translateUserRole(role: UserRole) { switch (role) { case UserRole.ADMIN: return "Administrator"; case UserRole.USER: return "Benutzer"; case UserRole.GUEST: return "Gast"; } } ================ File: src/utils/analytics/error-rate.ts ================ import type { printJobs } from "@/server/db/schema"; import type { InferResultType } from "@/utils/drizzle"; import type { InferSelectModel } from "drizzle-orm"; export interface PrinterErrorRate { printerId: string; name: string; errorRate: number; // Error rate as a percentage (0-100) } /** * Calculates the error rate for print jobs aggregated by printer as a percentage. * * @param pJobs - Array of print job objects. * @returns An array of PrinterErrorRate objects, each containing the printer ID and its error rate. */ export function calculatePrinterErrorRate( pJobs: InferResultType<"printJobs", { printer: true }>[], ): PrinterErrorRate[] { if (pJobs.length === 0) { return []; // No jobs, no data. } const printers = pJobs.map((job) => job.printer); // Group jobs by printer ID const jobsByPrinter: Record<string, InferSelectModel<typeof printJobs>[]> = pJobs.reduce( (acc, job) => { if (!acc[job.printerId]) { acc[job.printerId] = []; } acc[job.printerId].push(job); return acc; }, {} as Record<string, InferSelectModel<typeof printJobs>[]>, ); // Calculate the error rate for each printer const printerErrorRates: PrinterErrorRate[] = Object.entries(jobsByPrinter).map(([printerId, jobs]) => { const totalJobs = jobs.length; const abortedJobsCount = jobs.filter((job) => job.aborted).length; const errorRate = (abortedJobsCount / totalJobs) * 100; const printer = printers.find((printer) => printer.id === printerId); const printerName = printer ? printer.name : "Unbekannter Drucker"; return { printerId, name: printerName, errorRate: Number.parseFloat(errorRate.toFixed(2)), // Rounded to two decimal places }; }); return printerErrorRates; } ================ File: src/utils/analytics/errors.ts ================ import type { InferResultType } from "@/utils/drizzle"; export interface AbortReasonCount { abortReason: string; count: number; } /** * Calculates the count of each unique abort reason for print jobs. * * @param pJobs - Array of print job objects. * @returns An array of AbortReasonCount objects, each containing the abort reason and its count. */ export function calculateAbortReasonsCount(pJobs: InferResultType<"printJobs">[]): AbortReasonCount[] { if (pJobs.length === 0) { return []; // No jobs, no data. } // Filter aborted jobs and count each abort reason const abortReasonsCount = pJobs .filter((job) => job.aborted && job.abortReason) // Consider only aborted jobs with a reason .reduce( (acc, job) => { const reason = job.abortReason || "Unbekannter Grund"; if (!acc[reason]) { acc[reason] = 0; } acc[reason]++; return acc; }, {} as Record<string, number>, ); // Convert the result to an array of AbortReasonCount objects return Object.entries(abortReasonsCount).map(([abortReason, count]) => ({ abortReason, count, })); } ================ File: src/utils/analytics/forecast.ts ================ import type { InferResultType } from "@/utils/drizzle"; type UsagePerDay = { day: number; // 0 (Sunday) to 6 (Saturday) usageMinutes: number; }; function aggregateUsageByDay(jobs: InferResultType<"printJobs">[]): { usageData: UsagePerDay[]; earliestDate: Date; latestDate: Date; } { const usagePerDayMap = new Map<number, number>(); let earliestDate: Date | null = null; let latestDate: Date | null = null; for (const job of jobs) { let remainingDuration = job.durationInMinutes; const currentStart = new Date(job.startAt); // Update earliest and latest dates if (!earliestDate || currentStart < earliestDate) { earliestDate = new Date(currentStart); } const jobEnd = new Date(currentStart); jobEnd.setMinutes(jobEnd.getMinutes() + job.durationInMinutes); if (!latestDate || jobEnd > latestDate) { latestDate = new Date(jobEnd); } while (remainingDuration > 0) { const day = currentStart.getDay(); // Calculate minutes remaining in the current day const minutesRemainingInDay = (24 - currentStart.getHours()) * 60 - currentStart.getMinutes(); const minutesToAdd = Math.min(remainingDuration, minutesRemainingInDay); // Update the usage for the current day const usageMinutes = usagePerDayMap.get(day) || 0; usagePerDayMap.set(day, usageMinutes + minutesToAdd); // Update remaining duration and move to the next day remainingDuration -= minutesToAdd; currentStart.setDate(currentStart.getDate() + 1); currentStart.setHours(0, 0, 0, 0); // Start at the beginning of the next day } } const usageData: UsagePerDay[] = Array.from({ length: 7 }, (_, day) => ({ day, usageMinutes: usagePerDayMap.get(day) || 0, })); if (earliestDate === null) { earliestDate = new Date(); } if (latestDate === null) { latestDate = new Date(); } return { usageData, earliestDate: earliestDate, latestDate: latestDate }; } function countWeekdays(startDate: Date, endDate: Date): number[] { const countPerDay = Array(7).fill(0); const currentDate = new Date(startDate); currentDate.setHours(0, 0, 0, 0); // Ensure starting at midnight endDate.setHours(0, 0, 0, 0); // Ensure ending at midnight while (currentDate <= endDate) { const day = currentDate.getDay(); countPerDay[day]++; currentDate.setDate(currentDate.getDate() + 1); } return countPerDay; } export function forecastPrinterUsage(jobs: InferResultType<"printJobs">[]): number[] { const { usageData, earliestDate, latestDate } = aggregateUsageByDay(jobs); // Count the number of times each weekday occurs in the data period const weekdaysCount = countWeekdays(earliestDate, latestDate); const forecasts: number[] = []; for (const data of usageData) { const dayCount = weekdaysCount[data.day]; let usagePrediction = data.usageMinutes / dayCount; if (Number.isNaN(usagePrediction)) { usagePrediction = 0; } forecasts.push(Math.round(usagePrediction)); } return forecasts; } ================ File: src/utils/analytics/utilization.ts ================ import type { InferResultType } from "@/utils/drizzle"; export function calculatePrinterUtilization(jobs: InferResultType<"printJobs", { printer: true }>[]) { const printers = jobs.reduce<Record<string, string>>((acc, job) => { acc[job.printerId] = job.printer.name; return acc; }, {}); const usedTimePerPrinter: Record<string, number> = jobs.reduce( (acc, job) => { acc[job.printer.id] = (acc[job.printer.id] || 0) + job.durationInMinutes; return acc; }, {} as Record<string, number>, ); const totalTimeInMinutes = 60 * 35 * 3; // 60 Minutes * 35h * 3 Weeks // 35h Woche, 3 mal in der Woche in TBA const printerUtilizationPercentage = Object.keys(usedTimePerPrinter).map((printerId) => { const usedTime = usedTimePerPrinter[printerId]; return { printerId, name: printers[printerId], utilizationPercentage: usedTime / totalTimeInMinutes, }; }); return printerUtilizationPercentage; } ================ File: src/utils/analytics/volume.ts ================ import type { printJobs } from "@/server/db/schema"; import { endOfDay, endOfMonth, endOfWeek, startOfDay, startOfMonth, startOfWeek } from "date-fns"; import type { InferSelectModel } from "drizzle-orm"; interface PrintVolumes { today: number; thisWeek: number; thisMonth: number; } /** * Calculates the number of print jobs for today, this week, and this month. * * @param printJobs - Array of print job objects. * @returns An object with counts of print jobs for today, this week, and this month. */ export function calculatePrintVolumes(pJobs: InferSelectModel<typeof printJobs>[]): PrintVolumes { const now = new Date(); // Define time ranges with week starting on Monday const timeRanges = { today: { start: startOfDay(now), end: endOfDay(now) }, thisWeek: { start: startOfWeek(now, { weekStartsOn: 1 }), end: endOfWeek(now, { weekStartsOn: 1 }), }, thisMonth: { start: startOfMonth(now), end: endOfMonth(now) }, }; // Initialize counts const volumes: PrintVolumes = { today: 0, thisWeek: 0, thisMonth: 0, }; // Iterate over print jobs and count based on time ranges for (const job of pJobs) { const jobStart = new Date(job.startAt); if (jobStart >= timeRanges.today.start && jobStart <= timeRanges.today.end) { volumes.today += 1; } if (jobStart >= timeRanges.thisWeek.start && jobStart <= timeRanges.thisWeek.end) { volumes.thisWeek += 1; } if (jobStart >= timeRanges.thisMonth.start && jobStart <= timeRanges.thisMonth.end) { volumes.thisMonth += 1; } } return volumes; } ================ File: src/utils/drizzle.ts ================ import type * as schema from "@/server/db/schema"; import type { BuildQueryResult, DBQueryConfig, ExtractTablesWithRelations } from "drizzle-orm"; type Schema = typeof schema; type TSchema = ExtractTablesWithRelations<Schema>; /** * Infer the relation type of a table. */ export type IncludeRelation<TableName extends keyof TSchema> = DBQueryConfig< "one" | "many", boolean, TSchema, TSchema[TableName] >["with"]; /** * Infer the result type of a query with optional relations. */ export type InferResultType< TableName extends keyof TSchema, With extends IncludeRelation<TableName> | undefined = undefined, > = BuildQueryResult< TSchema, TSchema[TableName], { with: With; } >; ================ File: src/utils/errors.ts ================ import strings from "@/utils/strings"; /** * Base error class. */ class BaseError extends Error { constructor(message?: string) { // Pass the message to the Error constructor super(message); // Set the name of the error this.name = this.constructor.name; // Capture the stack trace if (Error.captureStackTrace) { Error.captureStackTrace(this, this.constructor); } else { this.stack = new Error(message).stack; } } } /** * Permission error class. */ export class PermissionError extends BaseError { constructor() { super(strings.ERROR.PERMISSION); } } /** * Authentication error class. */ export class AuthenticationError extends BaseError { constructor() { super(strings.ERROR.NO_SESSION); } } /** * Validation error class. */ export class ValidationError extends BaseError { constructor() { super(strings.ERROR.VALIDATION); } } /** * Not found error class. */ export class NotFoundError extends BaseError { constructor() { super(strings.ERROR.NOT_FOUND); } } export default BaseError; ================ File: src/utils/fetch.ts ================ export const fetcher = (url: string) => fetch(url).then((response) => response.json()); ================ File: src/utils/guard.ts ================ import type { UserRole } from "@/server/auth/permissions"; import type { users } from "@/server/db/schema"; import type { InferSelectModel } from "drizzle-orm"; import type { RegisteredDatabaseUserAttributes } from "lucia"; // Constants for better readability export const IS = false; export const IS_NOT = true; /** * Checks if a user has the required role(s). * @param user - The user to check. * @param negate - Whether to negate the result. * @param roleRequirements - The required role(s). * @returns Whether the user has the required role(s). */ export function guard( user: RegisteredDatabaseUserAttributes | InferSelectModel<typeof users> | undefined | null, negate: boolean, roleRequirements: UserRole | UserRole[], ) { // Early return for unauthenticated users if (!user) { return true; } // Normalize roleRequirements to an array const requiredRoles = Array.isArray(roleRequirements) ? roleRequirements : [roleRequirements]; // Check if the user's role is in the required roles const userHasRequiredRole = requiredRoles.includes(user.role as UserRole); // Return the result, negated if necessary return negate ? !userHasRequiredRole : userHasRequiredRole; } ================ File: src/utils/printers.ts ================ import type { InferResultType } from "@/utils/drizzle"; export enum PrinterStatus { IDLE = 0, OUT_OF_ORDER = 1, RESERVED = 2, } export function derivePrinterStatus( printer: InferResultType<"printers", { printJobs: true }>, ) { if (printer.status === PrinterStatus.OUT_OF_ORDER) { return PrinterStatus.OUT_OF_ORDER; } const activePrintJob = printer.printJobs[0]; if (!activePrintJob || activePrintJob.aborted) { return PrinterStatus.IDLE; } const now = Date.now(); const startAt = new Date(activePrintJob.startAt).getTime(); const endAt = startAt + activePrintJob.durationInMinutes * 60 * 1000; if (now < endAt) { return PrinterStatus.RESERVED; } return PrinterStatus.IDLE; } export function translatePrinterStatus(status: PrinterStatus) { switch (status) { case PrinterStatus.IDLE: return "Verfügbar"; case PrinterStatus.OUT_OF_ORDER: return "Außer Betrieb"; case PrinterStatus.RESERVED: return "Reserviert"; } } ================ File: src/utils/strings.ts ================ /** * Contains all strings used in the application. */ export default { ERROR: { PERMISSION: "Du besitzt nicht die erforderlichen Berechtigungen um diese Aktion auszuführen.", VALIDATION: "Die Eingabe ist ungültig.", NOT_FOUND: "Die angeforderten Daten konnten nicht gefunden werden.", NO_SESSION: "Du bist nicht angemeldet.", }, }; ================ File: src/utils/styles.ts ================ import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; /** * Utility function to merge classes with tailwindcss. * @param inputs Classes to merge * @returns classes */ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } ================ File: tailwind.config.ts ================ /** @type {import('tailwindcss').Config} */ const tremor = { content: [ "./node_modules/@tremor/**/*.{js,ts,jsx,tsx}", // Tremor module ], theme: { transparent: "transparent", current: "currentColor", extend: { colors: { // light mode tremor: { brand: { faint: "#eff6ff", // blue-50 muted: "#bfdbfe", // blue-200 subtle: "#60a5fa", // blue-400 DEFAULT: "#3b82f6", // blue-500 emphasis: "#1d4ed8", // blue-700 inverted: "#ffffff", // white }, background: { muted: "#f9fafb", // gray-50 subtle: "#f3f4f6", // gray-100 DEFAULT: "#ffffff", // white emphasis: "#374151", // gray-700 }, border: { DEFAULT: "#e5e7eb", // gray-200 }, ring: { DEFAULT: "#e5e7eb", // gray-200 }, content: { subtle: "#9ca3af", // gray-400 DEFAULT: "#6b7280", // gray-500 emphasis: "#374151", // gray-700 strong: "#111827", // gray-900 inverted: "#ffffff", // white }, }, // dark mode "dark-tremor": { brand: { faint: "#0B1229", // custom muted: "#172554", // blue-950 subtle: "#1e40af", // blue-800 DEFAULT: "#3b82f6", // blue-500 emphasis: "#60a5fa", // blue-400 inverted: "#030712", // gray-950 }, background: { muted: "#131A2B", // custom subtle: "#1f2937", // gray-800 DEFAULT: "#111827", // gray-900 emphasis: "#d1d5db", // gray-300 }, border: { DEFAULT: "#1f2937", // gray-800 }, ring: { DEFAULT: "#1f2937", // gray-800 }, content: { subtle: "#4b5563", // gray-600 DEFAULT: "#6b7280", // gray-500 emphasis: "#e5e7eb", // gray-200 strong: "#f9fafb", // gray-50 inverted: "#000000", // black }, }, }, boxShadow: { // light "tremor-input": "0 1px 2px 0 rgb(0 0 0 / 0.05)", "tremor-card": "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)", "tremor-dropdown": "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)", // dark "dark-tremor-input": "0 1px 2px 0 rgb(0 0 0 / 0.05)", "dark-tremor-card": "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)", "dark-tremor-dropdown": "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)", }, borderRadius: { "tremor-small": "0.375rem", "tremor-default": "0.5rem", "tremor-full": "9999px", }, fontSize: { "tremor-label": ["0.75rem"], "tremor-default": ["0.875rem", { lineHeight: "1.25rem" }], "tremor-title": ["1.125rem", { lineHeight: "1.75rem" }], "tremor-metric": ["1.875rem", { lineHeight: "2.25rem" }], }, }, }, safelist: [ { pattern: /^(bg-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, variants: ["hover", "ui-selected"], }, { pattern: /^(text-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, variants: ["hover", "ui-selected"], }, { pattern: /^(border-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, variants: ["hover", "ui-selected"], }, { pattern: /^(ring-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, }, { pattern: /^(stroke-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, }, { pattern: /^(fill-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, }, ], plugins: [require("@headlessui/tailwindcss")], }; module.exports = { ...tremor, darkMode: ["class"], content: [ "./pages/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./app/**/*.{ts,tsx}", "./src/**/*.{ts,tsx}", ...tremor.content, ], theme: { ...tremor.theme, container: { center: true, padding: "2rem", screens: { "2xl": "1400px", }, }, extend: { ...tremor.theme.extend, colors: { ...tremor.theme.extend.colors, border: "hsl(var(--border))", input: "hsl(var(--input))", ring: "hsl(var(--ring))", background: "hsl(var(--background))", foreground: "hsl(var(--foreground))", primary: { DEFAULT: "hsl(var(--primary))", foreground: "hsl(var(--primary-foreground))", }, secondary: { DEFAULT: "hsl(var(--secondary))", foreground: "hsl(var(--secondary-foreground))", }, destructive: { DEFAULT: "hsl(var(--destructive))", foreground: "hsl(var(--destructive-foreground))", }, muted: { DEFAULT: "hsl(var(--muted))", foreground: "hsl(var(--muted-foreground))", }, accent: { DEFAULT: "hsl(var(--accent))", foreground: "hsl(var(--accent-foreground))", }, popover: { DEFAULT: "hsl(var(--popover))", foreground: "hsl(var(--popover-foreground))", }, card: { DEFAULT: "hsl(var(--card))", foreground: "hsl(var(--card-foreground))", }, }, borderRadius: { lg: "var(--radius)", md: "calc(var(--radius) - 2px)", sm: "calc(var(--radius) - 4px)", ...tremor.theme.extend.borderRadius, }, keyframes: { "accordion-down": { from: { height: 0 }, to: { height: "var(--radix-accordion-content-height)" }, }, "accordion-up": { from: { height: "var(--radix-accordion-content-height)" }, to: { height: 0 }, }, }, animation: { "accordion-down": "accordion-down 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out", }, }, }, plugins: [require("tailwindcss-animate"), ...tremor.plugins], }; ================ File: tsconfig.json ================ { "compilerOptions": { "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "incremental": true, "plugins": [ { "name": "next" } ], "paths": { "@/*": ["./src/*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"] }