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

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

+

+ © 2024{" "} + + Torben Haack + +

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

Admin

+
+
+ +
{children}
+
+
+ ); +} + +================ +File: src/app/admin/page.tsx +================ +import { AbortReasonCountChart } from "@/app/admin/charts/printer-error-chart"; +import { PrinterErrorRateChart } from "@/app/admin/charts/printer-error-rate"; +import { ForecastPrinterUsageChart } from "@/app/admin/charts/printer-forecast"; +import { PrinterUtilizationChart } from "@/app/admin/charts/printer-utilization"; +import { PrinterVolumeChart } from "@/app/admin/charts/printer-volume"; +import { DataCard } from "@/components/data-card"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { db } from "@/server/db"; +import { calculatePrinterErrorRate } from "@/utils/analytics/error-rate"; +import { calculateAbortReasonsCount } from "@/utils/analytics/errors"; +import { forecastPrinterUsage } from "@/utils/analytics/forecast"; +import { calculatePrinterUtilization } from "@/utils/analytics/utilization"; +import { calculatePrintVolumes } from "@/utils/analytics/volume"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Admin Dashboard", +}; + +export const dynamic = "force-dynamic"; + +export default async function AdminPage() { + const currentDate = new Date(); + + const lastMonth = new Date(); + lastMonth.setDate(currentDate.getDate() - 31); + const printers = await db.query.printers.findMany({}); + const printJobs = await db.query.printJobs.findMany({ + where: (job, { gte }) => gte(job.startAt, lastMonth), + with: { + printer: true, + }, + }); + if (printJobs.length < 1) { + return ( + + + Druckaufträge + Zurzeit sind keine Druckaufträge verfügbar. + + +

Aktualisieren Sie die Seite oder prüfen Sie später erneut, ob neue Druckaufträge verfügbar sind.

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

Datenbank herunterladen

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