chore: normalize line endings + remove old reservation-platform

This commit is contained in:
Till Tomczak 2025-05-19 13:23:06 +02:00
parent 5dd1b7b78b
commit c0adbad773
161 changed files with 122 additions and 28427 deletions

41
.gitattributes vendored
View File

@ -1 +1,40 @@
packages/reservation-platform/docker/images/*.tar.xz filter=lfs diff=lfs merge=lfs -text frontend/docker/images/*.tar.xz filter=lfs diff=lfs merge=lfs -text
# Shell-Skripte: immer LF
*.sh text eol=lf
# Windows-Batch-Dateien: immer CRLF
*.bat text eol=crlf
*.cmd text eol=crlf
# Python, JS, HTML etc.: bevorzugt LF (aber nicht erzwungen)
*.py text eol=lf
*.js text eol=lf
*.ts text eol=lf
*.html text eol=lf
*.css text eol=lf
*.json text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.md text eol=lf
# Konfigurationsdateien
*.env text eol=lf
*.conf text eol=lf
*.ini text eol=lf
# Binärdateien
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.pdf binary
*.ico binary
*.ttf binary
*.woff binary
*.zip binary
*.tar binary
*.gz binary
*.7z binary
*.exe binary
*.dll binary

View File

@ -1,23 +1,28 @@
# Dokumentation MYP - Manage your Printer # Dokumentation MYP - Manage your Printer
## Projektbeschreibung ## Projektbeschreibung
MYP (Manage your Printer) ist eine Plattform zur Reservierung von 3D-Druckern, die für die TBA im Werk 040, Berlin-Marienfelde, entwickelt wurde. MYP (Manage your Printer) ist eine Plattform zur Reservierung von 3D-Druckern, die für die TBA im Werk 040, Berlin-Marienfelde, entwickelt wurde.
## Projektstruktur ## Projektstruktur
- `backend/`: Flask-Backend für die API-Anbindung und Datenbankzugriff - `backend/`: Flask-Backend für die API-Anbindung und Datenbankzugriff
- `packages/reservation-platform/`: Next.js Frontend für die Benutzeroberfläche - `frontend/`: Next.js Frontend für die Benutzeroberfläche
- `docs/`: Ausführliche Dokumentationen, Datenbankschema und Diagramme - `docs/`: Ausführliche Dokumentationen, Datenbankschema und Diagramme
- `scripts/`: Deployment- und Setup-Skripte - `scripts/`: Deployment- und Setup-Skripte
- `logs/`: Fehlerprotokolle und Logs - `logs/`: Fehlerprotokolle und Logs
## Umfassende Dokumentation ## Umfassende Dokumentation
Detaillierte Dokumentationen finden Sie in den folgenden Dateien: Detaillierte Dokumentationen finden Sie in den folgenden Dateien:
- [Technische Dokumentation](docs/README.md) - [Technische Dokumentation](docs/README.md)
- [Datenbankstruktur](docs/MYP.dbml) - [Datenbankstruktur](docs/MYP.dbml)
- [Aktueller Projektstand](docs/Aktueller%20Stand.md) - [Aktueller Projektstand](docs/Aktueller%20Stand.md)
- [IHK-Dokumentation](docs/Dokumentation_IHK.md) - [IHK-Dokumentation](docs/Dokumentation_IHK.md)
## Herausforderungen und Komplikationen ## Herausforderungen und Komplikationen
- Netzwerkanbindung - Netzwerkanbindung
- Ermitteln der Schnittstellen der Drucker - Ermitteln der Schnittstellen der Drucker
- Auswahl der Anbindung, Entwickeln eines Netzwerkkonzeptes - Auswahl der Anbindung, Entwickeln eines Netzwerkkonzeptes
@ -27,6 +32,7 @@ Detaillierte Dokumentationen finden Sie in den folgenden Dateien:
- Netzwerk einrichten, Frontend anbinden - Netzwerk einrichten, Frontend anbinden
## Verwendete Technologien ## Verwendete Technologien
- Backend: Python, Flask - Backend: Python, Flask
- Frontend: Next.js, React, TypeScript - Frontend: Next.js, React, TypeScript
- Datenbank: SQL - Datenbank: SQL
@ -34,7 +40,8 @@ Detaillierte Dokumentationen finden Sie in den folgenden Dateien:
- Raspberry Pi für Druckersteuerung - Raspberry Pi für Druckersteuerung
## Installation und Einsatz ## Installation und Einsatz
Installation und Einrichtung werden durch die Skripte im Verzeichnis `scripts/` unterstützt. Installation und Einrichtung werden durch die Skripte im Verzeichnis `scripts/` unterstützt.
- `scripts/setup/`: Einrichtungsskripte für Backend, Docker und OAuth - `scripts/setup/`: Einrichtungsskripte für Backend, Docker und OAuth
- `scripts/deployment/`: Bereitstellungsskripte für Raspberry Pi - `scripts/deployment/`: Bereitstellungsskripte für Raspberry Pi

View File

@ -2,7 +2,7 @@
MYP *(Manage your Printer)* ist eine Plattform zur Reservierung von 3D-Druckern, die für die TBA im Werk 040, Berlin-Marienfelde, entwickelt wurde. MYP *(Manage your Printer)* ist eine Plattform zur Reservierung von 3D-Druckern, die für die TBA im Werk 040, Berlin-Marienfelde, entwickelt wurde.
> Frontend: https://git.i.mercedes-benz.com/TBA-Berlin-FI/MYP/tree/main/packages/reservation-platform > Frontend: https://git.i.mercedes-benz.com/TBA-Berlin-FI/MYP/tree/main/frontend
> ⚠️ MYP ist zzt. in Entwicklung > ⚠️ MYP ist zzt. in Entwicklung

View File

@ -116,6 +116,7 @@ Die Anwendung ist dann unter http://localhost:5000 erreichbar.
## Datenmodell ## Datenmodell
### Benutzer (User) ### Benutzer (User)
- id (String UUID, Primary Key) - id (String UUID, Primary Key)
- username (String, Unique) - username (String, Unique)
- password_hash (String) - password_hash (String)
@ -124,11 +125,13 @@ Die Anwendung ist dann unter http://localhost:5000 erreichbar.
- role (String, 'admin', 'user' oder 'guest') - role (String, 'admin', 'user' oder 'guest')
### Session ### Session
- id (String UUID, Primary Key) - id (String UUID, Primary Key)
- user_id (String UUID, Foreign Key zu User) - user_id (String UUID, Foreign Key zu User)
- expires_at (DateTime) - expires_at (DateTime)
### Drucker (Printer) ### Drucker (Printer)
- id (String UUID, Primary Key) - id (String UUID, Primary Key)
- name (String) - name (String)
- description (Text) - description (Text)
@ -136,6 +139,7 @@ Die Anwendung ist dann unter http://localhost:5000 erreichbar.
- ip_address (String, IP-Adresse der Tapo-Steckdose) - ip_address (String, IP-Adresse der Tapo-Steckdose)
### Druckauftrag (PrintJob) ### Druckauftrag (PrintJob)
- id (String UUID, Primary Key) - id (String UUID, Primary Key)
- printer_id (String UUID, Foreign Key zu Printer) - printer_id (String UUID, Foreign Key zu Printer)
- user_id (String UUID, Foreign Key zu User) - user_id (String UUID, Foreign Key zu User)
@ -182,4 +186,4 @@ Die Anwendung beinhaltet einen CLI-Befehl `flask check-jobs`, der regelmäßig a
## Kompatibilität mit dem Frontend ## Kompatibilität mit dem Frontend
Das Backend wurde speziell für die Kompatibilität mit dem bestehenden Frontend entwickelt, welches in `/packages/reservation-platform` zu finden ist. Die API-Endpunkte und Datenstrukturen sind so gestaltet, dass sie nahtlos mit dem Frontend zusammenarbeiten. Das Backend wurde speziell für die Kompatibilität mit dem bestehenden Frontend entwickelt, welches in `/frontend` zu finden ist. Die API-Endpunkte und Datenstrukturen sind so gestaltet, dass sie nahtlos mit dem Frontend zusammenarbeiten.

View File

@ -1 +1,51 @@
# Aufräumarbeiten MYP-Projekt (19.05.2025)
## Durchgeführte Änderungen
### Verzeichnisstruktur
- Skriptdateien in logische Kategorien reorganisiert:
- `scripts/setup/`: Einrichtungsskripte
- `scripts/deployment/`: Bereitstellungsskripte für Raspberry Pi
- Neue Verzeichnisse erstellt:
- `logs/`: Für Fehlerprotokolle und Logdateien
- `config/secure/`: Für sensible Konfigurationsdaten
### Dokumentation
- Zentrale `Dokumentation.md` aktualisiert und erweitert
- Entwicklungsrichtlinien von `CLAUDE.md` nach `docs/Entwicklungsrichtlinien.md` verschoben
- README.md korrigiert und Git-Konfliktmarkierungen entfernt
### Sicherheit
- Sensible Daten (CREDENTIALS) in `config/secure/` verschoben
- `.gitignore` aktualisiert, um sensible Dateien und temporäre Dateien auszuschließen
### Dateiorganisation
- Fehlerlogs in `logs/` verschoben
- Temporäre Dateien bereinigt
- Skript-Dateien in sinnvolle Kategorien einsortiert
## Projektstruktur nach Aufräumarbeiten
```
Projektarbeit-MYP/
├── backend/ # Flask-Backend
├── config/
│ └── secure/ # Sensible Konfigurationen
├── docs/ # Projektdokumentation
├── frontend/
├── logs/ # Fehlerprotokolle
└── scripts/
├── deployment/ # Raspberry Pi Deployment
└── setup/ # Einrichtungsskripte
```
## Empfehlungen für zukünftige Arbeiten
- Gemeinsames Datenbankmodell zwischen Backend und Frontend überarbeiten
- Weitere Dokumentation der API-Schnittstellen erstellen
- Testabdeckung erhöhen
- Deployment-Prozess automatisieren

View File

@ -1,44 +1,49 @@
# MYP Project Development Guidelines # MYP Project Development Guidelines
## System Architecture ## System Architecture
- **Frontend**: - **Frontend**:
- Located in `packages/reservation-platform`
- Located in `frontend`
- Runs on a Raspberry Pi connected to company network - Runs on a Raspberry Pi connected to company network
- Has internet access on one interface - Has internet access on one interface
- Connected via LAN to an offline network - Connected via LAN to an offline network
- Serves as the user interface - Serves as the user interface
- Developed by another apprentice as part of IHK project work - Developed by another apprentice as part of IHK project work
- **Backend**: - **Backend**:
- Located in `backend` directory - Located in `backend` directory
- Flask application running on a separate Raspberry Pi - Flask application running on a separate Raspberry Pi
- Connected only to the offline network - Connected only to the offline network
- Communicates with WiFi smart plugs - Communicates with WiFi smart plugs
- Part of my IHK project work for digital networking qualification - Part of my IHK project work for digital networking qualification
- **Printers/Smart Plugs**: - **Printers/Smart Plugs**:
- Printers can only be controlled (on/off) via WiFi smart plugs - Printers can only be controlled (on/off) via WiFi smart plugs
- No other control mechanisms available - No other control mechanisms available
- Smart plugs and printers are equivalent in the system context - Smart plugs and printers are equivalent in the system context
## Build/Run Commands ## Build/Run Commands
- Backend: `cd backend && source venv/bin/activate && python app.py` - Backend: `cd backend && source venv/bin/activate && python app.py`
- Frontend: `cd packages/reservation-platform && pnpm dev` - Frontend: `cd frontend && pnpm dev`
- Run tests: `cd backend && python -m unittest development/tests/tests.py` - Run tests: `cd backend && python -m unittest development/tests/tests.py`
- Run single test: `cd backend && python -m unittest development.tests.tests.MYPBackendTestCase.test_name` - Run single test: `cd backend && python -m unittest development.tests.tests.MYPBackendTestCase.test_name`
- Check jobs manually: `cd backend && source venv/bin/activate && flask check-jobs` - Check jobs manually: `cd backend && source venv/bin/activate && flask check-jobs`
- Lint frontend: `cd packages/reservation-platform && pnpm lint` - Lint frontend: `cd frontend && pnpm lint`
- Format frontend: `cd packages/reservation-platform && npx @biomejs/biome format --write ./src` - Format frontend: `cd frontend && npx @biomejs/biome format --write ./src`
## Code Style ## Code Style
- **Python Backend**: - **Python Backend**:
- Use PEP 8 conventions, 4-space indentation - Use PEP 8 conventions, 4-space indentation
- Line width: 100 characters max - Line width: 100 characters max
- Add docstrings to functions and classes - Add docstrings to functions and classes
- Error handling: Use try/except with specific exceptions - Error handling: Use try/except with specific exceptions
- Naming: snake_case for functions/variables, PascalCase for classes - Naming: snake_case for functions/variables, PascalCase for classes
- **Frontend (Next.js/TypeScript)**: - **Frontend (Next.js/TypeScript)**:
- Use Biome for formatting and linting (line width: 120 chars) - Use Biome for formatting and linting (line width: 120 chars)
- Organize imports automatically with Biome - Organize imports automatically with Biome
- Use TypeScript types for all code - Use TypeScript types for all code
@ -46,6 +51,7 @@
- Naming: camelCase for functions/variables, PascalCase for components - Naming: camelCase for functions/variables, PascalCase for components
## Work Guidelines ## Work Guidelines
- All changes must be committed to git - All changes must be committed to git
- Work efficiently and cost-effectively - Work efficiently and cost-effectively
- Don't repeatedly try the same solution if it doesn't work - Don't repeatedly try the same solution if it doesn't work

View File

@ -1,27 +0,0 @@
# 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/

View File

@ -1,43 +0,0 @@
# 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

View File

@ -1,34 +0,0 @@
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"]

View File

@ -1,32 +0,0 @@
# 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)
```

View File

@ -1,19 +0,0 @@
{
"$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"
}
}
}
}

View File

@ -1,17 +0,0 @@
{
"$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"
}
}

View File

@ -1,21 +0,0 @@
{
"name": "myp-frontend-debug-server",
"version": "1.0.0",
"description": "Debug-Server für das MYP Frontend",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js"
},
"dependencies": {
"express": "^4.18.2",
"systeminformation": "^5.18.15",
"ejs": "^3.1.9",
"os-utils": "^0.0.14",
"node-fetch": "^2.6.7",
"socket.io": "^4.7.2"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
}

View File

@ -1,186 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MYP Frontend Debug-Server</title>
<link rel="stylesheet" href="/css/style.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🛠️</text></svg>">
<script src="/socket.io/socket.io.js"></script>
</head>
<body>
<header>
<div class="header-content">
<h1>MYP Frontend Debug-Server</h1>
<div class="server-info">
<span id="hostname"><strong>Hostname:</strong> <%= hostname %></span>
<% for(const [interface, ip] of Object.entries(ipAddresses)) { %>
<span><strong><%= interface %>:</strong> <%= ip %></span>
<% } %>
<span id="current-time"><strong>Zeitstempel:</strong> <%= timestamp %></span>
</div>
</div>
</header>
<nav>
<button class="nav-button active" data-panel="system">System</button>
<button class="nav-button" data-panel="network">Netzwerk</button>
<button class="nav-button" data-panel="services">Dienste</button>
<button class="nav-button" data-panel="tools">Diagnose-Tools</button>
<button class="nav-button" data-panel="realtime">Echtzeit-Monitor</button>
</nav>
<main>
<!-- System Panel -->
<section id="system" class="panel active">
<h2>Systeminformationen</h2>
<div class="loader" id="system-loader">Lade Daten...</div>
<div class="card-container" id="system-container" style="display: none;">
<div class="card">
<h3>Betriebssystem</h3>
<div id="os-info"></div>
</div>
<div class="card">
<h3>Prozessor</h3>
<div id="cpu-info"></div>
</div>
<div class="card">
<h3>Arbeitsspeicher</h3>
<div id="memory-info"></div>
<div class="progress-container">
<div id="memory-bar" class="progress-bar"></div>
</div>
</div>
<div class="card full-width">
<h3>Festplatten</h3>
<div id="disk-info"></div>
</div>
</div>
</section>
<!-- Network Panel -->
<section id="network" class="panel">
<h2>Netzwerkinformationen</h2>
<div class="loader" id="network-loader">Lade Daten...</div>
<div class="card-container" id="network-container" style="display: none;">
<div class="card full-width">
<h3>Netzwerkschnittstellen</h3>
<div id="network-interfaces"></div>
</div>
<div class="card">
<h3>DNS-Server</h3>
<div id="dns-servers"></div>
</div>
<div class="card">
<h3>Standard-Gateway</h3>
<div id="default-gateway"></div>
</div>
<div class="card full-width">
<h3>Netzwerkstatistiken</h3>
<div id="network-stats"></div>
</div>
</div>
</section>
<!-- Services Panel -->
<section id="services" class="panel">
<h2>Dienststatus</h2>
<div class="loader" id="services-loader">Lade Daten...</div>
<div class="card-container" id="services-container" style="display: none;">
<div class="card">
<h3>Frontend (Next.js)</h3>
<div id="frontend-status"></div>
</div>
<div class="card">
<h3>Backend (Flask)</h3>
<div id="backend-status"></div>
</div>
<div class="card full-width">
<h3>Docker Container</h3>
<div id="docker-container-list"></div>
</div>
</div>
</section>
<!-- Tools Panel -->
<section id="tools" class="panel">
<h2>Netzwerk-Diagnosetools</h2>
<div class="card-container">
<div class="card tool-card">
<h3>Ping</h3>
<div class="tool-input">
<input type="text" id="ping-host" placeholder="Hostname oder IP-Adresse">
<button id="ping-button">Ping</button>
</div>
<pre id="ping-result" class="result-box">Geben Sie einen Hostnamen oder eine IP-Adresse ein...</pre>
</div>
<div class="card tool-card">
<h3>Traceroute</h3>
<div class="tool-input">
<input type="text" id="traceroute-host" placeholder="Hostname oder IP-Adresse">
<button id="traceroute-button">Traceroute</button>
</div>
<pre id="traceroute-result" class="result-box">Geben Sie einen Hostnamen oder eine IP-Adresse ein...</pre>
</div>
<div class="card tool-card">
<h3>DNS-Lookup</h3>
<div class="tool-input">
<input type="text" id="nslookup-host" placeholder="Hostname oder IP-Adresse">
<button id="nslookup-button">NSLookup</button>
</div>
<pre id="nslookup-result" class="result-box">Geben Sie einen Hostnamen oder eine IP-Adresse ein...</pre>
</div>
</div>
</section>
<!-- Realtime Panel -->
<section id="realtime" class="panel">
<h2>Echtzeit-Monitoring</h2>
<div class="card-container">
<div class="card">
<h3>CPU-Auslastung</h3>
<div class="gauge-container">
<div class="gauge">
<div class="gauge-body">
<div id="cpu-gauge" class="gauge-fill"></div>
<div class="gauge-cover"></div>
</div>
<div id="cpu-percentage" class="gauge-value">0%</div>
</div>
</div>
<div id="cpu-cores-container"></div>
</div>
<div class="card">
<h3>Arbeitsspeicher</h3>
<div class="gauge-container">
<div class="gauge">
<div class="gauge-body">
<div id="memory-gauge" class="gauge-fill"></div>
<div class="gauge-cover"></div>
</div>
<div id="memory-percentage" class="gauge-value">0%</div>
</div>
</div>
<div id="memory-details"></div>
</div>
<div class="card full-width">
<h3>Netzwerkdurchsatz</h3>
<div id="network-throughput"></div>
<canvas id="network-chart"></canvas>
</div>
</div>
</section>
</main>
<footer>
<p>&copy; 2025 MYP (Manage your Printer) | Debug-Server v1.0.0</p>
<p>Netzwerk- und Systemdiagnose-Tool für das MYP Frontend</p>
</footer>
<script src="/js/script.js"></script>
</body>
</html>

View File

@ -1,471 +0,0 @@
// Frontend Debug-Server für MYP
const express = require('express');
const path = require('path');
const http = require('http');
const si = require('systeminformation');
const os = require('os');
const osUtils = require('os-utils');
const { exec } = require('child_process');
const fetch = require('node-fetch');
const socketIo = require('socket.io');
// Konfiguration
const app = express();
const server = http.createServer(app);
const io = socketIo(server);
const PORT = 6666;
const FRONTEND_PORT = 3000;
const FRONTEND_HOST = 'localhost';
const BACKEND_HOST = 'localhost';
const BACKEND_PORT = 5000;
// View Engine einrichten
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, '../public/views'));
app.use(express.static(path.join(__dirname, '../public')));
app.use(express.json());
// Hauptseite rendern
app.get('/', async (req, res) => {
const hostname = os.hostname();
const networkInterfaces = os.networkInterfaces();
const ipAddresses = {};
// IP-Adressen sammeln
Object.keys(networkInterfaces).forEach(interfaceName => {
const interfaceInfo = networkInterfaces[interfaceName];
const ipv4Addresses = interfaceInfo.filter(info => info.family === 'IPv4');
if (ipv4Addresses.length > 0) {
ipAddresses[interfaceName] = ipv4Addresses[0].address;
}
});
// Rendere die Hauptseite mit Basisdaten
res.render('index', {
hostname: hostname,
ipAddresses: ipAddresses,
timestamp: new Date().toLocaleString('de-DE'),
});
});
// API-Endpunkte
// Systeminformationen
app.get('/api/system', async (req, res) => {
try {
const [cpu, mem, osInfo, diskLayout, fsSize] = await Promise.all([
si.cpu(),
si.mem(),
si.osInfo(),
si.diskLayout(),
si.fsSize()
]);
const data = {
cpu: {
manufacturer: cpu.manufacturer,
brand: cpu.brand,
speed: cpu.speed,
cores: cpu.cores,
physicalCores: cpu.physicalCores
},
memory: {
total: formatBytes(mem.total),
free: formatBytes(mem.free),
used: formatBytes(mem.used),
usedPercent: Math.round(mem.used / mem.total * 100)
},
os: {
platform: osInfo.platform,
distro: osInfo.distro,
release: osInfo.release,
arch: osInfo.arch,
uptime: formatUptime(os.uptime())
},
filesystem: fsSize.map(fs => ({
fs: fs.fs,
type: fs.type,
size: formatBytes(fs.size),
used: formatBytes(fs.used),
available: formatBytes(fs.available),
mount: fs.mount,
usePercent: Math.round(fs.use)
})),
disks: diskLayout.map(disk => ({
device: disk.device,
type: disk.type,
name: disk.name,
size: formatBytes(disk.size)
}))
};
res.json(data);
} catch (error) {
console.error('Fehler beim Abrufen der Systemdaten:', error);
res.status(500).json({ error: 'Fehler beim Abrufen der Systemdaten' });
}
});
// Netzwerkinformationen
app.get('/api/network', async (req, res) => {
try {
const [netInterfaces, netStats] = await Promise.all([
si.networkInterfaces(),
si.networkStats()
]);
const dns = await getDnsServers();
const gateway = await getDefaultGateway();
const data = {
interfaces: netInterfaces.map(iface => ({
iface: iface.iface,
ip4: iface.ip4,
ip6: iface.ip6,
mac: iface.mac,
internal: iface.internal,
operstate: iface.operstate,
type: iface.type,
speed: iface.speed,
dhcp: iface.dhcp
})),
stats: netStats.map(stat => ({
iface: stat.iface,
rx_bytes: formatBytes(stat.rx_bytes),
tx_bytes: formatBytes(stat.tx_bytes),
rx_sec: formatBytes(stat.rx_sec),
tx_sec: formatBytes(stat.tx_sec)
})),
dns: dns,
gateway: gateway
};
res.json(data);
} catch (error) {
console.error('Fehler beim Abrufen der Netzwerkdaten:', error);
res.status(500).json({ error: 'Fehler beim Abrufen der Netzwerkdaten' });
}
});
// Dienststatus
app.get('/api/services', async (req, res) => {
try {
// Prüfen ob Frontend (Next.js) läuft
const frontendStatus = await checkServiceStatus(FRONTEND_HOST, FRONTEND_PORT);
// Prüfen ob Backend (Flask) läuft
const backendStatus = await checkServiceStatus(BACKEND_HOST, BACKEND_PORT);
// Docker-Container Status abrufen
const containers = await getDockerContainers();
const data = {
frontend: {
name: 'Next.js Frontend',
status: frontendStatus ? 'online' : 'offline',
port: FRONTEND_PORT,
host: FRONTEND_HOST
},
backend: {
name: 'Flask Backend',
status: backendStatus ? 'online' : 'offline',
port: BACKEND_PORT,
host: BACKEND_HOST
},
docker: {
containers: containers
}
};
res.json(data);
} catch (error) {
console.error('Fehler beim Abrufen der Dienststatus:', error);
res.status(500).json({ error: 'Fehler beim Abrufen der Dienststatus' });
}
});
// Ping-Endpunkt für Netzwerkdiagnose
app.get('/api/ping/:host', (req, res) => {
const host = req.params.host;
// Sicherheitscheck für den Hostnamen
if (!isValidHostname(host)) {
return res.status(400).json({ error: 'Ungültiger Hostname oder IP-Adresse' });
}
// Ping-Befehl ausführen
exec(`ping -n 4 ${host}`, (error, stdout, stderr) => {
if (error) {
return res.json({
success: false,
output: stderr || stdout,
error: error.message
});
}
res.json({
success: true,
output: stdout
});
});
});
// Traceroute-Endpunkt für Netzwerkdiagnose
app.get('/api/traceroute/:host', (req, res) => {
const host = req.params.host;
// Sicherheitscheck für den Hostnamen
if (!isValidHostname(host)) {
return res.status(400).json({ error: 'Ungültiger Hostname oder IP-Adresse' });
}
// Traceroute-Befehl ausführen (Windows: tracert, Unix: traceroute)
const command = process.platform === 'win32' ? 'tracert' : 'traceroute';
exec(`${command} ${host}`, (error, stdout, stderr) => {
// Traceroute kann einen Nicht-Null-Exit-Code zurückgeben, selbst wenn es teilweise erfolgreich ist
res.json({
success: true,
output: stdout,
error: stderr
});
});
});
// DNS-Lookup-Endpunkt für Netzwerkdiagnose
app.get('/api/nslookup/:host', (req, res) => {
const host = req.params.host;
// Sicherheitscheck für den Hostnamen
if (!isValidHostname(host)) {
return res.status(400).json({ error: 'Ungültiger Hostname oder IP-Adresse' });
}
// NSLookup-Befehl ausführen
exec(`nslookup ${host}`, (error, stdout, stderr) => {
if (error) {
return res.json({
success: false,
output: stderr || stdout,
error: error.message
});
}
res.json({
success: true,
output: stdout
});
});
});
// Echtzeit-Updates über WebSockets
io.on('connection', (socket) => {
console.log('Neue WebSocket-Verbindung');
// CPU- und Arbeitsspeichernutzung im Intervall senden
const systemMonitorInterval = setInterval(async () => {
try {
const [cpu, mem] = await Promise.all([
si.currentLoad(),
si.mem()
]);
socket.emit('system-stats', {
cpu: {
load: Math.round(cpu.currentLoad),
cores: cpu.cpus.map(core => Math.round(core.load))
},
memory: {
total: mem.total,
used: mem.used,
free: mem.free,
usedPercent: Math.round(mem.used / mem.total * 100)
}
});
} catch (error) {
console.error('Fehler beim Senden der Systemstatistiken:', error);
}
}, 2000);
// Netzwerkstatistiken im Intervall senden
const networkMonitorInterval = setInterval(async () => {
try {
const netStats = await si.networkStats();
socket.emit('network-stats', {
stats: netStats.map(stat => ({
iface: stat.iface,
rx_bytes: stat.rx_bytes,
tx_bytes: stat.tx_bytes,
rx_sec: stat.rx_sec,
tx_sec: stat.tx_sec
}))
});
} catch (error) {
console.error('Fehler beim Senden der Netzwerkstatistiken:', error);
}
}, 2000);
// Aufräumen, wenn die Verbindung getrennt wird
socket.on('disconnect', () => {
console.log('WebSocket-Verbindung getrennt');
clearInterval(systemMonitorInterval);
clearInterval(networkMonitorInterval);
});
});
// Hilfsfunktionen
// Bytes in lesbare Größen formatieren
function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
}
// Uptime in lesbare Zeit formatieren
function formatUptime(seconds) {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
return `${days} Tage, ${hours} Stunden, ${minutes} Minuten, ${secs} Sekunden`;
}
// Service-Status überprüfen
async function checkServiceStatus(host, port) {
return new Promise(resolve => {
const socket = new (require('net').Socket)();
socket.setTimeout(1000);
socket.on('connect', () => {
socket.destroy();
resolve(true);
});
socket.on('timeout', () => {
socket.destroy();
resolve(false);
});
socket.on('error', () => {
socket.destroy();
resolve(false);
});
socket.connect(port, host);
});
}
// Docker-Container abfragen
async function getDockerContainers() {
return new Promise((resolve) => {
exec('docker ps --format "{{.ID}},{{.Image}},{{.Status}},{{.Ports}},{{.Names}}"', (error, stdout) => {
if (error) {
resolve([]);
return;
}
const containers = [];
const lines = stdout.trim().split('\n');
for (const line of lines) {
if (line) {
const [id, image, status, ports, name] = line.split(',');
containers.push({ id, image, status, ports, name });
}
}
resolve(containers);
});
});
}
// DNS-Server abfragen
async function getDnsServers() {
return new Promise((resolve) => {
if (process.platform === 'win32') {
// Windows: DNS-Server über PowerShell abfragen
exec('powershell.exe -Command "Get-DnsClientServerAddress -AddressFamily IPv4 | Select-Object -ExpandProperty ServerAddresses"', (error, stdout) => {
if (error) {
resolve(['DNS-Server konnten nicht ermittelt werden']);
return;
}
const servers = stdout.trim().split('\r\n').filter(Boolean);
resolve(servers);
});
} else {
// Unix: DNS-Server aus /etc/resolv.conf lesen
exec('cat /etc/resolv.conf | grep nameserver | cut -d " " -f 2', (error, stdout) => {
if (error) {
resolve(['DNS-Server konnten nicht ermittelt werden']);
return;
}
const servers = stdout.trim().split('\n').filter(Boolean);
resolve(servers);
});
}
});
}
// Standard-Gateway abfragen
async function getDefaultGateway() {
return new Promise((resolve) => {
if (process.platform === 'win32') {
// Windows: Gateway über PowerShell abfragen
exec('powershell.exe -Command "Get-NetRoute -DestinationPrefix 0.0.0.0/0 | Select-Object -ExpandProperty NextHop"', (error, stdout) => {
if (error) {
resolve('Gateway konnte nicht ermittelt werden');
return;
}
resolve(stdout.trim());
});
} else {
// Unix: Gateway aus den Routentabellen lesen
exec("ip route | grep default | awk '{print $3}'", (error, stdout) => {
if (error) {
resolve('Gateway konnte nicht ermittelt werden');
return;
}
resolve(stdout.trim());
});
}
});
}
// Validierung des Hostnamens für Sicherheit
function isValidHostname(hostname) {
// Längenprüfung
if (!hostname || hostname.length > 255) {
return false;
}
// Erlaubte Hostnamen
if (hostname === 'localhost' || hostname === '127.0.0.1') {
return true;
}
// IPv4-Prüfung
const ipv4Regex = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
if (ipv4Regex.test(hostname)) {
return true;
}
// Hostname-Prüfung
const hostnameRegex = /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$/;
return hostnameRegex.test(hostname);
}
// Server starten
server.listen(PORT, () => {
console.log(`MYP Frontend Debug-Server läuft auf http://localhost:${PORT}`);
});

View File

@ -1,31 +0,0 @@
#!/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

View File

@ -1,37 +0,0 @@
{
debug
}
# Hauptdomain für die Anwendung
m040tbaraspi001.de040.corpintra.net, m040tbaraspi001, localhost {
reverse_proxy myp-rp:3000
tls internal
# Erlaube HTTP -> HTTPS Redirects für OAuth
@oauth path /auth/login/callback*
handle @oauth {
header Cache-Control "no-cache"
reverse_proxy myp-rp:3000
}
# Allgemeine Header für Sicherheit und Caching
header {
# Sicherheitsheader
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
# Cache-Control für statische Assets
@static {
path *.js *.css *.png *.jpg *.svg *.ico *.woff *.woff2
}
header @static Cache-Control "public, max-age=86400"
# Keine Caches für dynamische Inhalte
@dynamic {
not path *.js *.css *.png *.jpg *.svg *.ico *.woff *.woff2
}
header @dynamic Cache-Control "no-store, no-cache, must-revalidate"
}
}

View File

@ -1,30 +0,0 @@
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
environment:
- NEXT_PUBLIC_API_URL=http://192.168.0.105:5000
- OAUTH_CLIENT_ID=client_id
- OAUTH_CLIENT_SECRET=client_secret
env_file: "/srv/myp-env/github.env"
volumes:
- /srv/MYP-DB:/usr/src/app/db
restart: unless-stopped
# Füge Healthcheck hinzu für besseres Monitoring
healthcheck:
test: ["CMD", "wget", "--spider", "http://localhost:3000"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s

View File

@ -1,36 +0,0 @@
#!/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"

View File

@ -1,2 +0,0 @@
caddy_2.8.tar.xz filter=lfs diff=lfs merge=lfs -text
myp-rp_latest.tar.xz filter=lfs diff=lfs merge=lfs -text

Binary file not shown.

Binary file not shown.

View File

@ -1,68 +0,0 @@
#!/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

View File

@ -1,116 +0,0 @@
# **Detaillierte Dokumentation des Admin-Dashboards**
In diesem Abschnitt werde ich die Funktionen und Nutzung des Admin-Dashboards genauer beschreiben, einschließlich der verschiedenen Module, Diagramme und deren Zweck.
---
## **1. Überblick über das Admin-Dashboard**
Das Admin-Dashboard ist der zentrale Verwaltungsbereich für Administratoren. Es bietet Funktionen wie die Verwaltung von Druckern, Benutzern und Druckaufträgen sowie detaillierte Statistiken und Analysen.
### **1.1. Navigation**
Das Dashboard enthält ein Sidebar-Menü mit den folgenden Hauptbereichen:
1. **Dashboard:** Übersicht der wichtigsten Statistiken.
2. **Benutzer:** Verwaltung von Benutzerkonten.
3. **Drucker:** Hinzufügen, Bearbeiten und Verwalten von Druckern.
4. **Druckaufträge:** Einsicht in alle Druckaufträge und deren Status.
5. **Einstellungen:** Konfiguration der Anwendung.
6. **Über MYP:** Informationen über das Projekt und den Entwickler.
Die Sidebar wird in der Datei `src/app/admin/admin-sidebar.tsx` definiert und dynamisch basierend auf der aktuellen Seite hervorgehoben.
---
## **2. Funktionen des Admin-Dashboards**
### **2.1. Benutzerverwaltung**
- **Datei:** `src/app/admin/users/page.tsx`
- **Beschreibung:** Ermöglicht das Anzeigen, Bearbeiten und Löschen von Benutzerkonten.
- **Funktionen:**
- Anzeige einer Liste aller registrierten Benutzer.
- Bearbeiten von Benutzerrollen (z. B. „admin“ oder „user“).
- Deaktivieren oder Löschen von Benutzerkonten.
---
### **2.2. Druckerverwaltung**
- **Datei:** `src/app/admin/printers/page.tsx`
- **Beschreibung:** Verwaltung der Drucker, einschließlich Hinzufügen, Bearbeiten und Deaktivieren.
- **Funktionen:**
- Statusanzeige der Drucker (aktiv/inaktiv).
- Hinzufügen neuer Drucker mit Namen und Beschreibung.
- Löschen oder Bearbeiten bestehender Drucker.
---
### **2.3. Druckaufträge**
- **Datei:** `src/app/admin/jobs/page.tsx`
- **Beschreibung:** Übersicht aller Druckaufträge, einschließlich Details wie Startzeit, Dauer und Status.
- **Funktionen:**
- Filtern nach Benutzern, Druckern oder Status (abgeschlossen, abgebrochen).
- Anzeigen von Abbruchgründen und Fehlermeldungen.
- Sortieren nach Zeit oder Benutzer.
---
### **2.4. Einstellungen**
- **Datei:** `src/app/admin/settings/page.tsx`
- **Beschreibung:** Konfigurationsseite für die Anwendung.
- **Funktionen:**
- Ändern von globalen Einstellungen wie Standardzeiten oder Fehlerrichtlinien.
- Download von Daten (z. B. Export der Druckhistorie).
---
## **3. Statistiken und Diagramme**
Das Admin-Dashboard enthält interaktive Diagramme, die wichtige Statistiken visualisieren. Hier einige der zentralen Diagramme:
### **3.1. Abbruchgründe**
- **Datei:** `src/app/admin/charts/printer-error-chart.tsx`
- **Beschreibung:** Zeigt die Häufigkeit der Abbruchgründe für Druckaufträge in einem Balkendiagramm.
- **Nutzen:** Identifiziert häufige Probleme wie Materialmangel oder Düsenverstopfungen.
---
### **3.2. Fehlerraten**
- **Datei:** `src/app/admin/charts/printer-error-rate.tsx`
- **Beschreibung:** Zeigt die prozentuale Fehlerrate für jeden Drucker in einem Balkendiagramm.
- **Nutzen:** Ermöglicht die Überwachung und Identifizierung von problematischen Druckern.
---
### **3.3. Druckvolumen**
- **Datei:** `src/app/admin/charts/printer-volume.tsx`
- **Beschreibung:** Zeigt das Druckvolumen für heute, diese Woche und diesen Monat.
- **Nutzen:** Vergleich des Druckeroutputs über verschiedene Zeiträume.
---
### **3.4. Prognostizierte Nutzung**
- **Datei:** `src/app/admin/charts/printer-forecast.tsx`
- **Beschreibung:** Ein Bereichsdiagramm zeigt die erwartete Druckernutzung pro Wochentag.
- **Nutzen:** Hilft bei der Planung von Wartungsarbeiten oder Ressourcenzuweisungen.
---
### **3.5. Druckerauslastung**
- **Datei:** `src/app/admin/charts/printer-utilization.tsx`
- **Beschreibung:** Zeigt die aktuelle Nutzung eines Druckers in Prozent in einem Kreisdiagramm.
- **Nutzen:** Überwacht die Auslastung und identifiziert ungenutzte Ressourcen.
---
## **4. Rollenbasierte Zugriffssteuerung**
Das Admin-Dashboard ist nur für Benutzer mit der Rolle „admin“ zugänglich. Nicht berechtigte Benutzer werden auf die Startseite umgeleitet. Die Zugriffssteuerung erfolgt durch folgende Logik:
- **Datei:** `src/app/admin/layout.tsx`
- **Funktion:** `validateRequest` prüft die Rolle des aktuellen Benutzers.
- **Umleitung:** Falls die Rolle unzureichend ist, wird der Benutzer automatisch umgeleitet:
```typescript
if (guard(user, IS_NOT, UserRole.ADMIN)) {
redirect("/");
}
```
Nächster Schritt: [=> API-Endpunkte und deren Nutzung](./API.md)

View File

@ -1,79 +0,0 @@
# **Technische Architektur und Codeaufbau**
In diesem Abschnitt erläutere ich die Architektur und Struktur des MYP-Projekts sowie die Funktionalitäten der zentralen Komponenten.
---
## **1. Technische Architektur**
### **1.1. Architekturübersicht**
MYP basiert auf einer modernen Webanwendungsarchitektur:
- **Frontend:** Entwickelt mit React und Next.js. Stellt die Benutzeroberfläche bereit.
- **Backend:** Nutzt Node.js und Drizzle ORM für die Datenbankinteraktion und Geschäftslogik.
- **Datenbank:** SQLite zur Speicherung von Nutzerdaten, Druckaufträgen und Druckerkonfigurationen.
- **Containerisierung:** Docker wird verwendet, um die Anwendung in isolierten Containern bereitzustellen.
- **Webserver:** Caddy dient als Reverse Proxy mit HTTPS-Unterstützung.
### **1.2. Modulübersicht**
- **Datenfluss:** Die Anwendung ist stark datengetrieben. API-Routen werden genutzt, um Daten zwischen Frontend und Backend auszutauschen.
- **Rollenbasierter Zugriff:** Über ein Berechtigungssystem können Administratoren und Benutzer unterschiedliche Funktionen nutzen.
---
## **2. Codeaufbau**
### **2.1. Ordnerstruktur**
Die Datei `repomix-output.txt` zeigt eine strukturierte Übersicht des Projekts. Nachfolgend einige wichtige Verzeichnisse:
| **Verzeichnis** | **Inhalt** |
|--------------------------|---------------------------------------------------------------------------|
| `src/app` | Next.js-Seiten und Komponenten für Benutzer und Admins. |
| `src/components` | Wiederverwendbare UI-Komponenten wie Karten, Diagramme, Buttons etc. |
| `src/server` | Backend-Logik, Authentifizierung und Datenbankinteraktionen. |
| `src/utils` | Hilfsfunktionen für Analysen, Validierungen und Datenbankzugriffe. |
| `drizzle` | Datenbank-Migrationsdateien und Metadaten. |
| `docker` | Docker-Konfigurations- und Bereitstellungsskripte. |
---
### **2.2. Hauptdateien**
#### **Frontend**
- **`src/app/page.tsx`:** Startseite der Anwendung.
- **`src/app/admin/`:** Admin-spezifische Seiten, z. B. Druckerverwaltung oder Fehlerstatistiken.
- **`src/components/ui/`:** UI-Komponenten wie Dialoge, Formulare und Tabellen.
#### **Backend**
- **`src/server/auth/`:** Authentifizierung und Benutzerrollenmanagement.
- **`src/server/actions/`:** Funktionen zur Interaktion mit Druckaufträgen und Druckern.
- **`src/utils/`:** Analyse und Verarbeitung von Druckdaten (z. B. Fehlerquoten und Auslastung).
#### **Datenbank**
- **`drizzle/0000_overjoyed_strong_guy.sql`:** SQLite-Datenbankschema mit Tabellen für Drucker, Benutzer und Druckaufträge.
- **`drizzle.meta/`:** Metadaten zur Datenbankmigration.
---
### **2.3. Datenbankschema**
Das Schema enthält vier Haupttabellen:
1. **`user`:** Speichert Benutzerinformationen, einschließlich Rollen und E-Mail-Adressen.
2. **`printer`:** Beschreibt die Drucker, ihren Status und ihre Eigenschaften.
3. **`printJob`:** Zeichnet Druckaufträge auf, einschließlich Startzeit, Dauer und Abbruchgrund.
4. **`session`:** Verwaltert Benutzer-Sitzungen und Ablaufzeiten.
---
## **3. Wichtige Funktionen**
### **3.1. Authentifizierung**
Das System nutzt OAuth zur Anmeldung. Benutzerrollen werden in der Tabelle `user` gespeichert und im Backend überprüft.
### **3.2. Statistiken**
- **Fehlerrate:** Berechnet die Häufigkeit von Abbrüchen für jeden Drucker.
- **Auslastung:** Prozentuale Nutzung der Drucker, basierend auf geplanten und abgeschlossenen Druckaufträgen.
- **Prognosen:** Verwenden historische Daten, um zukünftige Drucknutzungen vorherzusagen.
### **3.3. API-Endpunkte**
- **`src/app/api/printers/`:** Zugriff auf Druckerkonfigurationsdaten.
- **`src/app/api/job/[jobId]/`:** Verwaltung einzelner Druckaufträge.
Nächster Schritt: [=> Datenbank und Analytik-Funktionen](./Datenbank.md)

View File

@ -1,150 +0,0 @@
# **Bereitstellungsdetails und Best Practices**
In diesem Abschnitt erläutere ich, wie das MYP-Projekt auf einem Server bereitgestellt wird, sowie empfohlene Praktiken zur Verwaltung und Optimierung des Systems.
---
## **1. Bereitstellungsschritte**
### **1.1. Voraussetzungen**
- **Server:** Raspberry Pi mit installiertem Raspbian Lite.
- **Docker:** Docker und Docker Compose müssen vorab installiert sein.
- **Netzwerk:** Der Server muss über eine statische IP-Adresse oder einen DNS-Namen erreichbar sein.
### **1.2. Vorbereitung**
#### **1.2.1. Docker-Images erstellen und speichern**
Führen Sie die folgenden Schritte auf dem Entwicklungssystem aus:
1. **Images erstellen:**
```bash
bash docker/build.sh
```
2. **Images exportieren und komprimieren:**
```bash
bash docker/save.sh <image-name>
```
Dies speichert die Docker-Images im Verzeichnis `docker/images/`.
#### **1.2.2. Übertragung auf den Server**
Kopieren Sie die erzeugten `.tar.xz`-Dateien auf den Raspberry Pi:
```bash
scp docker/images/*.tar.xz <username>@<server-ip>:/path/to/destination/
```
---
### **1.3. Images auf dem Server laden**
Loggen Sie sich auf dem Server ein und laden Sie die Docker-Images:
```bash
docker load -i /path/to/destination/<image-name>.tar.xz
```
---
### **1.4. Starten der Anwendung**
Führen Sie das Bereitstellungsskript aus:
```bash
bash docker/deploy.sh
```
Dieses Skript:
- Startet die Docker-Container mithilfe von `docker compose`.
- Verbindet den Reverse Proxy (Caddy) mit der Anwendung.
Die Anwendung sollte unter `http://<server-ip>` oder der konfigurierten Domain erreichbar sein.
---
## **2. Best Practices**
### **2.1. Sicherheit**
1. **Umgebungsvariablen schützen:**
- Stellen Sie sicher, dass die Datei `.env` nicht versehentlich in ein öffentliches Repository hochgeladen wird.
- Verwenden Sie geeignete Zugriffsrechte:
```bash
chmod 600 .env
```
2. **HTTPS aktivieren:**
- Der Caddy-Webserver unterstützt automatisch HTTPS. Stellen Sie sicher, dass eine gültige Domain konfiguriert ist.
3. **Zugriffsrechte beschränken:**
- Verwenden Sie Benutzerrollen („admin“, „guest“), um den Zugriff auf kritische Funktionen zu steuern.
---
### **2.2. Performance**
1. **Docker-Container optimieren:**
- Reduzieren Sie die Größe der Docker-Images, indem Sie unnötige Dateien in `.dockerignore` ausschließen.
2. **Datenbankwartung:**
- Führen Sie regelmäßige Backups der SQLite-Datenbank durch:
```bash
cp db/sqlite.db /path/to/backup/location/
```
- Optimieren Sie die Datenbank regelmäßig:
```sql
VACUUM;
```
3. **Skalierung:**
- Bei hoher Last kann die Anwendung mit Kubernetes oder einer Cloud-Lösung (z. B. AWS oder Azure) skaliert werden.
---
### **2.3. Fehlerbehebung**
1. **Logs überprüfen:**
- Docker-Logs können wichtige Debug-Informationen liefern:
```bash
docker logs <container-name>
```
2. **Health Checks:**
- Integrieren Sie Health Checks in die Docker Compose-Datei, um sicherzustellen, dass die Dienste korrekt laufen.
3. **Fehlerhafte Drucker deaktivieren:**
- Deaktivieren Sie Drucker mit einer hohen Fehlerrate über das Admin-Dashboard, um die Benutzererfahrung zu verbessern.
---
### **2.4. Updates**
1. **Neue Funktionen hinzufügen:**
- Aktualisieren Sie die Anwendung und erstellen Sie neue Docker-Images:
```bash
git pull origin main
bash docker/build.sh
```
- Stellen Sie die aktualisierten Images bereit:
```bash
bash docker/deploy.sh
```
2. **Datenbankmigrationen:**
- Führen Sie neue Migrationsskripte mit folgendem Befehl aus:
```bash
pnpm run db:migrate
```
---
## **3. Backup und Wiederherstellung**
### **3.1. Backups erstellen**
Sichern Sie wichtige Dateien und Datenbanken regelmäßig:
- **SQLite-Datenbank:**
```bash
cp db/sqlite.db /backup/location/sqlite-$(date +%F).db
```
- **Docker-Images:**
```bash
docker save myp-rp:latest | gzip > /backup/location/myp-rp-$(date +%F).tar.gz
```
### **3.2. Wiederherstellung**
- **Datenbank wiederherstellen:**
```bash
cp /backup/location/sqlite-<date>.db db/sqlite.db
```
- **Docker-Images importieren:**
```bash
docker load < /backup/location/myp-rp-<date>.tar.gz
```
Nächster Schritt: [=> Admin-Dashboard](./Admin-Dashboard.md)

View File

@ -1,153 +0,0 @@
# **Datenbank und Analytik-Funktionen**
Dieser Abschnitt konzentriert sich auf die Struktur der Datenbank sowie die Analyse- und Prognosefunktionen, die im Projekt verwendet werden.
---
## **1. Datenbankstruktur**
Das Datenbankschema wurde mit **Drizzle ORM** definiert und basiert auf SQLite. Die wichtigsten Tabellen und ihre Zwecke sind:
### **1.1. Tabellenübersicht**
#### **`user`**
- Speichert Benutzerinformationen.
- Enthält Rollen wie „admin“ oder „guest“ zur Verwaltung von Berechtigungen.
| **Feld** | **Typ** | **Beschreibung** |
|-------------------|------------|-------------------------------------------|
| `id` | `text` | Eindeutige ID des Benutzers. |
| `github_id` | `integer` | ID des Benutzers aus dem OAuth-Dienst. |
| `name` | `text` | Benutzername. |
| `displayName` | `text` | Angezeigter Name. |
| `email` | `text` | E-Mail-Adresse. |
| `role` | `text` | Benutzerrolle, Standardwert: „guest“. |
---
#### **`printer`**
- Beschreibt verfügbare Drucker und deren Status.
| **Feld** | **Typ** | **Beschreibung** |
|-------------------|------------|-------------------------------------------|
| `id` | `text` | Eindeutige Drucker-ID. |
| `name` | `text` | Name des Druckers. |
| `description` | `text` | Beschreibung oder Spezifikationen. |
| `status` | `integer` | Betriebsstatus (0 = inaktiv, 1 = aktiv). |
---
#### **`printJob`**
- Speichert Informationen zu Druckaufträgen.
| **Feld** | **Typ** | **Beschreibung** |
|-----------------------|---------------|-------------------------------------------------------|
| `id` | `text` | Eindeutige Auftrags-ID. |
| `printerId` | `text` | Verweis auf die ID des Druckers. |
| `userId` | `text` | Verweis auf die ID des Benutzers. |
| `startAt` | `integer` | Startzeit des Druckauftrags (Unix-Timestamp). |
| `durationInMinutes` | `integer` | Dauer des Druckauftrags in Minuten. |
| `comments` | `text` | Zusätzliche Kommentare. |
| `aborted` | `integer` | 1 = Abgebrochen, 0 = Erfolgreich abgeschlossen. |
| `abortReason` | `text` | Grund für den Abbruch (falls zutreffend). |
---
#### **`session`**
- Verwaltert Benutzer-Sitzungen und Ablaufzeiten.
| **Feld** | **Typ** | **Beschreibung** |
|-------------------|------------|-------------------------------------------|
| `id` | `text` | Eindeutige Sitzungs-ID. |
| `user_id` | `text` | Verweis auf die ID des Benutzers. |
| `expires_at` | `integer` | Zeitpunkt, wann die Sitzung abläuft. |
---
### **1.2. Relationen**
- `printer``printJob`: Druckaufträge sind an spezifische Drucker gebunden.
- `user``printJob`: Druckaufträge werden Benutzern zugewiesen.
- `user``session`: Sitzungen verknüpfen Benutzer mit Login-Details.
---
## **2. Analytik-Funktionen**
Das Projekt bietet verschiedene Analytik- und Prognosetools, um die Druckernutzung und Fehler zu überwachen.
### **2.1. Fehlerratenanalyse**
- Funktion: `calculatePrinterErrorRate` (in `src/utils/analytics/error-rate.ts`).
- Berechnet die prozentuale Fehlerrate für jeden Drucker basierend auf abgebrochenen Aufträgen.
Beispielausgabe:
```json
[
{ "name": "Drucker 1", "errorRate": 5.2 },
{ "name": "Drucker 2", "errorRate": 3.7 }
]
```
---
### **2.2. Abbruchgründe**
- Funktion: `calculateAbortReasonsCount` (in `src/utils/analytics/errors.ts`).
- Zählt die Häufigkeit der Abbruchgründe aus der Tabelle `printJob`.
Beispielausgabe:
```json
[
{ "abortReason": "Materialmangel", "count": 10 },
{ "abortReason": "Düsenverstopfung", "count": 7 }
]
```
---
### **2.3. Nutzung und Prognosen**
#### Nutzung:
- Funktion: `calculatePrinterUtilization` (in `src/utils/analytics/utilization.ts`).
- Berechnet die Nutzung der Drucker in Prozent.
Beispielausgabe:
```json
{ "printerId": "1", "utilizationPercentage": 85 }
```
#### Prognosen:
- Funktion: `forecastPrinterUsage` (in `src/utils/analytics/forecast.ts`).
- Nutzt historische Daten, um die erwartete Druckernutzung für kommende Tage/Wochen zu schätzen.
Beispielausgabe:
```json
[
{ "day": 1, "usageMinutes": 300 },
{ "day": 2, "usageMinutes": 200 }
]
```
---
### **2.4. Druckvolumen**
- Funktion: `calculatePrintVolumes` (in `src/utils/analytics/volume.ts`).
- Vergleicht die Anzahl der abgeschlossenen Druckaufträge für heute, diese Woche und diesen Monat.
Beispielausgabe:
```json
{
"today": 15,
"thisWeek": 90,
"thisMonth": 300
}
```
---
## **3. Datenbankinitialisierung**
Die Datenbank wird über Skripte in der `package.json` initialisiert:
```bash
pnpm run db:clean # Datenbank und Migrationsordner löschen
pnpm run db:generate # Neues Schema generieren
pnpm run db:migrate # Migrationsskripte ausführen
```
Nächster Schritt: [=> Bereitstellungsdetails und Best Practices](./Bereitstellungsdetails.md)

View File

@ -1,93 +0,0 @@
# **Installation und Einrichtung**
In diesem Abschnitt wird beschrieben, wie die MYP-Anwendung installiert und eingerichtet wird. Diese Schritte umfassen die Vorbereitung der Umgebung, das Konfigurieren der notwendigen Dienste und die Bereitstellung des Projekts.
---
## **Voraussetzungen**
### **Hardware und Software**
- **Raspberry Pi:** Die Anwendung ist für den Einsatz auf einem Raspberry Pi optimiert, auf dem Raspbian Lite installiert sein sollte.
- **Docker:** Docker und Docker Compose müssen installiert sein.
- **Netzwerkzugriff:** Der Raspberry Pi muss im Netzwerk erreichbar sein.
### **Abhängigkeiten**
- Node.js (mindestens Version 20)
- PNPM (Paketmanager)
- SQLite (für lokale Datenbankverwaltung)
---
## **Schritte zur Einrichtung**
### **1. Repository klonen**
Klonen Sie das Repository auf Ihr System:
```bash
git clone <repository-url>
cd <repository-ordner>
```
### **2. Konfiguration der Umgebungsvariablen**
Passen Sie die Datei `.env.example` an und benennen Sie sie in `.env` um:
```bash
cp .env.example .env
```
Erforderliche Variablen:
- `OAUTH_CLIENT_ID`: Client-ID für die OAuth-Authentifizierung
- `OAUTH_CLIENT_SECRET`: Geheimnis für die OAuth-Authentifizierung
### **3. Docker-Container erstellen**
Führen Sie das Skript `build.sh` aus, um Docker-Images zu erstellen:
```bash
bash docker/build.sh
```
Dies erstellt die notwendigen Docker-Images, einschließlich der Anwendung und eines Caddy-Webservers.
### **4. Docker-Images speichern**
Speichern Sie die Images in komprimierter Form, um sie auf anderen Geräten bereitzustellen:
```bash
bash docker/save.sh <image-name>
```
### **5. Bereitstellung**
Kopieren Sie die Docker-Images auf den Zielserver (z. B. Raspberry Pi) und führen Sie `deploy.sh` aus:
```bash
scp docker/images/*.tar.xz <ziel-server>:/path/to/deployment/
bash docker/deploy.sh
```
Das Skript führt die Docker Compose-Konfiguration aus und startet die Anwendung.
### **(Optional: 6. Admin-User anlegen)**
Um einen Admin-User anzulegen, muss zuerst das Container-Image gestartet werden. Anschließend meldet man sich mittels
der GitHub-Authentifizierung bei der Anwendung an.
Der nun in der Datenbank angelegte User hat die Rolle `guest`. Über das CLI muss man nun in die SQLite-Datenbank (die Datenbank sollte außerhalb des Container-Images liegen) wechseln und
den User updaten.
#### SQL-Befehl, um den User zu updaten:
```bash
sqlite3 db.sqlite3
UPDATE users SET role = 'admin' WHERE id = <user-id>;
```
---
## **Start der Anwendung**
Sobald die Docker-Container laufen, ist die Anwendung unter der angegebenen Domain oder IP-Adresse erreichbar. Standardmäßig verwendet der Caddy-Webserver Port 80 (HTTP) und 443 (HTTPS).
---
## **Optional: Entwicklungsmodus**
Für lokale Tests können Sie die Anwendung ohne Docker starten:
1. Installieren Sie Abhängigkeiten:
```bash
pnpm install
```
2. Starten Sie den Entwicklungsserver:
```bash
pnpm dev
```
Die Anwendung ist dann unter `http://localhost:3000` verfügbar.
Nächster Schritt: [=> Nutzung](./Nutzung.md)

View File

@ -1,75 +0,0 @@
# **Features und Nutzung der Anwendung**
In diesem Abschnitt beschreibe ich die Hauptfunktionen von MYP (Manage Your Printer) und gebe Anweisungen zur Nutzung der verschiedenen Module.
---
## **1. Hauptfunktionen**
### **1.1. Druckerreservierung**
- Nutzer können Drucker für einen definierten Zeitraum reservieren.
- Konflikte bei Reservierungen werden durch ein Echtzeit-Überprüfungssystem verhindert.
### **1.2. Fehler- und Auslastungsanalyse**
- Darstellung von Druckfehlern nach Kategorien und Häufigkeiten.
- Übersicht der aktuellen und historischen Druckernutzung.
- Diagramme zur Fehlerrate, Nutzung und Druckvolumen.
### **1.3. Admin-Dashboard**
- Verwaltung von Druckern, Nutzern und Druckaufträgen.
- Überblick über alle Abbruchgründe und Druckfehler.
- Zugriff auf erweiterte Statistiken und Prognosen.
---
## **2. Nutzung der Anwendung**
### **2.1. Login und Authentifizierung**
- Die Anwendung unterstützt OAuth-basierte Authentifizierung.
- Nutzer müssen sich mit einem gültigen Konto anmelden, um Zugriff auf die Funktionen zu erhalten.
### **2.2. Dashboard**
- Nach dem Login gelangen die Nutzer auf das Dashboard, das einen Überblick über die aktuelle Druckernutzung bietet.
- Administratoren haben Zugriff auf zusätzliche Menüpunkte, wie z. B. Benutzerverwaltung.
---
## **3. Admin-Funktionen**
### **3.1. Druckerverwaltung**
- Administratoren können Drucker hinzufügen, bearbeiten oder löschen.
- Status eines Druckers (z. B. „in Betrieb“, „außer Betrieb“) kann angepasst werden.
### **3.2. Nutzerverwaltung**
- Verwalten von Benutzerkonten, einschließlich Rollen (z. B. „Admin“ oder „User“).
- Benutzer können aktiviert oder deaktiviert werden.
### **3.3. Statistiken und Berichte**
- Diagramme wie:
- **Abbruchgründe:** Zeigt häufige Fehlerursachen.
- **Fehlerrate:** Prozentuale Fehlerquote der Drucker.
- **Nutzung:** Prognosen für die Druckernutzung pro Wochentag.
---
## **4. Diagramme und Visualisierungen**
### **4.1. Abbruchgründe**
- Ein Säulendiagramm zeigt die Häufigkeiten der Fehlerursachen.
- Nutzt Echtzeit-Daten aus der Druckhistorie.
### **4.2. Prognostizierte Nutzung**
- Ein Liniendiagramm zeigt die erwartete Druckernutzung pro Tag.
- Hilft bei der Planung von Wartungszeiten.
### **4.3. Druckvolumen**
- Balkendiagramme vergleichen Druckaufträge heute, diese Woche und diesen Monat.
---
## **5. Interaktive Komponenten**
- **Benachrichtigungen:** Informieren über Druckaufträge, Fehler oder Systemereignisse.
- **Filter und Suchfunktionen:** Erleichtern das Auffinden von Druckern oder Druckaufträgen.
- **Rollenbasierter Zugriff:** Funktionen sind je nach Benutzerrolle eingeschränkt.
Nächster Schritt: [=> Technische Architektur und Codeaufbau](./Architektur.md)

View File

@ -1,37 +0,0 @@
# **Einleitung**
> Information: Die Dokumenation wurde mit generativer AI erstellt und kann fehlerhaft sein. Im Zweifel bitte die Quellcode-Dateien anschauen oder die Entwickler kontaktieren.
## **Projektbeschreibung**
MYP (Manage Your Printer) ist eine Webanwendung zur Verwaltung und Reservierung von 3D-Druckern. Das Projekt wurde als Abschlussarbeit im Rahmen der Fachinformatiker-Ausbildung mit Schwerpunkt Daten- und Prozessanalyse entwickelt und dient als Plattform zur einfachen Koordination und Überwachung von Druckressourcen. Es wurde speziell für die Technische Berufsausbildung des Mercedes-Benz Werkes in Berlin-Marienfelde erstellt.
---
## **Hauptmerkmale**
- **Druckerreservierungen:** Nutzer können 3D-Drucker in definierten Zeitfenstern reservieren.
- **Fehleranalyse:** Statistiken über Druckfehler und Abbruchgründe werden visuell dargestellt.
- **Druckauslastung:** Echtzeit-Daten über die Nutzung der Drucker.
- **Admin-Dashboard:** Übersichtliche Verwaltung und Konfiguration von Druckern, Benutzern und Druckaufträgen.
- **Datenbankintegration:** Alle Daten werden in einer SQLite-Datenbank gespeichert und verwaltet.
---
## **Technologien**
- **Frontend:** React, Next.js, TailwindCSS
- **Backend:** Node.js, Drizzle ORM
- **Datenbank:** SQLite
- **Deployment:** Docker und Raspberry Pi
- **Zusätzliche Bibliotheken:** recharts für Diagramme, Faker.js für Testdaten, sowie diverse Radix-UI-Komponenten.
---
## **Dateistruktur**
Die Repository-Dateien sind in logische Abschnitte unterteilt:
1. **Docker-Konfigurationen** (`docker/`) - Skripte und Konfigurationsdateien für die Bereitstellung.
2. **Frontend-Komponenten** (`src/app/`) - Weboberfläche und deren Funktionalitäten.
3. **Backend-Funktionen** (`src/server/`) - Datenbankinteraktionen und Authentifizierungslogik.
4. **Utils und Helferfunktionen** (`src/utils/`) - Wiederverwendbare Dienste und Hilfsmethoden.
5. **Datenbank-Skripte** (`drizzle/`) - Datenbankschemas und Migrationsdateien.
Nächster Schritt: [=> Installation](./Installation.md)

View File

@ -1,12 +0,0 @@
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",
},
});

View File

@ -1,35 +0,0 @@
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'
);

View File

@ -1,241 +0,0 @@
{
"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": {}
}
}

View File

@ -1,13 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1715416514336,
"tag": "0000_overjoyed_strong_guy",
"breakpoints": true
}
]
}

View File

@ -1,26 +0,0 @@
/** @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;

View File

@ -1,83 +0,0 @@
{
"name": "myp-rp",
"version": "1.0.0",
"private": true,
"packageManager": "pnpm@9.12.1",
"scripts": {
"dev": "next dev",
"build": "node update-package.js && 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 diff suppressed because it is too large Load Diff

View File

@ -1,8 +0,0 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 629 B

File diff suppressed because it is too large Load Diff

View File

@ -1,367 +0,0 @@
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);
});

View File

@ -1,82 +0,0 @@
#!/bin/bash
# Skript zum Setzen der Backend-URL in der Frontend-Konfiguration
# Verwendet für die Verbindung zum Backend-Server unter 192.168.0.105:5000
# Farbcodes für Ausgabe
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Funktion zur Ausgabe mit Zeitstempel
log() {
echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1"
}
error_log() {
echo -e "${RED}[$(date +'%Y-%m-%d %H:%M:%S')] FEHLER:${NC} $1" >&2
}
# Definiere Variablen
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
ENV_FILE="$SCRIPT_DIR/.env.local"
DEFAULT_BACKEND_URL="http://192.168.0.105:5000"
# Falls übergebene Parameter vorhanden sind, Backend-URL anpassen
if [ -n "$1" ]; then
BACKEND_URL="$1"
log "Verwende übergebene Backend-URL: ${BACKEND_URL}"
else
BACKEND_URL="$DEFAULT_BACKEND_URL"
log "Verwende Standard-Backend-URL: ${BACKEND_URL}"
fi
# Bestimme den Hostnamen für OAuth
HOSTNAME=$(hostname)
if [[ "$HOSTNAME" == *"m040tbaraspi001"* ]] || [[ "$HOSTNAME" == *"corpintra"* ]]; then
FRONTEND_HOSTNAME="m040tbaraspi001.de040.corpintra.net"
OAUTH_URL="http://m040tbaraspi001.de040.corpintra.net/auth/login/callback"
log "Erkannt: Unternehmens-Hostname: $FRONTEND_HOSTNAME"
else
FRONTEND_HOSTNAME="$HOSTNAME"
OAUTH_URL="http://$HOSTNAME:3000/auth/login/callback"
log "Lokaler Hostname: $FRONTEND_HOSTNAME"
fi
# Erstelle .env.local Datei mit Backend-URL
log "${YELLOW}Erstelle .env.local Datei...${NC}"
cat > "$ENV_FILE" << EOL
# Backend API Konfiguration
NEXT_PUBLIC_API_URL=${BACKEND_URL}
# Frontend-URL für OAuth Callback
NEXT_PUBLIC_FRONTEND_URL=http://${FRONTEND_HOSTNAME}
# Explizite OAuth Callback URL für GitHub
NEXT_PUBLIC_OAUTH_CALLBACK_URL=${OAUTH_URL}
# OAuth Konfiguration (falls nötig)
OAUTH_CLIENT_ID=client_id
OAUTH_CLIENT_SECRET=client_secret
EOL
# Überprüfe, ob die Datei erstellt wurde
if [ -f "$ENV_FILE" ]; then
log "${GREEN}Erfolgreich .env.local Datei mit Backend-URL erstellt: ${BACKEND_URL}${NC}"
else
error_log "Konnte .env.local Datei nicht erstellen."
exit 1
fi
# Hinweis für Docker-Installation
log "${YELLOW}WICHTIG: Wenn Sie Docker verwenden, stellen Sie sicher, dass Sie die Umgebungsvariable setzen:${NC}"
log "NEXT_PUBLIC_API_URL=${BACKEND_URL}"
log ""
log "${GREEN}Backend-URL wurde erfolgreich konfiguriert. Nach einem Neustart der Anwendung sollte die Verbindung hergestellt werden.${NC}"
# Berechtigungen setzen
chmod 600 "$ENV_FILE"
exit 0

View File

@ -1,32 +0,0 @@
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 &mdash; 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>
&copy; 2024{" "}
<a href="https://linkedin.com/in/torben-haack" target="_blank" rel="noreferrer">
Torben Haack
</a>
</p>
</CardContent>
</Card>
);
}

View File

@ -1,66 +0,0 @@
"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>
);
}

View File

@ -1,68 +0,0 @@
"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>
);
}

View File

@ -1,66 +0,0 @@
"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>
);
}

View File

@ -1,83 +0,0 @@
"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(", ");
}

View File

@ -1,80 +0,0 @@
"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>
);
}

View File

@ -1,69 +0,0 @@
"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>
);
}

View File

@ -1,35 +0,0 @@
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>
);
}

View File

@ -1,34 +0,0 @@
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>
);
}

View File

@ -1,121 +0,0 @@
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>
</>
);
}

View File

@ -1,86 +0,0 @@
"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>
);
},
},
];

View File

@ -1,135 +0,0 @@
"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>
);
}

View File

@ -1,26 +0,0 @@
"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>
);
}

View File

@ -1,83 +0,0 @@
"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>
);
}

View File

@ -1,30 +0,0 @@
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>
);
}

View File

@ -1,204 +0,0 @@
"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>
);
}

View File

@ -1,31 +0,0 @@
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>
);
}

View File

@ -1,7 +0,0 @@
import fs from "node:fs";
export const dynamic = 'force-dynamic';
export async function GET() {
return new Response(fs.readFileSync("./db/sqlite.db"));
}

View File

@ -1,30 +0,0 @@
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>
);
}

View File

@ -1,137 +0,0 @@
"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}`;
}

View File

@ -1,135 +0,0 @@
"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>
);
}

View File

@ -1,56 +0,0 @@
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>
);
}

View File

@ -1,212 +0,0 @@
"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>
);
}

View File

@ -1,26 +0,0 @@
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>
);
}

View File

@ -1,41 +0,0 @@
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,
});
}

View File

@ -1,99 +0,0 @@
import { API_ENDPOINTS } from "@/utils/api-config";
export const dynamic = "force-dynamic";
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const id = params.id;
// Rufe einzelnen Job vom externen Backend ab
const response = await fetch(`${API_ENDPOINTS.JOBS}/${id}`);
if (!response.ok) {
console.error(`Backend-Fehler: ${response.status} ${response.statusText}`);
return new Response(JSON.stringify({ error: 'Backend nicht erreichbar' }), {
status: 502,
headers: { 'Content-Type': 'application/json' }
});
}
const job = await response.json();
return Response.json(job);
} catch (error) {
console.error('Fehler beim Abrufen des Jobs vom Backend:', error);
return new Response(JSON.stringify({ error: 'Backend nicht erreichbar' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
export async function PUT(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const id = params.id;
const body = await request.json();
// Sende Job-Aktualisierung an das externe Backend
const response = await fetch(`${API_ENDPOINTS.JOBS}/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (!response.ok) {
console.error(`Backend-Fehler: ${response.status} ${response.statusText}`);
return new Response(JSON.stringify({ error: 'Backend nicht erreichbar' }), {
status: 502,
headers: { 'Content-Type': 'application/json' }
});
}
const result = await response.json();
return Response.json(result);
} catch (error) {
console.error('Fehler beim Aktualisieren des Jobs:', error);
return new Response(JSON.stringify({ error: 'Fehler beim Aktualisieren des Jobs' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
export async function DELETE(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const id = params.id;
// Sende Job-Löschung an das externe Backend
const response = await fetch(`${API_ENDPOINTS.JOBS}/${id}`, {
method: 'DELETE'
});
if (!response.ok) {
console.error(`Backend-Fehler: ${response.status} ${response.statusText}`);
return new Response(JSON.stringify({ error: 'Backend nicht erreichbar' }), {
status: 502,
headers: { 'Content-Type': 'application/json' }
});
}
const result = await response.json();
return Response.json(result);
} catch (error) {
console.error('Fehler beim Löschen des Jobs:', error);
return new Response(JSON.stringify({ error: 'Fehler beim Löschen des Jobs' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}

View File

@ -1,59 +0,0 @@
import { API_ENDPOINTS } from "@/utils/api-config";
export const dynamic = "force-dynamic";
export async function GET() {
try {
// Rufe Jobs vom externen Backend ab
const response = await fetch(API_ENDPOINTS.JOBS);
if (!response.ok) {
console.error(`Backend-Fehler: ${response.status} ${response.statusText}`);
return new Response(JSON.stringify({ error: 'Backend nicht erreichbar' }), {
status: 502,
headers: { 'Content-Type': 'application/json' }
});
}
const jobs = await response.json();
return Response.json(jobs);
} catch (error) {
console.error('Fehler beim Abrufen der Jobs vom Backend:', error);
return new Response(JSON.stringify({ error: 'Backend nicht erreichbar' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
export async function POST(request: Request) {
try {
const body = await request.json();
// Sende Job-Erstellung an das externe Backend
const response = await fetch(API_ENDPOINTS.JOBS, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (!response.ok) {
console.error(`Backend-Fehler: ${response.status} ${response.statusText}`);
return new Response(JSON.stringify({ error: 'Backend nicht erreichbar' }), {
status: 502,
headers: { 'Content-Type': 'application/json' }
});
}
const result = await response.json();
return Response.json(result);
} catch (error) {
console.error('Fehler beim Erstellen des Jobs:', error);
return new Response(JSON.stringify({ error: 'Fehler beim Erstellen des Jobs' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}

View File

@ -1,27 +0,0 @@
import { API_ENDPOINTS } from "@/utils/api-config";
export const dynamic = "force-dynamic";
export async function GET() {
try {
// Rufe Drucker vom externen Backend ab statt von der lokalen Datenbank
const response = await fetch(API_ENDPOINTS.PRINTERS);
if (!response.ok) {
console.error(`Backend-Fehler: ${response.status} ${response.statusText}`);
return new Response(JSON.stringify({ error: 'Backend nicht erreichbar' }), {
status: 502,
headers: { 'Content-Type': 'application/json' }
});
}
const printers = await response.json();
return Response.json(printers);
} catch (error) {
console.error('Fehler beim Abrufen der Drucker vom Backend:', error);
return new Response(JSON.stringify({ error: 'Backend nicht erreichbar' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}

View File

@ -1,123 +0,0 @@
import { lucia } from "@/server/auth";
import { type GitHubUserResult, github, isValidCallbackHost, USED_CALLBACK_URL } from "@/server/auth/oauth";
import { ALLOWED_CALLBACK_HOSTS } from "@/utils/api-config";
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;
// Log für Debugging
console.log("OAuth Callback erhalten:", url.toString());
console.log("Callback URL Validierung:", isValidCallbackHost(url.toString()));
console.log("Erlaubte Hosts:", ALLOWED_CALLBACK_HOSTS);
if (!code || !state || !storedState || state !== storedState) {
return new Response(
JSON.stringify({
status_text: "Ungültiger OAuth-Callback",
data: { code, state, storedState, url: url.toString() },
}),
{
status: 400,
},
);
}
try {
// GitHub OAuth Code validieren - die redirectURI ist bereits im GitHub Client konfiguriert
const tokens = await github.validateAuthorizationCode(code);
// Log zur Fehlersuche
console.log(`GitHub OAuth Token-Validierung erfolgreich, verwendete Callback-URL: ${USED_CALLBACK_URL}`);
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,
});
}
}

View File

@ -1,30 +0,0 @@
import { github, USED_CALLBACK_URL } 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();
// Verwende die zentral definierte Callback-URL
// Die redirectURI ist bereits im GitHub-Client konfiguriert
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",
});
// Log zur Fehlersuche
console.log(`GitHub OAuth redirect zu: ${url.toString()}`);
console.log(`Verwendete Callback-URL: ${USED_CALLBACK_URL}`);
return Response.redirect(url);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

View File

@ -1,61 +0,0 @@
@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%;
}
}

View File

@ -1,132 +0,0 @@
"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>
);
}

View File

@ -1,56 +0,0 @@
"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>
);
}

View File

@ -1,151 +0,0 @@
"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>
);
}

View File

@ -1,87 +0,0 @@
"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>
);
}

View File

@ -1,123 +0,0 @@
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"),
*/

View File

@ -1,36 +0,0 @@
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>
);
}

View File

@ -1,141 +0,0 @@
"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>
);
},
},
];

View File

@ -1,73 +0,0 @@
"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>
);
}

View File

@ -1,47 +0,0 @@
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} &mdash; {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>
);
}

View File

@ -1,11 +0,0 @@
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>
);
}

View File

@ -1,71 +0,0 @@
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>
)}
</>
);
}

View File

@ -1,169 +0,0 @@
"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>
);
}

View File

@ -1,36 +0,0 @@
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>
);
}

View File

@ -1,38 +0,0 @@
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">&nbsp;</p>
</CardContent>
</Card>
);
}

View File

@ -1,38 +0,0 @@
"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} />;
});
}

View File

@ -1,85 +0,0 @@
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>
);
}

View File

@ -1,49 +0,0 @@
"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>
);
}

View File

@ -1,37 +0,0 @@
"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>
);
}

View File

@ -1,23 +0,0 @@
"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>
);
}

View File

@ -1,71 +0,0 @@
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>
);
}

View File

@ -1,25 +0,0 @@
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>
);
}

View File

@ -1,44 +0,0 @@
"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>
);
}

View File

@ -1,87 +0,0 @@
"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>
);
}

Some files were not shown because too many files have changed in this diff Show More