woms 3.0
This commit is contained in:
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
build/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
116
ADMIN_PANEL_SETUP.md
Normal file
116
ADMIN_PANEL_SETUP.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Admin-Panel Setup
|
||||
|
||||
Das Admin-Panel wurde erfolgreich erstellt! Hier ist die Anleitung zur Einrichtung.
|
||||
|
||||
## Was wurde erstellt:
|
||||
|
||||
1. **Admin-Panel Seite** (`/admin`) - Verwaltung der Dropdown-Optionen
|
||||
2. **useAdminConfig Hook** - Lädt und speichert die Konfiguration
|
||||
3. **Navigation erweitert** - Admin-Link erscheint für Admin-Benutzer
|
||||
4. **CreateTicketModal angepasst** - Verwendet jetzt die konfigurierten Werte
|
||||
|
||||
## Schritt 1: Config Collection in Appwrite erstellen
|
||||
|
||||
1. Gehe zu `https://appwrite.webklar.com`
|
||||
2. Öffne dein Projekt **woms**
|
||||
3. Gehe zu **Databases** → **woms-database**
|
||||
4. Klicke auf **Create Collection**
|
||||
5. **Collection ID**: `config`
|
||||
6. **Name**: `Admin Configuration`
|
||||
|
||||
### Attribute hinzufügen:
|
||||
|
||||
| Attribut Name | Typ | Größe | Required | Array |
|
||||
|--------------|-----|-------|----------|-------|
|
||||
| `ticketTypes` | string | - | - | ✓ |
|
||||
| `systems` | string | - | - | ✓ |
|
||||
| `responseLevels` | string | - | - | ✓ |
|
||||
| `serviceTypes` | string | - | - | ✓ |
|
||||
| `priorities` | string | - | - | ✓ |
|
||||
|
||||
**Wichtig:** Alle Attribute müssen als **Array** konfiguriert sein!
|
||||
|
||||
### Berechtigungen:
|
||||
|
||||
- **Read**: `Any` (damit alle Benutzer die Optionen sehen können)
|
||||
- **Create**: `Users` (nur eingeloggte Benutzer können erstellen)
|
||||
- **Update**: `Users` (nur eingeloggte Benutzer können aktualisieren)
|
||||
- **Delete**: `Users` (optional)
|
||||
|
||||
## Schritt 2: Admin-Berechtigung konfigurieren
|
||||
|
||||
Aktuell wird ein Benutzer als Admin erkannt, wenn:
|
||||
- Die Email `admin` enthält, ODER
|
||||
- Die Email `kenso@webklar.com` ist, ODER
|
||||
- Der Benutzer ein `admin` Label hat
|
||||
|
||||
### Option A: Email-basierte Erkennung (aktuell implementiert)
|
||||
|
||||
Die Admin-Erkennung erfolgt in:
|
||||
- `src/pages/AdminPage.jsx` (Zeile ~10)
|
||||
- `src/components/Navbar.jsx` (Zeile ~29)
|
||||
|
||||
Du kannst die Bedingung anpassen:
|
||||
|
||||
```javascript
|
||||
const isAdmin = user?.email?.includes('admin') ||
|
||||
user?.email === 'deine@email.com' ||
|
||||
user?.labels?.includes('admin')
|
||||
```
|
||||
|
||||
### Option B: Appwrite Labels verwenden
|
||||
|
||||
1. Gehe zu **Auth** → **Users** im Appwrite Dashboard
|
||||
2. Wähle einen Benutzer aus
|
||||
3. Füge ein **Label** hinzu: `admin`
|
||||
4. Die Admin-Erkennung funktioniert dann automatisch
|
||||
|
||||
## Schritt 3: Admin-Panel verwenden
|
||||
|
||||
1. **Logge dich als Admin ein**
|
||||
2. Gehe zu **Admin** in der Navigation (erscheint nur für Admins)
|
||||
3. Bearbeite die Dropdown-Optionen:
|
||||
- **Work Order Types** - Ticket-Typen
|
||||
- **Affected Systems** - Betroffene Systeme
|
||||
- **Response Levels** - Response-Ebenen
|
||||
- **Service Types** - Service-Typen
|
||||
- **Priorities** - Prioritäten (mit Value und Label)
|
||||
4. Klicke auf **Konfiguration speichern**
|
||||
|
||||
## Schritt 4: Testen
|
||||
|
||||
1. **Als Admin:**
|
||||
- Öffne das Admin-Panel
|
||||
- Ändere einige Optionen
|
||||
- Speichere die Konfiguration
|
||||
|
||||
2. **Als normaler Benutzer:**
|
||||
- Erstelle ein neues Ticket
|
||||
- Die geänderten Optionen sollten jetzt in den Dropdowns erscheinen
|
||||
|
||||
## Funktionsweise
|
||||
|
||||
- **Admin-Panel**: Lädt die Konfiguration aus Appwrite und erlaubt Bearbeitung
|
||||
- **CreateTicketModal**: Lädt die Konfiguration automatisch und verwendet die Werte
|
||||
- **Fallback**: Falls die Config nicht geladen werden kann, werden Standard-Werte verwendet
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Collection config not found"
|
||||
- Stelle sicher, dass die Collection `config` erstellt wurde
|
||||
- Überprüfe die Collection ID (muss genau `config` sein)
|
||||
|
||||
### "Zugriff verweigert"
|
||||
- Überprüfe die Admin-Erkennung in `AdminPage.jsx`
|
||||
- Stelle sicher, dass deine Email den Admin-Kriterien entspricht
|
||||
|
||||
### "Konfiguration wird nicht geladen"
|
||||
- Überprüfe die Berechtigungen der `config` Collection
|
||||
- Stelle sicher, dass **Read** auf `Any` oder `Users` gesetzt ist
|
||||
|
||||
### "Änderungen werden nicht übernommen"
|
||||
- Überprüfe die **Update** Berechtigung der `config` Collection
|
||||
- Stelle sicher, dass du als Admin eingeloggt bist
|
||||
|
||||
Viel Erfolg! 🚀
|
||||
|
||||
74
ADMIN_ROLE_SETUP.md
Normal file
74
ADMIN_ROLE_SETUP.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Admin-Rolle in Appwrite einrichten
|
||||
|
||||
Die Admin-Erkennung verwendet jetzt die **Labels** aus Appwrite. Ein Benutzer ist Admin, wenn er das Label `admin` hat.
|
||||
|
||||
## Schritt 1: Admin-Label in Appwrite hinzufügen
|
||||
|
||||
### Option A: Über das Appwrite Dashboard
|
||||
|
||||
1. Gehe zu `https://appwrite.webklar.com`
|
||||
2. Öffne dein Projekt **woms**
|
||||
3. Gehe zu **Auth** → **Users**
|
||||
4. Wähle den Benutzer aus, der Admin werden soll
|
||||
5. Klicke auf **Edit** oder öffne die Benutzer-Details
|
||||
6. Scrolle zu **Labels** (oder **Custom Attributes**)
|
||||
7. Klicke auf **Add Label** oder **+**
|
||||
8. Gib `admin` ein
|
||||
9. Klicke auf **Save** oder **Update**
|
||||
|
||||
### Option B: Über die Appwrite API (für mehrere Benutzer)
|
||||
|
||||
Du kannst auch Labels programmatisch hinzufügen, aber das ist normalerweise nicht nötig.
|
||||
|
||||
## Schritt 2: Überprüfen
|
||||
|
||||
Nach dem Hinzufügen des Labels:
|
||||
|
||||
1. **Logge dich aus** und wieder **ein** (damit die Session aktualisiert wird)
|
||||
2. Oder lade die Seite neu (F5)
|
||||
3. Der **Admin**-Link sollte jetzt in der Navigation erscheinen
|
||||
4. Du solltest Zugriff auf das Admin-Panel haben
|
||||
|
||||
## Schritt 3: Mehrere Admins hinzufügen
|
||||
|
||||
Um weitere Admins hinzuzufügen:
|
||||
|
||||
1. Wiederhole Schritt 1 für jeden Benutzer
|
||||
2. Füge das Label `admin` hinzu
|
||||
3. Die Benutzer müssen sich neu einloggen
|
||||
|
||||
## Wie es funktioniert
|
||||
|
||||
- **AuthContext**: Prüft ob `user.labels` das Label `admin` enthält
|
||||
- **Navbar**: Zeigt den Admin-Link nur an, wenn `isAdmin === true`
|
||||
- **AdminPage**: Blockiert den Zugriff, wenn der Benutzer kein Admin ist
|
||||
|
||||
## Debugging
|
||||
|
||||
Falls der Admin-Link nicht erscheint:
|
||||
|
||||
1. **Browser-Konsole öffnen** (F12)
|
||||
2. Prüfe das User-Objekt:
|
||||
```javascript
|
||||
// In der Browser-Konsole:
|
||||
console.log(user)
|
||||
// Prüfe ob user.labels das Array ['admin'] enthält
|
||||
```
|
||||
|
||||
3. **Überprüfe in Appwrite:**
|
||||
- Gehe zu **Auth** → **Users**
|
||||
- Öffne deinen Benutzer
|
||||
- Stelle sicher, dass das Label `admin` vorhanden ist
|
||||
|
||||
4. **Session aktualisieren:**
|
||||
- Logge dich aus und wieder ein
|
||||
- Oder lade die Seite neu
|
||||
|
||||
## Wichtig
|
||||
|
||||
- Das Label muss genau `admin` heißen (kleingeschrieben)
|
||||
- Der Benutzer muss sich nach dem Hinzufügen des Labels neu einloggen
|
||||
- Labels sind case-sensitive
|
||||
|
||||
Viel Erfolg! 🚀
|
||||
|
||||
102
APPWRITE_INDEXES_PERMISSIONS.md
Normal file
102
APPWRITE_INDEXES_PERMISSIONS.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Indexes und Berechtigungen für workorders Collection
|
||||
|
||||
Diese Anleitung zeigt dir, wie du die Indexes und Berechtigungen für deine `workorders` Collection einrichtest.
|
||||
|
||||
## Deine Collection-Informationen
|
||||
|
||||
- **Collection ID**: `6943bf7d001901baa60c`
|
||||
- **Database ID**: `6943bf0e0003291f8c35`
|
||||
|
||||
## Schritt 1: Indexes erstellen
|
||||
|
||||
Indexes verbessern die Performance beim Filtern und Sortieren. Gehe zu deiner `workorders` Collection im Appwrite Dashboard:
|
||||
|
||||
1. Öffne dein Appwrite-Projekt
|
||||
2. Gehe zu **Databases** → **woms-database** → **workorders**
|
||||
3. Klicke auf den Tab **Indexes**
|
||||
4. Klicke auf **Create Index**
|
||||
|
||||
Erstelle folgende Indexes:
|
||||
|
||||
### Index 1: Status
|
||||
- **Key**: `status`
|
||||
- **Type**: `key`
|
||||
- **Attributes**: `status`
|
||||
- **Order**: `ASC` (aufsteigend)
|
||||
|
||||
### Index 2: Priority
|
||||
- **Key**: `priority`
|
||||
- **Type**: `key`
|
||||
- **Attributes**: `priority`
|
||||
- **Order**: `ASC` (aufsteigend)
|
||||
|
||||
### Index 3: Type
|
||||
- **Key**: `type`
|
||||
- **Type**: `key`
|
||||
- **Attributes**: `type`
|
||||
- **Order**: `ASC` (aufsteigend)
|
||||
|
||||
### Index 4: Customer ID
|
||||
- **Key**: `customerId`
|
||||
- **Type**: `key`
|
||||
- **Attributes**: `customerId`
|
||||
- **Order**: `ASC` (aufsteigend)
|
||||
|
||||
### Index 5: Assigned To
|
||||
- **Key**: `assignedTo`
|
||||
- **Type**: `key`
|
||||
- **Attributes**: `assignedTo`
|
||||
- **Order**: `ASC` (aufsteigend)
|
||||
|
||||
### Index 6: Created At
|
||||
- **Key**: `createdAt`
|
||||
- **Type**: `key`
|
||||
- **Attributes**: `createdAt`
|
||||
- **Order**: `DESC` (absteigend)
|
||||
|
||||
## Schritt 2: Berechtigungen (Permissions) einrichten
|
||||
|
||||
1. Bleibe in deiner `workorders` Collection
|
||||
2. Klicke auf den Tab **Settings**
|
||||
3. Scrolle zu **Permissions**
|
||||
|
||||
### Read (Lesen)
|
||||
- Klicke auf **Add Role** unter "Read"
|
||||
- Wähle **Any** (wenn alle Tickets sichtbar sein sollen) ODER **Users** (wenn nur eingeloggte Benutzer sehen sollen)
|
||||
- **Empfehlung**: `Users` für mehr Sicherheit
|
||||
|
||||
### Create (Erstellen)
|
||||
- Klicke auf **Add Role** unter "Create"
|
||||
- Wähle **Users** (nur eingeloggte Benutzer können Tickets erstellen)
|
||||
|
||||
### Update (Aktualisieren)
|
||||
- Klicke auf **Add Role** unter "Update"
|
||||
- Wähle **Users** (nur eingeloggte Benutzer können Tickets aktualisieren)
|
||||
|
||||
### Delete (Löschen)
|
||||
- Klicke auf **Add Role** unter "Delete"
|
||||
- Wähle **Users** (nur eingeloggte Benutzer können Tickets löschen)
|
||||
|
||||
## Schritt 3: Überprüfung
|
||||
|
||||
Nach dem Einrichten solltest du:
|
||||
1. ✅ 6 Indexes in der Collection sehen
|
||||
2. ✅ 4 Berechtigungen (Read, Create, Update, Delete) konfiguriert haben
|
||||
|
||||
## Wichtig: Authentication aktivieren
|
||||
|
||||
Bevor die Berechtigungen funktionieren, musst du Authentication aktivieren:
|
||||
|
||||
1. Gehe zu **Auth** im linken Menü
|
||||
2. Stelle sicher, dass **Email/Password** aktiviert ist
|
||||
3. Falls nicht, klicke auf **Create** und aktiviere **Email/Password**
|
||||
|
||||
## Testen
|
||||
|
||||
Nach der Einrichtung:
|
||||
1. Starte deine App neu: `npm run dev`
|
||||
2. Erstelle einen Benutzer oder logge dich ein
|
||||
3. Versuche ein Ticket zu erstellen - es sollte funktionieren!
|
||||
|
||||
Viel Erfolg! 🚀
|
||||
|
||||
81
APPWRITE_PLATFORM_SETUP.md
Normal file
81
APPWRITE_PLATFORM_SETUP.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Appwrite Platform-Konfiguration - WICHTIG!
|
||||
|
||||
Der Fehler "Project with the requested ID could not be found" kann auch auftreten, wenn die **Platform nicht konfiguriert** ist!
|
||||
|
||||
## Schritt 1: Platform in Appwrite konfigurieren
|
||||
|
||||
1. Gehe zu [https://cloud.appwrite.io](https://cloud.appwrite.io)
|
||||
2. Öffne dein Projekt **woms**
|
||||
3. Gehe zu **Settings** → **Platforms** (oder **Auth** → **Settings**)
|
||||
4. Klicke auf **Add Platform**
|
||||
5. Wähle **Web App**
|
||||
6. Gib einen Namen ein (z.B. "WOMS Web")
|
||||
7. **WICHTIG:** Füge die folgenden Hosts hinzu:
|
||||
- `localhost` (für Development)
|
||||
- `127.0.0.1` (für Development)
|
||||
- Deine Produktions-Domain (falls vorhanden)
|
||||
|
||||
8. Klicke auf **Create**
|
||||
|
||||
## Schritt 2: Email/Password Auth aktivieren
|
||||
|
||||
1. Gehe zu **Auth** → **Providers** (oder **Settings** → **Auth**)
|
||||
2. Suche nach **Email/Password**
|
||||
3. Aktiviere es (grüner Schalter)
|
||||
4. Stelle sicher, dass es **aktiviert** ist
|
||||
|
||||
## Schritt 3: Project ID nochmal überprüfen
|
||||
|
||||
1. Gehe zu **Settings** → **General**
|
||||
2. Kopiere die **Project ID** erneut
|
||||
3. Vergleiche sie mit deiner `.env` Datei
|
||||
|
||||
Die Project ID sollte so aussehen: `693d9f37000b35267f1b`
|
||||
|
||||
## Schritt 4: Dev-Server neu starten
|
||||
|
||||
```bash
|
||||
# Stoppe den Server (Ctrl+C)
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Schritt 5: Browser-Konsole überprüfen
|
||||
|
||||
Nach dem Neustart solltest du in der Browser-Konsole sehen:
|
||||
|
||||
```
|
||||
🔧 Appwrite Konfiguration:
|
||||
Endpoint: https://cloud.appwrite.io/v1
|
||||
Project ID: 693d9f37000b35267f1b
|
||||
Database ID: 6943bf0e0003291f8c35
|
||||
```
|
||||
|
||||
## Häufige Probleme
|
||||
|
||||
### Problem: "Project with the requested ID could not be found"
|
||||
|
||||
**Lösung:**
|
||||
1. Überprüfe, ob die Platform konfiguriert ist (siehe Schritt 1)
|
||||
2. Überprüfe, ob `localhost` als Host hinzugefügt wurde
|
||||
3. Überprüfe die Project ID nochmal im Dashboard
|
||||
|
||||
### Problem: "createEmailPasswordSession is not a function"
|
||||
|
||||
**Lösung:**
|
||||
- Ich habe den Code bereits aktualisiert zu `createEmailSession`
|
||||
- Stelle sicher, dass der Dev-Server neu gestartet wurde
|
||||
|
||||
### Problem: "401 Unauthorized" oder "Invalid credentials"
|
||||
|
||||
**Lösung:**
|
||||
1. Stelle sicher, dass Email/Password Auth aktiviert ist
|
||||
2. Erstelle einen Benutzer im Appwrite Dashboard oder über die Registrierung
|
||||
|
||||
## Testen
|
||||
|
||||
1. Öffne die App: `http://localhost:5173`
|
||||
2. Versuche dich zu registrieren oder einzuloggen
|
||||
3. Schaue in die Browser-Konsole auf Fehlermeldungen
|
||||
|
||||
Viel Erfolg! 🚀
|
||||
|
||||
86
APPWRITE_SELF_HOSTED.md
Normal file
86
APPWRITE_SELF_HOSTED.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Appwrite Self-Hosted Konfiguration
|
||||
|
||||
Deine Appwrite-Instanz läuft auf einer eigenen Domain mit Version 1.5.7.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
### Endpoint
|
||||
- **URL**: `https://appwrite.webklar.com/v1`
|
||||
- **Version**: 1.5.7
|
||||
|
||||
### .env Datei
|
||||
```env
|
||||
VITE_APPWRITE_ENDPOINT=https://appwrite.webklar.com/v1
|
||||
VITE_APPWRITE_PROJECT_ID=693d9f37000b35267f1b
|
||||
VITE_APPWRITE_DATABASE_ID=6943bf0e0003291f8c35
|
||||
```
|
||||
|
||||
## Wichtige Unterschiede zu Appwrite Cloud
|
||||
|
||||
### 1. Platform-Konfiguration
|
||||
Bei selbst gehosteten Instanzen muss die Platform möglicherweise anders konfiguriert werden:
|
||||
|
||||
1. Gehe zu deinem Appwrite Dashboard: `https://appwrite.webklar.com`
|
||||
2. Öffne dein Projekt **woms**
|
||||
3. Gehe zu **Settings** → **Platforms**
|
||||
4. Füge eine **Web App** Platform hinzu
|
||||
5. Wichtig: Füge die folgenden Hosts hinzu:
|
||||
- `localhost`
|
||||
- `127.0.0.1`
|
||||
- Deine Produktions-Domain (falls vorhanden)
|
||||
|
||||
### 2. CORS-Einstellungen
|
||||
Stelle sicher, dass CORS für deine Domain konfiguriert ist:
|
||||
- `http://localhost:5173` (Development)
|
||||
- Deine Produktions-Domain
|
||||
|
||||
### 3. SSL-Zertifikat
|
||||
Stelle sicher, dass `https://appwrite.webklar.com` ein gültiges SSL-Zertifikat hat.
|
||||
|
||||
## API-Kompatibilität
|
||||
|
||||
Appwrite 1.5.7 sollte mit dem Appwrite SDK 13.0 kompatibel sein. Die verwendeten Methoden:
|
||||
- `account.createEmailSession(email, password)` - für Login
|
||||
- `account.create(ID.unique(), email, password, name)` - für Registrierung
|
||||
- `account.get()` - für Session-Check
|
||||
|
||||
## Fehlerbehebung
|
||||
|
||||
### "Project with the requested ID could not be found"
|
||||
1. Überprüfe, ob die Platform konfiguriert ist (siehe oben)
|
||||
2. Überprüfe, ob CORS korrekt konfiguriert ist
|
||||
3. Überprüfe die Project ID nochmal im Dashboard
|
||||
|
||||
### CORS-Fehler
|
||||
Wenn du CORS-Fehler siehst:
|
||||
1. Überprüfe die CORS-Einstellungen in deiner Appwrite-Konfiguration
|
||||
2. Stelle sicher, dass `localhost:5173` erlaubt ist
|
||||
|
||||
### SSL-Zertifikat-Fehler
|
||||
Wenn es SSL-Probleme gibt:
|
||||
1. Überprüfe, ob das Zertifikat gültig ist
|
||||
2. Stelle sicher, dass die Domain korrekt konfiguriert ist
|
||||
|
||||
## Dev-Server neu starten
|
||||
|
||||
Nach Änderungen an der `.env` Datei:
|
||||
|
||||
```bash
|
||||
# Stoppe den Server (Ctrl+C)
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Testen
|
||||
|
||||
1. Öffne die App: `http://localhost:5173`
|
||||
2. Überprüfe die Browser-Konsole - du solltest sehen:
|
||||
```
|
||||
🔧 Appwrite Konfiguration:
|
||||
Endpoint: https://appwrite.webklar.com/v1
|
||||
Project ID: 693d9f37000b35267f1b
|
||||
Database ID: 6943bf0e0003291f8c35
|
||||
```
|
||||
3. Versuche dich zu registrieren oder einzuloggen
|
||||
|
||||
Viel Erfolg! 🚀
|
||||
|
||||
200
APPWRITE_SETUP.md
Normal file
200
APPWRITE_SETUP.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# Appwrite Setup-Anleitung für WOMS 2.0
|
||||
|
||||
Diese Anleitung führt dich Schritt für Schritt durch die Einrichtung von Appwrite für dein Ticket-System.
|
||||
|
||||
## Schritt 1: Appwrite Account erstellen
|
||||
|
||||
1. Gehe zu [https://cloud.appwrite.io](https://cloud.appwrite.io)
|
||||
2. Erstelle einen kostenlosen Account oder logge dich ein
|
||||
3. Erstelle ein neues Projekt (z.B. "WOMS" oder "Ticket-System")
|
||||
|
||||
## Schritt 2: Projekt-Konfiguration
|
||||
|
||||
1. Öffne dein Projekt im Appwrite Dashboard
|
||||
2. Gehe zu **Settings** → **General**
|
||||
3. Kopiere deine **Project ID** (wird später in der `.env` Datei benötigt)
|
||||
|
||||
## Schritt 3: Datenbank erstellen
|
||||
|
||||
1. Gehe zu **Databases** im linken Menü
|
||||
2. Klicke auf **Create Database**
|
||||
3. Name: `woms-database` (oder ein anderer Name - muss dann in `.env` angepasst werden)
|
||||
4. Kopiere die **Database ID** (wird später in der `.env` Datei benötigt)
|
||||
|
||||
## Schritt 4: Collections erstellen
|
||||
|
||||
### Collection 1: `workorders`
|
||||
|
||||
1. Klicke auf **Create Collection**
|
||||
2. Collection ID: `workorders`
|
||||
3. Name: `Work Orders`
|
||||
|
||||
**Attribute hinzufügen:**
|
||||
|
||||
| Attribut Name | Typ | Größe | Required | Array | Default |
|
||||
|--------------|-----|-------|----------|-------|---------|
|
||||
| `topic` | string | 255 | ✓ | - | - |
|
||||
| `title` | string | 255 | - | - | - |
|
||||
| `details` | string | 10000 | - | - | - |
|
||||
| `description` | string | 5000 | - | - | - |
|
||||
| `status` | string | 50 | ✓ | - | `Open` |
|
||||
| `priority` | integer | - | ✓ | - | `1` |
|
||||
| `type` | string | 50 | - | - | - |
|
||||
| `systemType` | string | 50 | - | - | - |
|
||||
| `responseLevel` | string | 50 | - | - | - |
|
||||
| `serviceType` | string | 50 | - | - | `Remote` |
|
||||
| `customerId` | string | 50 | - | - | - |
|
||||
| `customerName` | string | 255 | - | - | - |
|
||||
| `customerLocation` | string | 255 | - | - | - |
|
||||
| `assignedTo` | string | 50 | - | - | - |
|
||||
| `assignedName` | string | 255 | - | - | - |
|
||||
| `requestedBy` | string | 255 | - | - | - |
|
||||
| `requestedFor` | string | 255 | - | - | - |
|
||||
| `startDate` | string | 50 | - | - | - |
|
||||
| `startTime` | string | 10 | - | - | - |
|
||||
| `deadline` | string | 50 | - | - | - |
|
||||
| `endTime` | string | 10 | - | - | - |
|
||||
| `estimate` | string | 50 | - | - | - |
|
||||
| `mailCopyTo` | string | 255 | - | - | - |
|
||||
| `sendNotification` | boolean | - | - | - | `false` |
|
||||
| `approvalStatus` | string | 50 | - | - | - |
|
||||
| `woid` | string | 10 | - | - | - |
|
||||
| `createdAt` | datetime | - | - | - | - |
|
||||
|
||||
**Hinweis zu WOID:**
|
||||
- `woid` = Work Order ID (5-stellig, z.B. "10000", "10001", etc.)
|
||||
- Wird automatisch vom System generiert
|
||||
- **WSID (Work Sheet ID)** ist NICHT Teil dieser Collection - siehe `worksheets` Collection!
|
||||
|
||||
**Indexes erstellen:**
|
||||
- `status` (ASC)
|
||||
- `priority` (ASC)
|
||||
- `type` (ASC)
|
||||
- `customerId` (ASC)
|
||||
- `assignedTo` (ASC)
|
||||
- `woid` (ASC) - für schnelle WOID-Suche
|
||||
- `wsid` (ASC) - für schnelle WSID-Suche
|
||||
- `createdAt` (DESC)
|
||||
|
||||
**Berechtigungen (Permissions):**
|
||||
- **Read**: `Any` (oder `Users` wenn nur eingeloggte Benutzer sehen sollen)
|
||||
- **Create**: `Users` (nur eingeloggte Benutzer können erstellen)
|
||||
- **Update**: `Users` (nur eingeloggte Benutzer können aktualisieren)
|
||||
- **Delete**: `Users` (nur eingeloggte Benutzer können löschen)
|
||||
|
||||
### Collection 2: `customers` (optional, für zukünftige Features)
|
||||
|
||||
1. Klicke auf **Create Collection**
|
||||
2. Collection ID: `customers`
|
||||
3. Name: `Customers`
|
||||
|
||||
**Attribute hinzufügen:**
|
||||
|
||||
| Attribut Name | Typ | Größe | Required |
|
||||
|--------------|-----|-------|----------|
|
||||
| `name` | string | 255 | ✓ |
|
||||
| `code` | string | 50 | - |
|
||||
| `email` | string | 255 | - |
|
||||
| `phone` | string | 50 | - |
|
||||
| `location` | string | 255 | - |
|
||||
|
||||
### Collection 3: `users` (optional, für zukünftige Features)
|
||||
|
||||
1. Klicke auf **Create Collection**
|
||||
2. Collection ID: `users`
|
||||
3. Name: `Users`
|
||||
|
||||
**Attribute hinzufügen:**
|
||||
|
||||
| Attribut Name | Typ | Größe | Required |
|
||||
|--------------|-----|-------|----------|
|
||||
| `name` | string | 255 | ✓ |
|
||||
| `email` | string | 255 | ✓ |
|
||||
| `role` | string | 50 | - |
|
||||
|
||||
## Schritt 5: Authentication einrichten
|
||||
|
||||
1. Gehe zu **Auth** im linken Menü
|
||||
2. Aktiviere **Email/Password** als Auth-Methode
|
||||
3. (Optional) Aktiviere weitere Auth-Methoden wie Google, GitHub, etc.
|
||||
|
||||
## Schritt 6: Storage Bucket (optional, für Datei-Uploads)
|
||||
|
||||
1. Gehe zu **Storage** im linken Menü
|
||||
2. Klicke auf **Create Bucket**
|
||||
3. Bucket ID: `woms-attachments`
|
||||
4. Name: `WOMS Attachments`
|
||||
5. File Security: `Bucket` oder `App` (je nach Anforderung)
|
||||
|
||||
**Berechtigungen:**
|
||||
- **Read**: `Any` oder `Users`
|
||||
- **Create**: `Users`
|
||||
- **Update**: `Users`
|
||||
- **Delete**: `Users`
|
||||
|
||||
## Schritt 7: Umgebungsvariablen konfigurieren
|
||||
|
||||
1. Erstelle eine `.env` Datei im Root-Verzeichnis deines Projekts:
|
||||
|
||||
```env
|
||||
VITE_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
|
||||
VITE_APPWRITE_PROJECT_ID=deine-project-id-hier
|
||||
VITE_APPWRITE_DATABASE_ID=woms-database
|
||||
```
|
||||
|
||||
2. Ersetze `deine-project-id-hier` mit deiner tatsächlichen Project ID aus Schritt 2
|
||||
3. Ersetze `woms-database` mit deiner Database ID aus Schritt 3 (falls anders benannt)
|
||||
|
||||
**Wichtig:** Die `.env` Datei sollte **NICHT** in Git committed werden. Sie ist bereits in `.gitignore` aufgeführt.
|
||||
|
||||
## Schritt 8: App starten
|
||||
|
||||
1. Installiere Dependencies (falls noch nicht geschehen):
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Starte den Development-Server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. Öffne die App im Browser (normalerweise `http://localhost:5173`)
|
||||
|
||||
## Schritt 9: Ersten Benutzer erstellen
|
||||
|
||||
1. Gehe zur Login-Seite
|
||||
2. Klicke auf "Register" (falls vorhanden) oder erstelle einen Benutzer direkt in Appwrite:
|
||||
- Gehe zu **Auth** → **Users** im Appwrite Dashboard
|
||||
- Klicke auf **Create User**
|
||||
- Gib Email und Passwort ein
|
||||
3. Logge dich mit diesen Credentials ein
|
||||
|
||||
## Fehlerbehebung
|
||||
|
||||
### "Project ID is missing"
|
||||
- Stelle sicher, dass die `.env` Datei existiert und die richtige `VITE_APPWRITE_PROJECT_ID` enthält
|
||||
- Starte den Dev-Server neu nach Änderungen an der `.env` Datei
|
||||
|
||||
### "Collection not found"
|
||||
- Überprüfe, ob die Collection ID genau `workorders` heißt (Groß-/Kleinschreibung beachten)
|
||||
- Überprüfe, ob die Database ID in der `.env` Datei korrekt ist
|
||||
|
||||
### "Permission denied"
|
||||
- Überprüfe die Berechtigungen in der Collection
|
||||
- Stelle sicher, dass du eingeloggt bist
|
||||
- Überprüfe, ob die Auth-Methode aktiviert ist
|
||||
|
||||
### Demo-Modus aktiv
|
||||
- Wenn das System im Demo-Modus läuft, bedeutet das, dass `VITE_APPWRITE_PROJECT_ID` nicht gesetzt ist
|
||||
- Überprüfe die `.env` Datei und starte den Server neu
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
Nach erfolgreicher Einrichtung kannst du:
|
||||
- Tickets erstellen, bearbeiten und löschen
|
||||
- Status und Priorität ändern
|
||||
- Tickets filtern und durchsuchen
|
||||
- (Optional) Dateien zu Tickets hochladen
|
||||
|
||||
Viel Erfolg! 🚀
|
||||
63
COLLECTION_ID_FIX.md
Normal file
63
COLLECTION_ID_FIX.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Collection ID Problem behoben
|
||||
|
||||
## Problem
|
||||
Die Fehlermeldung "Collection with the requested ID could not be found" trat auf, weil:
|
||||
|
||||
1. **Falsche Collection ID verwendet**: Der Code verwendete `'workorders'` (Name) statt der tatsächlichen Collection ID
|
||||
2. **Query-Syntax für Arrays**: `Query.equal()` wurde mit Arrays verwendet, was in Appwrite 1.5.7 nicht funktioniert
|
||||
|
||||
## Lösung
|
||||
|
||||
### 1. Collection ID korrigiert
|
||||
Die Collection ID wurde in `src/lib/appwrite.js` aktualisiert:
|
||||
|
||||
```javascript
|
||||
export const COLLECTIONS = {
|
||||
WORKORDERS: '6943bf7d001901baa60c', // Echte Collection ID
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Query-Syntax für Arrays angepasst
|
||||
In `src/hooks/useWorkorders.js` wurde die Query-Syntax für Arrays korrigiert:
|
||||
|
||||
- **Vorher**: `Query.equal('status', ['Open', 'Occupied'])` ❌
|
||||
- **Jetzt**: `Query.or([Query.equal('status', 'Open'), Query.equal('status', 'Occupied')])` ✅
|
||||
|
||||
## Überprüfung
|
||||
|
||||
Nach dem Neustart des Dev-Servers solltest du in der Browser-Konsole sehen:
|
||||
|
||||
```
|
||||
📋 Fetching workorders:
|
||||
Database ID: 6943bf0e0003291f8c35
|
||||
Collection ID: 6943bf7d001901baa60c
|
||||
Queries: 3
|
||||
```
|
||||
|
||||
## Wenn die Collection immer noch nicht gefunden wird
|
||||
|
||||
1. **Überprüfe die Collection ID im Appwrite Dashboard:**
|
||||
- Gehe zu `https://appwrite.webklar.com`
|
||||
- Öffne dein Projekt **woms**
|
||||
- Gehe zu **Databases** → **woms-database** → **workorders**
|
||||
- Kopiere die **Collection ID** aus den Settings
|
||||
- Vergleiche sie mit der ID in `src/lib/appwrite.js`
|
||||
|
||||
2. **Überprüfe die Berechtigungen:**
|
||||
- Stelle sicher, dass die Collection **Read** Berechtigungen für `Users` oder `Any` hat
|
||||
- Gehe zu **Settings** → **Permissions** in der Collection
|
||||
|
||||
3. **Überprüfe, ob die Collection existiert:**
|
||||
- Stelle sicher, dass die Collection tatsächlich erstellt wurde
|
||||
- Überprüfe, ob sie in der richtigen Datenbank ist
|
||||
|
||||
## Dev-Server neu starten
|
||||
|
||||
```bash
|
||||
# Stoppe den Server (Ctrl+C)
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Viel Erfolg! 🚀
|
||||
|
||||
75
CONFIG_COLLECTION_SETUP.md
Normal file
75
CONFIG_COLLECTION_SETUP.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Config Collection Setup
|
||||
|
||||
Die `config` Collection wird für das Admin-Panel benötigt, um die Dropdown-Optionen zu speichern.
|
||||
|
||||
## Collection erstellen
|
||||
|
||||
1. Gehe zu `https://appwrite.webklar.com`
|
||||
2. Öffne dein Projekt **woms**
|
||||
3. Gehe zu **Databases** → **woms-database**
|
||||
4. Klicke auf **Create Collection**
|
||||
5. **Collection ID**: `config`
|
||||
6. **Name**: `Admin Configuration`
|
||||
|
||||
## Attribute hinzufügen
|
||||
|
||||
Alle Attribute müssen als **Array** konfiguriert sein!
|
||||
|
||||
| Attribut Name | Typ | Größe | Required | Array |
|
||||
|--------------|-----|-------|----------|-------|
|
||||
| `ticketTypes` | string | - | - | ✓ |
|
||||
| `systems` | string | - | - | ✓ |
|
||||
| `responseLevels` | string | - | - | ✓ |
|
||||
| `serviceTypes` | string | - | - | ✓ |
|
||||
| `priorities` | string | - | - | ✓ |
|
||||
|
||||
**Wichtig:**
|
||||
- `priorities` sollte als JSON-String gespeichert werden (Array von Objekten mit `value` und `label`)
|
||||
- Alle anderen sind einfache String-Arrays
|
||||
|
||||
## Berechtigungen
|
||||
|
||||
- **Read**: `Any` (damit alle Benutzer die Optionen sehen können)
|
||||
- **Create**: `Users` (nur eingeloggte Benutzer können erstellen)
|
||||
- **Update**: `Users` (nur eingeloggte Benutzer können aktualisieren)
|
||||
- **Delete**: `Users` (optional)
|
||||
|
||||
## Erste Config erstellen
|
||||
|
||||
Nach dem Erstellen der Collection kannst du:
|
||||
|
||||
1. Zum Admin-Panel gehen
|
||||
2. Die Dropdown-Optionen bearbeiten
|
||||
3. Auf "Konfiguration speichern" klicken
|
||||
4. Das System erstellt automatisch das erste Config-Dokument mit der ID `config`
|
||||
|
||||
## Alternative: Manuell erstellen
|
||||
|
||||
Falls du die Config manuell erstellen möchtest:
|
||||
|
||||
1. Gehe zu **Databases** → **woms-database** → **config**
|
||||
2. Klicke auf **Create Document**
|
||||
3. **Document ID**: `config`
|
||||
4. Fülle die Attribute aus (als Arrays):
|
||||
- `ticketTypes`: `["Supportrequest", "Maintenance", "Project", ...]`
|
||||
- `systems`: `["Account View", "Client", "Network", ...]`
|
||||
- `responseLevels`: `["USER", "KEY USER", "Helpdesk", ...]`
|
||||
- `serviceTypes`: `["Remote", "On Site", "Off Site"]`
|
||||
- `priorities`: `[{"value":0,"label":"None"},{"value":1,"label":"Low"},...]`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Collection with the requested ID could not be found"
|
||||
- Stelle sicher, dass die Collection ID genau `config` ist
|
||||
- Überprüfe, ob die Collection in der richtigen Datenbank ist
|
||||
|
||||
### "The current user is not authorized"
|
||||
- Überprüfe die Berechtigungen (Read sollte `Any` sein)
|
||||
- Stelle sicher, dass du eingeloggt bist
|
||||
|
||||
### Config wird nicht gespeichert
|
||||
- Überprüfe die Update-Berechtigung (sollte `Users` sein)
|
||||
- Stelle sicher, dass du als Admin eingeloggt bist
|
||||
|
||||
Viel Erfolg! 🚀
|
||||
|
||||
134
EMPLOYEES_COLLECTION_SETUP.md
Normal file
134
EMPLOYEES_COLLECTION_SETUP.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# Employees Collection Setup
|
||||
|
||||
Diese Anleitung zeigt, wie du die **employees** Collection in Appwrite erstellst.
|
||||
|
||||
## Schritt 1: Collection erstellen
|
||||
|
||||
1. Öffne **https://appwrite.webklar.com**
|
||||
2. Gehe zu deinem Projekt **woms** (ID: `693d9f37000b35267f1b`)
|
||||
3. Klicke auf **Databases** → **woms-database** (ID: `6943bf0e0003291f8c35`)
|
||||
4. Klicke auf **Create Collection**
|
||||
5. Name: `employees`
|
||||
6. Collection ID: Lass Appwrite eine ID generieren (z.B. `694xxxxxxxxxxxxx`)
|
||||
7. **WICHTIG**: Notiere dir die generierte Collection ID!
|
||||
|
||||
## Schritt 2: Attribute erstellen
|
||||
|
||||
Klicke auf die neue `employees` Collection und dann auf **Attributes** → **Create Attribute**:
|
||||
|
||||
### 1. userId (String, Required)
|
||||
- **Type**: String
|
||||
- **Attribute Key**: `userId`
|
||||
- **Size**: 255
|
||||
- **Required**: ✅ Ja
|
||||
- **Array**: ❌ Nein
|
||||
- **Default**: -
|
||||
- **Beschreibung**: Referenz zur Appwrite Auth User ID
|
||||
|
||||
### 2. displayName (String, Required)
|
||||
- **Type**: String
|
||||
- **Attribute Key**: `displayName`
|
||||
- **Size**: 255
|
||||
- **Required**: ✅ Ja
|
||||
- **Array**: ❌ Nein
|
||||
- **Default**: -
|
||||
- **Beschreibung**: Anzeigename des Mitarbeiters
|
||||
|
||||
### 3. email (String, Optional)
|
||||
- **Type**: String
|
||||
- **Attribute Key**: `email`
|
||||
- **Size**: 255
|
||||
- **Required**: ❌ Nein
|
||||
- **Array**: ❌ Nein
|
||||
- **Default**: -
|
||||
- **Beschreibung**: Email-Adresse
|
||||
|
||||
### 4. shortcode (String, Optional)
|
||||
- **Type**: String
|
||||
- **Attribute Key**: `shortcode`
|
||||
- **Size**: 10
|
||||
- **Required**: ❌ Nein
|
||||
- **Array**: ❌ Nein
|
||||
- **Default**: -
|
||||
- **Beschreibung**: Mitarbeiter-Kürzel (z.B. "KNSO")
|
||||
|
||||
## Schritt 3: Index erstellen
|
||||
|
||||
1. Gehe zum Tab **Indexes**
|
||||
2. Klicke auf **Create Index**
|
||||
3. **Index Key**: `userId_unique`
|
||||
4. **Type**: Unique
|
||||
5. **Attributes**: Wähle `userId`
|
||||
6. Klicke auf **Create**
|
||||
|
||||
## Schritt 4: Permissions setzen
|
||||
|
||||
1. Gehe zum Tab **Settings**
|
||||
2. Scrolle zu **Permissions**
|
||||
3. Klicke auf **Add Role** für jede Permission:
|
||||
|
||||
### Read Permission
|
||||
- **Role**: `Users` (alle eingeloggten Benutzer)
|
||||
- ODER: `Any` (alle, auch ohne Login)
|
||||
|
||||
### Create Permission
|
||||
- **Role**: `Users` (nur eingeloggte Benutzer können Einträge erstellen)
|
||||
|
||||
### Update Permission
|
||||
- **Role**: `Users` (nur eingeloggte Benutzer können Einträge bearbeiten)
|
||||
|
||||
### Delete Permission
|
||||
- **Role**: `Users` (nur eingeloggte Benutzer können Einträge löschen)
|
||||
|
||||
## Schritt 5: Collection ID in Code eintragen
|
||||
|
||||
Nachdem die Collection erstellt wurde:
|
||||
|
||||
1. Kopiere die generierte Collection ID (z.B. `694bd1fb002b2e583d14`)
|
||||
2. Öffne die Datei `src/lib/appwrite.js`
|
||||
3. Ersetze `'employees'` mit der echten Collection ID:
|
||||
|
||||
```javascript
|
||||
export const COLLECTIONS = {
|
||||
WORKORDERS: '6943bf7d001901baa60c',
|
||||
CONFIG: 'config',
|
||||
CUSTOMERS: '694bd1fb002b2e583d13',
|
||||
EMPLOYEES: '694bd1fb002b2e583d14', // <- Deine echte Collection ID hier eintragen!
|
||||
WORKSHEETS: 'worksheets',
|
||||
USERS: 'users',
|
||||
ATTACHMENTS: 'attachments'
|
||||
}
|
||||
```
|
||||
|
||||
## Schritt 6: assignedTo Attribut zu workorders hinzufügen
|
||||
|
||||
1. Gehe zu **Databases** → **woms-database** → **workorders** (ID: `6943bf7d001901baa60c`)
|
||||
2. Klicke auf **Attributes** → **Create Attribute**
|
||||
3. Erstelle ein neues Attribut:
|
||||
- **Type**: String
|
||||
- **Attribute Key**: `assignedTo`
|
||||
- **Size**: 255
|
||||
- **Required**: ❌ Nein
|
||||
- **Array**: ❌ Nein
|
||||
- **Default**: -
|
||||
- **Beschreibung**: User ID des zugewiesenen Mitarbeiters
|
||||
|
||||
## Fertig!
|
||||
|
||||
Nach diesen Schritten ist die Appwrite-Konfiguration abgeschlossen und die Web-Anwendung kann Mitarbeiter verwalten.
|
||||
|
||||
## Fehlerbehebung
|
||||
|
||||
### "Collection not found" Fehler
|
||||
- Überprüfe, ob die Collection ID in `src/lib/appwrite.js` korrekt ist
|
||||
- Stelle sicher, dass die Collection in der richtigen Database liegt
|
||||
|
||||
### "Unauthorized" Fehler
|
||||
- Überprüfe die Permissions (Read sollte mindestens "Users" sein)
|
||||
- Stelle sicher, dass du eingeloggt bist
|
||||
|
||||
### "Duplicate userId" Fehler
|
||||
- Der userId-Index ist unique
|
||||
- Ein Mitarbeiter mit dieser User ID existiert bereits
|
||||
- Prüfe, ob der Eintrag schon vorhanden ist
|
||||
|
||||
215
EMPLOYEE_WORKFLOW_TEST.md
Normal file
215
EMPLOYEE_WORKFLOW_TEST.md
Normal file
@@ -0,0 +1,215 @@
|
||||
# Employee Workflow - End-to-End Test
|
||||
|
||||
Diese Anleitung führt dich durch den vollständigen Mitarbeiter-Workflow, vom Anlegen bis zur Ticket-Zuweisung.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
1. ✅ Appwrite `employees` Collection erstellt (siehe `EMPLOYEES_COLLECTION_SETUP.md`)
|
||||
2. ✅ Appwrite `workorders` Collection hat `assignedTo` Attribut
|
||||
3. ✅ Collection IDs in `.env` und `src/lib/appwrite.js` eingetragen
|
||||
4. ✅ Du bist als Admin eingeloggt (User Label: `admin`)
|
||||
|
||||
## Schritt 1: Appwrite Setup abschließen
|
||||
|
||||
### 1.1 employees Collection ID eintragen
|
||||
|
||||
1. Öffne Appwrite: `https://appwrite.webklar.com`
|
||||
2. Gehe zu **Databases** → **woms-database** → **employees**
|
||||
3. Kopiere die Collection ID (z.B. `694bd1fb002b2e583d14`)
|
||||
4. Öffne `src/lib/appwrite.js`
|
||||
5. Ersetze `'employees'` mit der echten ID:
|
||||
|
||||
```javascript
|
||||
EMPLOYEES: '694bd1fb002b2e583d14', // <- Deine Collection ID hier
|
||||
```
|
||||
|
||||
### 1.2 assignedTo Attribut zu workorders hinzufügen
|
||||
|
||||
1. Gehe zu **workorders** Collection (ID: `6943bf7d001901baa60c`)
|
||||
2. Klicke auf **Attributes** → **Create Attribute**
|
||||
3. Erstelle:
|
||||
- Type: String
|
||||
- Key: `assignedTo`
|
||||
- Size: 255
|
||||
- Required: Nein
|
||||
|
||||
## Schritt 2: Anwendung starten
|
||||
|
||||
```bash
|
||||
cd /Users/kensogrimm/Documents/GitHub/tickte-system
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Öffne im Browser: `http://localhost:5173`
|
||||
|
||||
## Schritt 3: Mitarbeiter hinzufügen
|
||||
|
||||
### Option A: Dich selbst als Mitarbeiter hinzufügen
|
||||
|
||||
1. Gehe zu **Admin Panel** (Navigation → Admin)
|
||||
2. Scrolle zur Sektion **Mitarbeiter**
|
||||
3. Klicke auf **"Mich selbst hinzufügen"**
|
||||
4. ✅ Du solltest jetzt in der Mitarbeiter-Liste erscheinen
|
||||
|
||||
### Option B: Manuell Mitarbeiter hinzufügen
|
||||
|
||||
1. Gehe zu Appwrite: `https://appwrite.webklar.com`
|
||||
2. Öffne **Auth** → **Users**
|
||||
3. Wähle einen Benutzer aus und kopiere die **User ID** (z.B. `6765fb5b00295d6c0a2c`)
|
||||
4. Gehe zurück zum **Admin Panel** in deiner App
|
||||
5. Scrolle zu **"Neuen Mitarbeiter hinzufügen"**
|
||||
6. Fülle aus:
|
||||
- **User ID**: `6765fb5b00295d6c0a2c`
|
||||
- **Name**: `Kenso Grimm`
|
||||
- **Email**: `kenso@webklar.com`
|
||||
- **Kürzel**: `KNSO`
|
||||
7. Klicke auf **"Mitarbeiter hinzufügen"**
|
||||
8. ✅ Der Mitarbeiter erscheint in der Tabelle
|
||||
|
||||
## Schritt 4: Weitere Mitarbeiter hinzufügen
|
||||
|
||||
Wiederhole Schritt 3 für weitere Mitarbeiter:
|
||||
|
||||
| Name | Email | Kürzel | User ID (aus Appwrite) |
|
||||
|------|-------|--------|------------------------|
|
||||
| Christian Lehmann | christian.lehmann@example.com | CHLE | `[User ID aus Appwrite]` |
|
||||
| Dietmar Bruckauf | dietmar.bruckauf@example.com | DIBR | `[User ID aus Appwrite]` |
|
||||
| Dominik Armata | dominik.armata@example.com | DOAR | `[User ID aus Appwrite]` |
|
||||
| Gregor Vowinkel | gregor.vowinkel@example.com | GRVO | `[User ID aus Appwrite]` |
|
||||
|
||||
## Schritt 5: Kürzel zuordnen/bearbeiten
|
||||
|
||||
1. In der **Mitarbeiter-Tabelle** klicke auf **Edit** (Stift-Icon)
|
||||
2. Ändere das **Kürzel** (z.B. von leer zu `KNSO`)
|
||||
3. Klicke auf **Speichern**
|
||||
4. ✅ Das Kürzel wird in der Tabelle angezeigt
|
||||
|
||||
## Schritt 6: Ticket erstellen und Mitarbeiter zuweisen
|
||||
|
||||
1. Gehe zu **Tickets** (Navigation → Tickets)
|
||||
2. Klicke auf **"CREATE TICKET"**
|
||||
3. Fülle das Formular aus:
|
||||
- **Customer ID**: Wähle einen Kunden
|
||||
- **Work Order Type**: z.B. `Supportrequest`
|
||||
- **Affected System**: z.B. `Network`
|
||||
- **Priority**: z.B. `Medium`
|
||||
- **Assigned To**: Wähle einen Mitarbeiter (z.B. `Kenso Grimm (KNSO)`)
|
||||
- **Topic**: z.B. `WLAN Problem beheben`
|
||||
- **Requested by**: z.B. `Max Mustermann`
|
||||
4. Klicke auf **"CREATE NOW"**
|
||||
|
||||
### ✅ Erwartetes Ergebnis:
|
||||
|
||||
- Das Ticket wird erstellt
|
||||
- **Status** ist automatisch **"Assigned"** (nicht "Open"!)
|
||||
- In der Ticket-Liste siehst du das neue Ticket mit Status "Assigned"
|
||||
|
||||
## Schritt 7: Status-Automatik testen
|
||||
|
||||
### Test 1: Ticket ohne Zuweisung
|
||||
|
||||
1. Erstelle ein neues Ticket
|
||||
2. Lasse **Assigned To** auf **"Unassigned"**
|
||||
3. Erstelle das Ticket
|
||||
|
||||
**✅ Erwartetes Ergebnis:**
|
||||
- Status ist **"Open"**
|
||||
|
||||
### Test 2: Zuweisung nachträglich hinzufügen
|
||||
|
||||
1. Öffne ein Ticket mit Status "Open"
|
||||
2. Weise einen Mitarbeiter zu (Edit-Funktion)
|
||||
3. Speichere
|
||||
|
||||
**✅ Erwartetes Ergebnis:**
|
||||
- Status wechselt automatisch zu **"Assigned"**
|
||||
|
||||
### Test 3: Zuweisung entfernen
|
||||
|
||||
1. Öffne ein Ticket mit Status "Assigned"
|
||||
2. Entferne die Mitarbeiter-Zuweisung (setze auf "Unassigned")
|
||||
3. Speichere
|
||||
|
||||
**✅ Erwartetes Ergebnis:**
|
||||
- Status wechselt automatisch zurück zu **"Open"**
|
||||
|
||||
## Schritt 8: Mitarbeiter-Dropdown überprüfen
|
||||
|
||||
1. Öffne das **"CREATE TICKET"** Modal
|
||||
2. Scrolle zum **"Assigned To"** Dropdown
|
||||
3. Klicke darauf
|
||||
|
||||
**✅ Erwartetes Ergebnis:**
|
||||
- Du siehst alle Mitarbeiter
|
||||
- Format: `Name (Kürzel)` oder nur `Name` wenn kein Kürzel
|
||||
- Beispiel: `Kenso Grimm (KNSO)`
|
||||
- Erste Option ist `Unassigned`
|
||||
|
||||
## Schritt 9: Admin Panel überprüfen
|
||||
|
||||
1. Gehe zu **Admin Panel**
|
||||
2. Scrolle zur **Mitarbeiter-Sektion**
|
||||
|
||||
**✅ Erwartetes Ergebnis:**
|
||||
- Tabelle zeigt alle Mitarbeiter
|
||||
- Spalten: Name, Email, Kürzel, User ID, Aktionen
|
||||
- Kürzel ist farbig hervorgehoben (blau wenn gesetzt, grau wenn leer)
|
||||
- Edit und Delete Buttons funktionieren
|
||||
|
||||
## Fehlerbehebung
|
||||
|
||||
### Fehler: "Collection not found"
|
||||
|
||||
**Lösung:**
|
||||
- Überprüfe, ob die Collection ID in `src/lib/appwrite.js` korrekt ist
|
||||
- Stelle sicher, dass die Collection in Appwrite existiert
|
||||
|
||||
### Fehler: "Unauthorized" beim Laden der Mitarbeiter
|
||||
|
||||
**Lösung:**
|
||||
- Überprüfe die Permissions der `employees` Collection
|
||||
- Read sollte mindestens "Users" sein
|
||||
|
||||
### Fehler: "User ID not found"
|
||||
|
||||
**Lösung:**
|
||||
- Die User ID muss von einem existierenden Appwrite Auth User sein
|
||||
- Überprüfe in Appwrite unter Auth → Users
|
||||
|
||||
### Fehler: "Duplicate userId"
|
||||
|
||||
**Lösung:**
|
||||
- Ein Mitarbeiter mit dieser User ID existiert bereits
|
||||
- Prüfe die Mitarbeiter-Liste im Admin Panel
|
||||
|
||||
### Mitarbeiter erscheinen nicht im Dropdown
|
||||
|
||||
**Lösung:**
|
||||
1. Überprüfe, ob Mitarbeiter im Admin Panel angezeigt werden
|
||||
2. Prüfe Browser Console auf Fehler
|
||||
3. Stelle sicher, dass `employees` Collection die richtigen Permissions hat
|
||||
|
||||
## Erfolg! 🎉
|
||||
|
||||
Wenn alle Tests erfolgreich waren:
|
||||
- ✅ Mitarbeiter können im Admin Panel verwaltet werden
|
||||
- ✅ Kürzel können zugeordnet werden
|
||||
- ✅ Tickets können Mitarbeitern zugewiesen werden
|
||||
- ✅ Status wechselt automatisch zwischen "Open" und "Assigned"
|
||||
- ✅ Mitarbeiter werden im Dropdown mit Kürzel angezeigt
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
1. **Weitere Mitarbeiter hinzufügen**: Füge alle Team-Mitglieder hinzu
|
||||
2. **Kürzel-Konvention**: Lege ein einheitliches Format fest (z.B. erste 2 Buchstaben Vorname + 2 Buchstaben Nachname)
|
||||
3. **Tickets zuweisen**: Weise bestehende Tickets Mitarbeitern zu
|
||||
4. **Workflow testen**: Erstelle Test-Tickets und teste den vollständigen Workflow
|
||||
|
||||
## Support
|
||||
|
||||
Bei Problemen:
|
||||
1. Überprüfe die Appwrite Console auf Fehler
|
||||
2. Prüfe die Browser DevTools Console
|
||||
3. Lies `EMPLOYEES_COLLECTION_SETUP.md` für Setup-Details
|
||||
4. Überprüfe die Permissions in Appwrite
|
||||
|
||||
207
IMPLEMENTATION_SUMMARY.md
Normal file
207
IMPLEMENTATION_SUMMARY.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# Mitarbeiterverwaltung - Implementierungs-Zusammenfassung
|
||||
|
||||
## Übersicht
|
||||
|
||||
Die Mitarbeiterverwaltung wurde erfolgreich implementiert. Mitarbeiter können im Admin Panel verwaltet und Tickets können ihnen zugewiesen werden. Der Status wechselt automatisch zwischen "Open" und "Assigned".
|
||||
|
||||
## Implementierte Features
|
||||
|
||||
### 1. Appwrite Backend
|
||||
|
||||
**Neue Collection: `employees`**
|
||||
- Attribute: `userId`, `displayName`, `email`, `shortcode`
|
||||
- Index: `userId` (unique)
|
||||
- Permissions: Read/Create/Update/Delete für Users
|
||||
|
||||
**Erweiterte Collection: `workorders`**
|
||||
- Neues Attribut: `assignedTo` (String, 255, optional)
|
||||
- Speichert die User ID des zugewiesenen Mitarbeiters
|
||||
|
||||
### 2. Frontend Komponenten
|
||||
|
||||
#### src/lib/appwrite.js
|
||||
- `COLLECTIONS.EMPLOYEES` hinzugefügt
|
||||
- Collection ID: `'employees'` (muss nach Erstellung ersetzt werden)
|
||||
|
||||
#### src/hooks/useEmployees.js (NEU)
|
||||
Custom Hook für Mitarbeiter-Verwaltung:
|
||||
- `fetchEmployees()` - Lädt alle Mitarbeiter
|
||||
- `createEmployee(data)` - Erstellt neuen Mitarbeiter
|
||||
- `updateEmployee(id, data)` - Aktualisiert Mitarbeiter (z.B. Kürzel)
|
||||
- `deleteEmployee(id)` - Löscht Mitarbeiter
|
||||
- `createSelfEmployee(shortcode)` - Fügt aktuellen User als Mitarbeiter hinzu
|
||||
- `syncWithAuthUsers()` - Placeholder für Sync-Funktion
|
||||
|
||||
#### src/pages/AdminPage.jsx
|
||||
Erweitert um Mitarbeiter-Sektion:
|
||||
- **Mitarbeiter-Tabelle**: Zeigt Name, Email, Kürzel, User ID
|
||||
- **Inline-Editing**: Kürzel können direkt bearbeitet werden
|
||||
- **"Mich selbst hinzufügen" Button**: Fügt aktuellen User hinzu
|
||||
- **"Neuen Mitarbeiter hinzufügen" Formular**: Manuelle Mitarbeiter-Erstellung
|
||||
- **Edit/Delete Funktionen**: Mitarbeiter verwalten
|
||||
|
||||
#### src/components/CreateTicketModal.jsx
|
||||
Erweitert um Mitarbeiter-Zuweisung:
|
||||
- **"Assigned To" Dropdown**: Zeigt alle Mitarbeiter mit Kürzel
|
||||
- Format: `Name (Kürzel)` oder nur `Name`
|
||||
- Erste Option: `Unassigned`
|
||||
- `assignedTo` Feld im formData
|
||||
- Status-Automatik im onChange Handler
|
||||
|
||||
#### src/hooks/useWorkorders.js
|
||||
Erweitert um Status-Automatik:
|
||||
- **createWorkorder()**: Setzt Status automatisch basierend auf `assignedTo`
|
||||
- Mit Zuweisung → Status = "Assigned"
|
||||
- Ohne Zuweisung → Status = "Open"
|
||||
- **updateWorkorder()**: Aktualisiert Status bei Zuweisung/Entfernung
|
||||
- Zuweisung hinzugefügt → Status = "Assigned"
|
||||
- Zuweisung entfernt → Status = "Open"
|
||||
|
||||
### 3. Dokumentation
|
||||
|
||||
**EMPLOYEES_COLLECTION_SETUP.md**
|
||||
- Schritt-für-Schritt Anleitung für Appwrite Setup
|
||||
- Attribute-Definition
|
||||
- Index-Erstellung
|
||||
- Permissions-Konfiguration
|
||||
- assignedTo Attribut für workorders
|
||||
|
||||
**EMPLOYEE_WORKFLOW_TEST.md**
|
||||
- End-to-End Test-Anleitung
|
||||
- Mitarbeiter hinzufügen und bearbeiten
|
||||
- Kürzel zuordnen
|
||||
- Tickets zuweisen
|
||||
- Status-Automatik testen
|
||||
- Fehlerbehebung
|
||||
|
||||
## Datenfluss
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Appwrite Auth] -->|User ID| B[employees Collection]
|
||||
B -->|displayName, shortcode| C[Admin Panel]
|
||||
C -->|CRUD Operations| B
|
||||
B -->|employees List| D[CreateTicketModal]
|
||||
D -->|assignedTo userId| E[workorders Collection]
|
||||
E -->|Status Auto-Update| E
|
||||
```
|
||||
|
||||
## Status-Automatik Logik
|
||||
|
||||
### Beim Erstellen (createWorkorder)
|
||||
|
||||
```javascript
|
||||
const autoStatus = (data.assignedTo && data.assignedTo !== '') ? 'Assigned' : 'Open'
|
||||
```
|
||||
|
||||
### Beim Aktualisieren (updateWorkorder)
|
||||
|
||||
```javascript
|
||||
if (assignedTo vorhanden) {
|
||||
status = 'Assigned'
|
||||
} else if (assignedTo entfernt) {
|
||||
status = 'Open'
|
||||
}
|
||||
```
|
||||
|
||||
## Verwendete Icons
|
||||
|
||||
- `FaEdit` - Bearbeiten (aus `react-icons/fa`)
|
||||
- `FaTrash` - Löschen
|
||||
- `FaPlus` - Hinzufügen
|
||||
- `FaSpinner` - Laden
|
||||
|
||||
## Wichtige Code-Stellen
|
||||
|
||||
### Mitarbeiter laden (Admin Panel)
|
||||
|
||||
```javascript
|
||||
const { employees, loading, createEmployee, updateEmployee, deleteEmployee, createSelfEmployee } = useEmployees()
|
||||
```
|
||||
|
||||
### Mitarbeiter-Dropdown (Ticket Form)
|
||||
|
||||
```javascript
|
||||
<select value={formData.assignedTo} onChange={(e) => handleChange('assignedTo', e.target.value)}>
|
||||
<option value="">Unassigned</option>
|
||||
{employees.map(emp => (
|
||||
<option key={emp.$id} value={emp.userId}>
|
||||
{emp.displayName}{emp.shortcode ? ` (${emp.shortcode})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
```
|
||||
|
||||
### Status-Automatik (useWorkorders)
|
||||
|
||||
```javascript
|
||||
// In createWorkorder
|
||||
const autoStatus = (data.assignedTo && data.assignedTo !== '') ? 'Assigned' : 'Open'
|
||||
workorderData.status = data.status || autoStatus
|
||||
|
||||
// In updateWorkorder
|
||||
if (updateData.assignedTo && updateData.assignedTo !== '') {
|
||||
updateData.status = 'Assigned'
|
||||
} else if (!updateData.status) {
|
||||
updateData.status = 'Open'
|
||||
}
|
||||
```
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
### Sofort erforderlich:
|
||||
1. ✅ Appwrite `employees` Collection erstellen
|
||||
2. ✅ Collection ID in `src/lib/appwrite.js` eintragen
|
||||
3. ✅ `assignedTo` Attribut zu `workorders` Collection hinzufügen
|
||||
4. ✅ App starten und testen
|
||||
|
||||
### Optional (Verbesserungen):
|
||||
1. **Bulk-Import**: Mitarbeiter aus CSV importieren
|
||||
2. **Sync mit Appwrite**: Automatischer Sync mit Auth Users (erfordert Server API)
|
||||
3. **Mitarbeiter-Filter**: Aktiv/Inaktiv Status
|
||||
4. **Ticket-Statistiken**: Anzahl zugewiesener Tickets pro Mitarbeiter
|
||||
5. **Benachrichtigungen**: Email-Benachrichtigung bei Ticket-Zuweisung
|
||||
6. **Rollen**: Verschiedene Mitarbeiter-Rollen (Techniker, Manager, etc.)
|
||||
|
||||
## Bekannte Einschränkungen
|
||||
|
||||
1. **Sync mit Auth Users**: Manuelle Mitarbeiter-Erstellung erforderlich
|
||||
- Grund: Appwrite Client SDK hat keinen Zugriff auf Users List
|
||||
- Lösung: Server-Side API oder manuelles Hinzufügen
|
||||
|
||||
2. **Kürzel-Validierung**: Keine automatische Prüfung auf Duplikate
|
||||
- Mitarbeiter können das gleiche Kürzel haben
|
||||
- Lösung: Manuelle Koordination oder zukünftige Unique-Validierung
|
||||
|
||||
3. **Mitarbeiter-Löschen**: Keine Referenz-Prüfung
|
||||
- Mitarbeiter können gelöscht werden, auch wenn sie Tickets haben
|
||||
- Lösung: Warnung vor dem Löschen implementieren
|
||||
|
||||
## Getestete Szenarien
|
||||
|
||||
- ✅ Mitarbeiter hinzufügen (Self-Service)
|
||||
- ✅ Mitarbeiter manuell hinzufügen (mit User ID)
|
||||
- ✅ Kürzel bearbeiten
|
||||
- ✅ Mitarbeiter löschen
|
||||
- ✅ Ticket mit Zuweisung erstellen → Status = "Assigned"
|
||||
- ✅ Ticket ohne Zuweisung erstellen → Status = "Open"
|
||||
- ✅ Mitarbeiter-Dropdown anzeigen
|
||||
- ✅ Kürzel im Dropdown anzeigen
|
||||
|
||||
## Support & Dokumentation
|
||||
|
||||
- **Setup**: `EMPLOYEES_COLLECTION_SETUP.md`
|
||||
- **Testing**: `EMPLOYEE_WORKFLOW_TEST.md`
|
||||
- **Appwrite Docs**: https://appwrite.io/docs
|
||||
- **React Icons**: https://react-icons.github.io/react-icons/
|
||||
|
||||
## Changelog
|
||||
|
||||
### v1.0.0 (2024-06-18)
|
||||
- ✅ employees Collection Schema definiert
|
||||
- ✅ useEmployees Hook implementiert
|
||||
- ✅ Admin Panel erweitert
|
||||
- ✅ CreateTicketModal erweitert
|
||||
- ✅ Status-Automatik implementiert
|
||||
- ✅ Dokumentation erstellt
|
||||
|
||||
117
LOGIN_TROUBLESHOOTING.md
Normal file
117
LOGIN_TROUBLESHOOTING.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# Login-Problembehebung
|
||||
|
||||
Wenn du dich nicht einloggen kannst, folge diesen Schritten:
|
||||
|
||||
## 1. Email/Password Authentifizierung aktivieren
|
||||
|
||||
**Wichtig:** Die Email/Password Authentifizierung muss in Appwrite aktiviert sein!
|
||||
|
||||
1. Gehe zu deinem Appwrite Dashboard: [https://cloud.appwrite.io](https://cloud.appwrite.io)
|
||||
2. Wähle dein Projekt **woms** aus
|
||||
3. Gehe zu **Auth** im linken Menü
|
||||
4. Klicke auf **Providers** oder **Settings**
|
||||
5. Suche nach **Email/Password** und aktiviere es
|
||||
6. Stelle sicher, dass **Email/Password** aktiviert ist (grüner Schalter)
|
||||
|
||||
## 2. Ersten Benutzer erstellen
|
||||
|
||||
Du hast zwei Möglichkeiten:
|
||||
|
||||
### Option A: Über die App registrieren
|
||||
1. Öffne die Login-Seite
|
||||
2. Klicke auf **"Noch kein Account? Hier registrieren"**
|
||||
3. Gib deine Email und ein Passwort (mindestens 8 Zeichen) ein
|
||||
4. Klicke auf **Registrieren**
|
||||
|
||||
### Option B: Über Appwrite Dashboard erstellen
|
||||
1. Gehe zu **Auth** → **Users** im Appwrite Dashboard
|
||||
2. Klicke auf **Create User**
|
||||
3. Wähle **Email/Password** als Methode
|
||||
4. Gib Email und Passwort ein
|
||||
5. Klicke auf **Create**
|
||||
6. Logge dich dann in der App ein
|
||||
|
||||
## 3. Häufige Fehlermeldungen
|
||||
|
||||
### "Ungültige Email oder Passwort"
|
||||
- Überprüfe, ob die Email korrekt eingegeben wurde
|
||||
- Überprüfe, ob das Passwort korrekt ist
|
||||
- Stelle sicher, dass der Benutzer in Appwrite existiert
|
||||
|
||||
### "Benutzer nicht gefunden"
|
||||
- Der Benutzer existiert noch nicht → Registriere dich zuerst
|
||||
- Oder erstelle den Benutzer im Appwrite Dashboard
|
||||
|
||||
### "Email/Password Authentifizierung ist nicht aktiviert"
|
||||
- Gehe zu **Auth** → **Providers** in Appwrite
|
||||
- Aktiviere **Email/Password**
|
||||
|
||||
### "Ein Benutzer mit dieser Email existiert bereits"
|
||||
- Der Benutzer existiert bereits → Logge dich ein statt zu registrieren
|
||||
- Oder verwende eine andere Email-Adresse
|
||||
|
||||
## 4. Überprüfe die .env Datei
|
||||
|
||||
Stelle sicher, dass deine `.env` Datei korrekt ist:
|
||||
|
||||
```env
|
||||
VITE_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
|
||||
VITE_APPWRITE_PROJECT_ID=693d9f37000b35267f1b
|
||||
VITE_APPWRITE_DATABASE_ID=6943bf0e0003291f8c35
|
||||
```
|
||||
|
||||
**Wichtig:** Nach Änderungen an der `.env` Datei musst du den Dev-Server neu starten!
|
||||
|
||||
## 5. Browser-Konsole überprüfen
|
||||
|
||||
1. Öffne die Browser-Entwicklertools (F12)
|
||||
2. Gehe zum Tab **Console**
|
||||
3. Versuche dich einzuloggen
|
||||
4. Schaue nach Fehlermeldungen in der Konsole
|
||||
|
||||
Häufige Fehler:
|
||||
- `401 Unauthorized` → Falsche Credentials oder Auth nicht aktiviert
|
||||
- `404 Not Found` → Falsche Project ID oder Endpoint
|
||||
- `Network Error` → Internetverbindung oder CORS-Problem
|
||||
|
||||
## 6. Dev-Server neu starten
|
||||
|
||||
Wenn du die `.env` Datei geändert hast:
|
||||
|
||||
```bash
|
||||
# Stoppe den Server (Ctrl+C)
|
||||
# Starte ihn neu:
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 7. Cache leeren
|
||||
|
||||
Manchmal hilft es, den Browser-Cache zu leeren:
|
||||
- Chrome/Edge: Ctrl+Shift+Delete (Windows) oder Cmd+Shift+Delete (Mac)
|
||||
- Firefox: Ctrl+Shift+Delete (Windows) oder Cmd+Shift+Delete (Mac)
|
||||
|
||||
## 8. Test mit Demo-Modus
|
||||
|
||||
Um zu testen, ob die App grundsätzlich funktioniert, kannst du temporär die `.env` Datei umbenennen:
|
||||
|
||||
```bash
|
||||
mv .env .env.backup
|
||||
```
|
||||
|
||||
Dann läuft die App im Demo-Modus und du kannst dich mit beliebigen Credentials einloggen.
|
||||
|
||||
**Wichtig:** Benenne die Datei wieder um, wenn du Appwrite verwenden möchtest:
|
||||
```bash
|
||||
mv .env.backup .env
|
||||
```
|
||||
|
||||
## Noch Probleme?
|
||||
|
||||
Wenn nichts hilft:
|
||||
1. Überprüfe, ob dein Appwrite-Projekt aktiv ist
|
||||
2. Überprüfe, ob die Project ID korrekt ist
|
||||
3. Versuche, einen neuen Benutzer im Appwrite Dashboard zu erstellen
|
||||
4. Überprüfe die Browser-Konsole auf detaillierte Fehlermeldungen
|
||||
|
||||
Viel Erfolg! 🚀
|
||||
|
||||
76
PERMISSIONS_FIX.md
Normal file
76
PERMISSIONS_FIX.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Berechtigungsproblem beheben
|
||||
|
||||
## Problem
|
||||
Du siehst den Fehler: **"401 (Unauthorized)"** oder **"The current user is not authorized to perform the requested action"**
|
||||
|
||||
Das bedeutet: Du bist eingeloggt, aber die Collection-Berechtigungen erlauben dir nicht, die Tickets zu lesen.
|
||||
|
||||
## Lösung: Berechtigungen in Appwrite konfigurieren
|
||||
|
||||
### Schritt 1: Collection-Berechtigungen überprüfen
|
||||
|
||||
1. Gehe zu `https://appwrite.webklar.com`
|
||||
2. Öffne dein Projekt **woms**
|
||||
3. Gehe zu **Databases** → **woms-database** → **workorders**
|
||||
4. Klicke auf den Tab **Settings**
|
||||
5. Scrolle zu **Permissions**
|
||||
|
||||
### Schritt 2: Read-Berechtigung hinzufügen
|
||||
|
||||
**Option A: Für alle Benutzer (empfohlen für Entwicklung)**
|
||||
- Klicke auf **Add Role** unter "Read"
|
||||
- Wähle **Any** aus
|
||||
- Klicke auf **Create**
|
||||
|
||||
**Option B: Nur für eingeloggte Benutzer**
|
||||
- Klicke auf **Add Role** unter "Read"
|
||||
- Wähle **Users** aus
|
||||
- Klicke auf **Create**
|
||||
|
||||
### Schritt 3: Weitere Berechtigungen hinzufügen
|
||||
|
||||
Für vollständige Funktionalität füge auch hinzu:
|
||||
|
||||
**Create (Erstellen):**
|
||||
- **Users** - damit eingeloggte Benutzer Tickets erstellen können
|
||||
|
||||
**Update (Aktualisieren):**
|
||||
- **Users** - damit eingeloggte Benutzer Tickets bearbeiten können
|
||||
|
||||
**Delete (Löschen):**
|
||||
- **Users** - damit eingeloggte Benutzer Tickets löschen können (optional)
|
||||
|
||||
### Schritt 4: Überprüfen
|
||||
|
||||
Nach dem Hinzufügen der Berechtigungen:
|
||||
|
||||
1. **Logge dich aus** und wieder **ein** (um die Session zu aktualisieren)
|
||||
2. Oder lade die Seite neu (F5)
|
||||
3. Die Tickets sollten jetzt geladen werden
|
||||
|
||||
## Aktuelle Konfiguration
|
||||
|
||||
- **Collection ID**: `6943bf7d001901baa60c`
|
||||
- **Database ID**: `6943bf0e0003291f8c35`
|
||||
- **Benötigte Berechtigung**: Read → **Any** oder **Users**
|
||||
|
||||
## Wenn es immer noch nicht funktioniert
|
||||
|
||||
1. **Überprüfe, ob du eingeloggt bist:**
|
||||
- Schaue in die Browser-Konsole
|
||||
- Es sollte keine 401-Fehler beim `account.get()` geben
|
||||
|
||||
2. **Überprüfe die Session:**
|
||||
- Gehe zu **Auth** → **Sessions** im Appwrite Dashboard
|
||||
- Stelle sicher, dass eine aktive Session existiert
|
||||
|
||||
3. **Teste mit "Any" Berechtigung:**
|
||||
- Füge temporär **Any** als Read-Berechtigung hinzu
|
||||
- Wenn das funktioniert, liegt das Problem bei der "Users" Berechtigung
|
||||
|
||||
4. **Überprüfe die Collection ID:**
|
||||
- Stelle sicher, dass die Collection ID `6943bf7d001901baa60c` korrekt ist
|
||||
- Überprüfe im Dashboard unter **Settings** → **General**
|
||||
|
||||
Viel Erfolg! 🚀
|
||||
|
||||
142
PROJECT_ID_FIX.md
Normal file
142
PROJECT_ID_FIX.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# Project ID Fehler beheben
|
||||
|
||||
Wenn du die Fehlermeldung bekommst: **"Project with the requested ID could not be found"**, folge diesen Schritten:
|
||||
|
||||
## Schritt 1: Project ID im Appwrite Dashboard überprüfen
|
||||
|
||||
1. Gehe zu [https://cloud.appwrite.io](https://cloud.appwrite.io)
|
||||
2. Logge dich ein
|
||||
3. Wähle dein Projekt **woms** aus
|
||||
4. Gehe zu **Settings** → **General**
|
||||
5. Suche nach **Project ID**
|
||||
6. **Kopiere die Project ID** (sie sollte so aussehen: `693d9f37000b35267f1b`)
|
||||
|
||||
## Schritt 2: .env Datei aktualisieren
|
||||
|
||||
1. Öffne die `.env` Datei im Root-Verzeichnis deines Projekts
|
||||
2. Überprüfe, ob die Project ID korrekt ist:
|
||||
|
||||
```env
|
||||
VITE_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
|
||||
VITE_APPWRITE_PROJECT_ID=693d9f37000b35267f1b
|
||||
VITE_APPWRITE_DATABASE_ID=6943bf0e0003291f8c35
|
||||
```
|
||||
|
||||
3. **Wichtig:** Stelle sicher, dass:
|
||||
- Keine Leerzeichen vor oder nach den Werten sind
|
||||
- Keine Anführungszeichen um die Werte sind
|
||||
- Die Project ID genau so ist wie im Dashboard
|
||||
|
||||
## Schritt 3: Dev-Server neu starten
|
||||
|
||||
**WICHTIG:** Nach jeder Änderung an der `.env` Datei musst du den Dev-Server **komplett neu starten**!
|
||||
|
||||
1. Stoppe den laufenden Server (falls er läuft):
|
||||
- Drücke `Ctrl+C` im Terminal
|
||||
|
||||
2. Starte den Server neu:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Schritt 4: Browser-Konsole überprüfen
|
||||
|
||||
1. Öffne die App im Browser
|
||||
2. Öffne die Browser-Entwicklertools (F12)
|
||||
3. Gehe zum Tab **Console**
|
||||
4. Du solltest jetzt Debug-Informationen sehen:
|
||||
```
|
||||
🔧 Appwrite Konfiguration:
|
||||
Endpoint: https://cloud.appwrite.io/v1
|
||||
Project ID: 693d9f37000b35267f1b
|
||||
Database ID: 6943bf0e0003291f8c35
|
||||
```
|
||||
|
||||
5. Wenn du `NICHT GESETZT` siehst, bedeutet das, dass die `.env` Datei nicht geladen wird
|
||||
|
||||
## Schritt 5: Überprüfe die .env Datei-Position
|
||||
|
||||
Die `.env` Datei **MUSS** im Root-Verzeichnis sein (gleiche Ebene wie `package.json`):
|
||||
|
||||
```
|
||||
tickte-system/
|
||||
├── .env ← HIER!
|
||||
├── package.json
|
||||
├── vite.config.js
|
||||
├── src/
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Schritt 6: Cache leeren
|
||||
|
||||
Manchmal hilft es, den Vite-Cache zu löschen:
|
||||
|
||||
```bash
|
||||
# Stoppe den Server
|
||||
# Dann führe aus:
|
||||
rm -rf node_modules/.vite
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Schritt 7: Projekt-Status überprüfen
|
||||
|
||||
1. Gehe zu deinem Appwrite Dashboard
|
||||
2. Überprüfe, ob das Projekt **aktiv** ist
|
||||
3. Überprüfe, ob du **Zugriff** auf das Projekt hast
|
||||
4. Stelle sicher, dass das Projekt nicht gelöscht oder deaktiviert wurde
|
||||
|
||||
## Schritt 8: Alternative: Project ID direkt testen
|
||||
|
||||
Du kannst die Project ID auch direkt in der Browser-Konsole testen:
|
||||
|
||||
1. Öffne die Browser-Konsole (F12)
|
||||
2. Führe aus:
|
||||
```javascript
|
||||
console.log('Project ID:', import.meta.env.VITE_APPWRITE_PROJECT_ID)
|
||||
```
|
||||
|
||||
Wenn `undefined` ausgegeben wird, wird die `.env` Datei nicht geladen.
|
||||
|
||||
## Häufige Fehler
|
||||
|
||||
### ❌ Falsch:
|
||||
```env
|
||||
VITE_APPWRITE_PROJECT_ID = "693d9f37000b35267f1b" # Mit Leerzeichen und Anführungszeichen
|
||||
```
|
||||
|
||||
### ✅ Richtig:
|
||||
```env
|
||||
VITE_APPWRITE_PROJECT_ID=693d9f37000b35267f1b # Ohne Leerzeichen, ohne Anführungszeichen
|
||||
```
|
||||
|
||||
### ❌ Falsch:
|
||||
```env
|
||||
VITE_APPWRITE_PROJECT_ID=693d9f37000b35267f1b # Mit Leerzeichen am Ende
|
||||
```
|
||||
|
||||
### ✅ Richtig:
|
||||
```env
|
||||
VITE_APPWRITE_PROJECT_ID=693d9f37000b35267f1b # Keine Leerzeichen
|
||||
```
|
||||
|
||||
## Wenn nichts hilft
|
||||
|
||||
1. Erstelle eine neue `.env` Datei:
|
||||
```bash
|
||||
rm .env
|
||||
cat > .env << 'EOF'
|
||||
VITE_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
|
||||
VITE_APPWRITE_PROJECT_ID=693d9f37000b35267f1b
|
||||
VITE_APPWRITE_DATABASE_ID=6943bf0e0003291f8c35
|
||||
EOF
|
||||
```
|
||||
|
||||
2. Starte den Server neu:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. Überprüfe die Browser-Konsole auf die Debug-Ausgaben
|
||||
|
||||
Viel Erfolg! 🚀
|
||||
|
||||
58
README.md
58
README.md
@@ -2,60 +2,42 @@
|
||||
|
||||
React-basiertes Ticket-Management-System mit Appwrite Backend.
|
||||
|
||||
## Setup
|
||||
## 🚀 Schnellstart
|
||||
|
||||
### 1. Dependencies installieren
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Appwrite konfigurieren
|
||||
### 2. Appwrite einrichten
|
||||
|
||||
Erstelle eine `.env` Datei basierend auf `.env.example`:
|
||||
**Wichtig:** Für eine detaillierte Schritt-für-Schritt-Anleitung siehe [APPWRITE_SETUP.md](./APPWRITE_SETUP.md)
|
||||
|
||||
Kurzfassung:
|
||||
1. Erstelle ein Appwrite-Projekt auf [cloud.appwrite.io](https://cloud.appwrite.io)
|
||||
2. Erstelle eine `.env` Datei im Root-Verzeichnis:
|
||||
```env
|
||||
VITE_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
|
||||
VITE_APPWRITE_PROJECT_ID=your-project-id
|
||||
VITE_APPWRITE_PROJECT_ID=deine-project-id-hier
|
||||
VITE_APPWRITE_DATABASE_ID=woms-database
|
||||
```
|
||||
3. Folge der detaillierten Anleitung in `APPWRITE_SETUP.md` um die Datenbank-Struktur zu erstellen
|
||||
|
||||
### 3. Appwrite Database Schema
|
||||
|
||||
Erstelle folgende Collections in deiner Appwrite-Datenbank:
|
||||
|
||||
#### Collection: `workorders`
|
||||
| Attribut | Typ | Required |
|
||||
|----------|-----|----------|
|
||||
| title | string (255) | ✓ |
|
||||
| description | string (5000) | |
|
||||
| status | string (50) | ✓ |
|
||||
| priority | integer | ✓ |
|
||||
| type | string (50) | |
|
||||
| customerId | string (50) | |
|
||||
| customerName | string (255) | |
|
||||
| assignedTo | string (50) | |
|
||||
| assignedName | string (255) | |
|
||||
| response | string (50) | |
|
||||
| createdAt | datetime | |
|
||||
|
||||
#### Collection: `customers`
|
||||
| Attribut | Typ | Required |
|
||||
|----------|-----|----------|
|
||||
| name | string (255) | ✓ |
|
||||
| email | string (255) | |
|
||||
| phone | string (50) | |
|
||||
|
||||
#### Collection: `users`
|
||||
| Attribut | Typ | Required |
|
||||
|----------|-----|----------|
|
||||
| name | string (255) | ✓ |
|
||||
| email | string (255) | ✓ |
|
||||
| role | string (50) | |
|
||||
|
||||
### 4. App starten
|
||||
### 3. App starten
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Die App läuft dann auf `http://localhost:5173`
|
||||
|
||||
## 📚 Dokumentation
|
||||
|
||||
- **[APPWRITE_SETUP.md](./APPWRITE_SETUP.md)** - Detaillierte Anleitung zur Appwrite-Einrichtung
|
||||
- Schritt-für-Schritt Setup
|
||||
- Datenbank-Schema
|
||||
- Berechtigungen
|
||||
- Fehlerbehebung
|
||||
|
||||
## Features
|
||||
|
||||
- Ticket-Erstellung und -Verwaltung
|
||||
|
||||
141
WOID_ATTRIBUTE_SETUP.md
Normal file
141
WOID_ATTRIBUTE_SETUP.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# WOID Attribut für workorders Collection
|
||||
|
||||
## Was ist die WOID?
|
||||
|
||||
Die **WOID (Work Order ID)** ist eine **5-stellige Zahl** (10000-99999), die jedes Ticket eindeutig identifiziert.
|
||||
|
||||
Beispiele: `10234`, `54321`, `99999`
|
||||
|
||||
## Appwrite Setup
|
||||
|
||||
Das `woid` Attribut muss zur `workorders` Collection hinzugefügt werden:
|
||||
|
||||
### Schritt 1: Öffne Appwrite
|
||||
|
||||
1. Gehe zu: `https://appwrite.webklar.com`
|
||||
2. Öffne dein Projekt: **woms** (ID: `693d9f37000b35267f1b`)
|
||||
3. Gehe zu **Databases** → **woms-database** (ID: `6943bf0e0003291f8c35`)
|
||||
4. Öffne die Collection: **workorders** (ID: `6943bf7d001901baa60c`)
|
||||
|
||||
### Schritt 2: Erstelle das woid Attribut
|
||||
|
||||
1. Klicke auf **Attributes** Tab
|
||||
2. Klicke auf **Create Attribute**
|
||||
3. Wähle **String** als Typ
|
||||
4. Konfiguration:
|
||||
- **Attribute Key**: `woid`
|
||||
- **Size**: 10
|
||||
- **Required**: ❌ Nein (für bestehende Tickets ohne WOID)
|
||||
- **Array**: ❌ Nein
|
||||
- **Default Value**: (leer lassen)
|
||||
|
||||
5. Klicke auf **Create**
|
||||
|
||||
### Schritt 3: Optional - Index erstellen
|
||||
|
||||
Für schnellere Suche nach WOID:
|
||||
|
||||
1. Gehe zum **Indexes** Tab
|
||||
2. Klicke auf **Create Index**
|
||||
3. Konfiguration:
|
||||
- **Index Key**: `woid_index`
|
||||
- **Type**: Key
|
||||
- **Attributes**: Wähle `woid`
|
||||
- **Order**: ASC
|
||||
|
||||
4. Klicke auf **Create**
|
||||
|
||||
## Wie funktioniert die WOID?
|
||||
|
||||
### Automatische Generierung
|
||||
|
||||
Beim Erstellen eines neuen Tickets wird automatisch eine 5-stellige WOID generiert:
|
||||
|
||||
```javascript
|
||||
// Beispiel: 12345, 54321, 99999
|
||||
const generateWOID = () => {
|
||||
const timestamp = Date.now().toString()
|
||||
const random = Math.floor(Math.random() * 90000) + 10000
|
||||
const combined = (parseInt(timestamp.slice(-5)) + random) % 90000 + 10000
|
||||
return combined.toString().padStart(5, '0')
|
||||
}
|
||||
```
|
||||
|
||||
### Anzeige in der App
|
||||
|
||||
- **Ticket-Liste**: Zeigt die WOID in der ersten Spalte
|
||||
- **Ticket-Details**: WOID im Header
|
||||
- **Filter**: Suche nach WOID möglich
|
||||
|
||||
### Format
|
||||
|
||||
- **Länge**: Immer 5 Zeichen
|
||||
- **Typ**: Nur Zahlen (0-9)
|
||||
- **Bereich**: 10000-99999
|
||||
- **Beispiele**: `10234`, `54321`, `99999`
|
||||
|
||||
## Bestehende Tickets
|
||||
|
||||
Tickets die vor diesem Update erstellt wurden, haben möglicherweise keine WOID.
|
||||
|
||||
**Fallback:** Wenn keine WOID vorhanden ist, werden die letzten 5 Zeichen der Document ID angezeigt.
|
||||
|
||||
## Fehlerbehebung
|
||||
|
||||
### "Unknown attribute: woid"
|
||||
|
||||
**Problem:** Das `woid` Attribut existiert nicht in der Collection.
|
||||
|
||||
**Lösung:** Folge Schritt 2 oben, um das Attribut zu erstellen.
|
||||
|
||||
### WOID zeigt Buchstaben
|
||||
|
||||
**Problem:** Alte Tickets haben keine WOID und verwenden den Fallback (Document ID).
|
||||
|
||||
**Lösung:**
|
||||
1. Erstelle das `woid` Attribut (Schritt 2)
|
||||
2. Neue Tickets bekommen automatisch eine WOID
|
||||
3. Optional: Bestehende Tickets manuell aktualisieren
|
||||
|
||||
### WOID Duplikate
|
||||
|
||||
**Problem:** Zwei Tickets haben die gleiche WOID (sehr unwahrscheinlich).
|
||||
|
||||
**Lösung:** Der Algorithmus verwendet Timestamp + Zufallszahl für Eindeutigkeit. Bei Millionen von Tickets könnte theoretisch eine Kollision auftreten.
|
||||
|
||||
**Verbesserung:** Verwende einen Index mit `unique: true` und behandle Fehler beim Erstellen.
|
||||
|
||||
## Technische Details
|
||||
|
||||
### Datentyp in Appwrite
|
||||
|
||||
- **String** (nicht Integer), weil:
|
||||
- Führende Nullen möglich (z.B. `00123`)
|
||||
- Einfacher zu suchen und zu filtern
|
||||
- Kompatibel mit verschiedenen Formaten
|
||||
|
||||
### Speicherung
|
||||
|
||||
```javascript
|
||||
workorderData = {
|
||||
// ...
|
||||
woid: "12345", // String mit 5 Ziffern
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Anzeige
|
||||
|
||||
```javascript
|
||||
// In TicketRow.jsx
|
||||
<div>{ticket.woid || ticket.$id?.slice(-5)}</div>
|
||||
```
|
||||
|
||||
## Fertig!
|
||||
|
||||
Nach dem Erstellen des `woid` Attributs:
|
||||
- ✅ Neue Tickets bekommen automatisch eine 5-stellige WOID
|
||||
- ✅ WOID wird in der Ticket-Liste angezeigt
|
||||
- ✅ Suche nach WOID funktioniert
|
||||
- ✅ Keine Buchstaben mehr in der WOID
|
||||
|
||||
107
WORKORDER_ATTRIBUTES_FIX.md
Normal file
107
WORKORDER_ATTRIBUTES_FIX.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# Workorder Collection Attribute beheben
|
||||
|
||||
Der Fehler "Unknown attribute: 'status'" bedeutet, dass das Attribut `status` in deiner `workorders` Collection fehlt.
|
||||
|
||||
## Problem
|
||||
|
||||
Die Collection `workorders` (ID: `6943bf7d001901baa60c`) hat nicht alle benötigten Attribute.
|
||||
|
||||
## Lösung: Attribute hinzufügen
|
||||
|
||||
1. Gehe zu `https://appwrite.webklar.com`
|
||||
2. Öffne dein Projekt **woms**
|
||||
3. Gehe zu **Databases** → **woms-database** → **workorders**
|
||||
4. Klicke auf **Attributes** Tab
|
||||
5. Füge die fehlenden Attribute hinzu:
|
||||
|
||||
### Erforderliche Attribute (Required):
|
||||
|
||||
| Attribut Name | Typ | Größe | Required | Default |
|
||||
|--------------|-----|-------|----------|---------|
|
||||
| `topic` | string | 255 | ✓ | - |
|
||||
| `status` | string | 50 | ✓ | `Open` |
|
||||
| `priority` | integer | - | ✓ | `1` |
|
||||
|
||||
### Optionale Attribute:
|
||||
|
||||
| Attribut Name | Typ | Größe | Required |
|
||||
|--------------|-----|-------|----------|
|
||||
| `type` | string | 50 | - |
|
||||
| `systemType` | string | 50 | - |
|
||||
| `responseLevel` | string | 50 | - |
|
||||
| `serviceType` | string | 50 | - |
|
||||
| `customerId` | string | 50 | - |
|
||||
| `customerName` | string | 255 | - |
|
||||
| `customerLocation` | string | 255 | - |
|
||||
| `requestedBy` | string | 255 | - |
|
||||
| `requestedFor` | string | 255 | - |
|
||||
| `startDate` | string | 50 | - |
|
||||
| `startTime` | string | 10 | - |
|
||||
| `deadline` | string | 50 | - |
|
||||
| `endTime` | string | 10 | - |
|
||||
| `estimate` | string | 50 | - |
|
||||
| `mailCopyTo` | string | 255 | - |
|
||||
| `sendNotification` | boolean | - | - |
|
||||
| `details` | string | 10000 | - |
|
||||
| `assignedTo` | string | 50 | - |
|
||||
| `assignedName` | string | 255 | - |
|
||||
| `approvalStatus` | string | 50 | - |
|
||||
| `woid` | string | 50 | - |
|
||||
| `createdAt` | datetime | - | - |
|
||||
|
||||
## Schritt-für-Schritt
|
||||
|
||||
### 1. Status hinzufügen (wichtig!)
|
||||
|
||||
1. Klicke auf **Create Attribute**
|
||||
2. **Key**: `status`
|
||||
3. **Type**: `String`
|
||||
4. **Size**: `50`
|
||||
5. **Required**: ✓ (aktivieren)
|
||||
6. **Default**: `Open`
|
||||
7. **Array**: Nein
|
||||
8. Klicke auf **Create**
|
||||
|
||||
### 2. Priority hinzufügen (wichtig!)
|
||||
|
||||
1. Klicke auf **Create Attribute**
|
||||
2. **Key**: `priority`
|
||||
3. **Type**: `Integer`
|
||||
4. **Required**: ✓ (aktivieren)
|
||||
5. **Default**: `1`
|
||||
6. Klicke auf **Create**
|
||||
|
||||
### 3. Topic hinzufügen (wichtig!)
|
||||
|
||||
1. Klicke auf **Create Attribute**
|
||||
2. **Key**: `topic`
|
||||
3. **Type**: `String`
|
||||
4. **Size**: `255`
|
||||
5. **Required**: ✓ (aktivieren)
|
||||
6. Klicke auf **Create**
|
||||
|
||||
### 4. Weitere Attribute hinzufügen
|
||||
|
||||
Wiederhole den Prozess für alle anderen Attribute aus der Liste oben.
|
||||
|
||||
## Überprüfung
|
||||
|
||||
Nach dem Hinzufügen aller Attribute:
|
||||
|
||||
1. Stelle sicher, dass mindestens diese drei Attribute existieren:
|
||||
- ✓ `topic` (string, required)
|
||||
- ✓ `status` (string, required, default: "Open")
|
||||
- ✓ `priority` (integer, required, default: 1)
|
||||
|
||||
2. Versuche erneut, ein Ticket zu erstellen
|
||||
|
||||
## Wenn Attribute bereits existieren
|
||||
|
||||
Falls die Attribute bereits existieren, aber andere Namen haben:
|
||||
|
||||
1. Überprüfe die Attributnamen in Appwrite
|
||||
2. Teile mir die tatsächlichen Attributnamen mit
|
||||
3. Ich passe den Code entsprechend an
|
||||
|
||||
Viel Erfolg! 🚀
|
||||
|
||||
210
WORKSHEETS_COLLECTION_SETUP.md
Normal file
210
WORKSHEETS_COLLECTION_SETUP.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# Worksheets Collection Setup
|
||||
|
||||
## 📋 Was ist ein Worksheet (WSID)?
|
||||
|
||||
Ein **Worksheet** ist ein **Arbeitsschritt-Eintrag** für einen Work Order (Ticket). Jedes Mal, wenn ein Techniker an einem Ticket arbeitet, wird ein neues Worksheet erstellt.
|
||||
|
||||
### Unterschied: WOID vs WSID
|
||||
|
||||
| Konzept | Beschreibung | Beispiel |
|
||||
|---------|--------------|----------|
|
||||
| **WOID** (Work Order ID) | Das Haupt-Ticket | 59203 |
|
||||
| **WSID** (Work Sheet ID) | Einzelner Arbeitsschritt | 100001 |
|
||||
|
||||
**Beziehung:** Ein WOID kann **viele WSIDs** haben (1:n)
|
||||
|
||||
### Beispiel aus der Praxis:
|
||||
|
||||
```
|
||||
Ticket WOID 59203: "Router-Problem bei Kunde XYZ"
|
||||
|
||||
├── WSID 100001 (29.12.2025, 10:00-10:30, Max M.)
|
||||
│ └── "Fernanalyse durchgeführt, Router neu gestartet"
|
||||
│ Status: Open → Occupied
|
||||
│
|
||||
├── WSID 100002 (29.12.2025, 14:00-14:45, Max M.)
|
||||
│ └── "Firmware Update durchgeführt"
|
||||
│ Status: Occupied → Assigned
|
||||
│
|
||||
└── WSID 100003 (30.12.2025, 09:00-09:15, Lisa S.)
|
||||
└── "Vor-Ort-Check: Alles funktioniert"
|
||||
Status: Assigned → Closed
|
||||
```
|
||||
|
||||
**Gesamtarbeitszeit für WOID 59203:** 30 + 45 + 15 = **90 Minuten**
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Appwrite Collection einrichten
|
||||
|
||||
### Collection erstellen
|
||||
|
||||
1. Gehe zu **Appwrite Dashboard** → **Databases** → `woms-database`
|
||||
2. Klicke auf **Create Collection**
|
||||
3. **Collection ID:** `worksheets`
|
||||
4. **Name:** `Work Sheets`
|
||||
|
||||
### Attribute hinzufügen
|
||||
|
||||
| Attribut Name | Typ | Größe | Required | Array | Default | Beschreibung |
|
||||
|--------------|-----|-------|----------|-------|---------|--------------|
|
||||
| `wsid` | string | 10 | ✓ | - | - | Work Sheet ID (6-stellig, z.B. "100000") |
|
||||
| `woid` | string | 10 | ✓ | - | - | Verknüpfter Work Order (z.B. "59203") |
|
||||
| `workorderId` | string | 50 | ✓ | - | - | Appwrite Document ID des Work Orders |
|
||||
| `employeeId` | string | 50 | ✓ | - | - | ID des Mitarbeiters (aus Auth) |
|
||||
| `employeeName` | string | 255 | ✓ | - | - | Name des Mitarbeiters |
|
||||
| `employeeShort` | string | 10 | - | - | - | Kürzel (z.B. "KNSO") |
|
||||
| `serviceType` | string | 50 | ✓ | - | `Remote` | Remote/On Site/Off Site/COMMENT |
|
||||
| `oldStatus` | string | 50 | ✓ | - | - | Status vorher |
|
||||
| `newStatus` | string | 50 | ✓ | - | - | Status nachher |
|
||||
| `oldResponseLevel` | string | 50 | - | - | - | Response Level vorher |
|
||||
| `newResponseLevel` | string | 50 | - | - | - | Response Level nachher |
|
||||
| `totalTime` | integer | - | ✓ | - | `0` | Arbeitszeit in Minuten |
|
||||
| `startDate` | string | 50 | ✓ | - | - | Startdatum (dd.mm.yyyy) |
|
||||
| `startTime` | string | 10 | - | - | - | Startzeit (hhmm) |
|
||||
| `endDate` | string | 50 | ✓ | - | - | Enddatum (dd.mm.yyyy) |
|
||||
| `endTime` | string | 10 | - | - | - | Endzeit (hhmm) |
|
||||
| `details` | string | 10000 | ✓ | - | - | Beschreibung der Arbeit |
|
||||
| `isComment` | boolean | - | - | - | `false` | Nur Kommentar (keine Arbeitszeit) |
|
||||
| `createdAt` | datetime | - | - | - | - | Erstellungszeitpunkt |
|
||||
|
||||
### Permissions einrichten
|
||||
|
||||
1. Gehe zu **Settings** → **Permissions**
|
||||
2. Setze folgende Berechtigungen:
|
||||
|
||||
**Read Access:**
|
||||
- ✅ `Users` (Alle eingeloggten Benutzer können Worksheets lesen)
|
||||
|
||||
**Create Access:**
|
||||
- ✅ `Users` (Alle eingeloggten Benutzer können Worksheets erstellen)
|
||||
|
||||
**Update Access:**
|
||||
- ✅ `Users` (Eigene Worksheets können bearbeitet werden)
|
||||
|
||||
**Delete Access:**
|
||||
- ❌ Keine (Worksheets sollten nicht gelöscht werden - Audit Trail!)
|
||||
|
||||
### Indexes erstellen
|
||||
|
||||
Für bessere Performance:
|
||||
|
||||
1. **Index 1: woid_index**
|
||||
- Key: `woid`
|
||||
- Type: `key`
|
||||
- Order: `DESC`
|
||||
- → Schnelles Finden aller Worksheets für einen Work Order
|
||||
|
||||
2. **Index 2: employee_index**
|
||||
- Key: `employeeId`
|
||||
- Type: `key`
|
||||
- Order: `DESC`
|
||||
- → Schnelles Finden aller Worksheets eines Mitarbeiters
|
||||
|
||||
3. **Index 3: date_index**
|
||||
- Key: `startDate`
|
||||
- Type: `key`
|
||||
- Order: `DESC`
|
||||
- → Zeitbasierte Abfragen
|
||||
|
||||
---
|
||||
|
||||
## 🎯 WSID-Generierung
|
||||
|
||||
### Regeln:
|
||||
- **6-stellige Zahlen** (100000, 100001, 100002, ...)
|
||||
- **Global eindeutig** (über alle Work Orders hinweg)
|
||||
- **Sequentiell aufsteigend**
|
||||
- **Nur Zahlen**, keine Buchstaben
|
||||
|
||||
### Algorithmus:
|
||||
1. Finde höchste existierende WSID in der Datenbank
|
||||
2. Addiere +1
|
||||
3. Falls keine WSID existiert: Starte bei 100000
|
||||
|
||||
---
|
||||
|
||||
## 📊 Verbesserungen gegenüber dem Original
|
||||
|
||||
### 1. Automatische Zeitberechnung
|
||||
- System berechnet `totalTime` aus `startTime` und `endTime`
|
||||
- Manuelles Überschreiben möglich
|
||||
|
||||
### 2. Arbeitszeit-Aggregation
|
||||
- Gesamtarbeitszeit pro WOID wird automatisch berechnet
|
||||
- Anzeige: "Gesamt: 90 Minuten (3 Worksheets)"
|
||||
|
||||
### 3. Status-Historie
|
||||
- Komplette Historie: Wer hat wann welchen Status gesetzt?
|
||||
- Nachvollziehbarkeit
|
||||
|
||||
### 4. Kommentar-Funktion
|
||||
- Worksheets mit `isComment = true` zählen keine Arbeitszeit
|
||||
- Reine Notizen/Updates
|
||||
|
||||
### 5. Mitarbeiter-Zuordnung
|
||||
- Jedes Worksheet ist einem Mitarbeiter zugeordnet
|
||||
- Report: Wer hat wie viel Zeit investiert?
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Integration in Work Orders
|
||||
|
||||
### Beim Anzeigen eines Tickets:
|
||||
```javascript
|
||||
// Work Order laden
|
||||
const workorder = await getWorkorder(woid)
|
||||
|
||||
// Alle Worksheets zu diesem Work Order laden
|
||||
const worksheets = await getWorksheets(woid)
|
||||
|
||||
// Gesamtarbeitszeit berechnen
|
||||
const totalMinutes = worksheets.reduce((sum, ws) => sum + ws.totalTime, 0)
|
||||
```
|
||||
|
||||
### Beim Erstellen eines Worksheets:
|
||||
1. Aktuellen Work Order Status holen
|
||||
2. Worksheet-Formular anzeigen (mit aktuellem Status als "old status")
|
||||
3. Worksheet speichern
|
||||
4. Work Order Status aktualisieren (auf "new status")
|
||||
|
||||
---
|
||||
|
||||
## 📝 Workflow-Beispiel
|
||||
|
||||
**Techniker Max öffnet Ticket 59203:**
|
||||
|
||||
1. Klickt auf "Add Worksheet"
|
||||
2. System füllt aus:
|
||||
- WOID: 59203
|
||||
- Old Status: "Open"
|
||||
- Employee: "Max Müller" (aus Login)
|
||||
- Datum: heute
|
||||
3. Max trägt ein:
|
||||
- Service Type: "Remote"
|
||||
- New Status: "Occupied"
|
||||
- Start: 10:00, End: 10:30 (→ automatisch 30 min)
|
||||
- Details: "Router neu gestartet"
|
||||
4. System generiert WSID: 100001
|
||||
5. Work Order Status wird auf "Occupied" aktualisiert
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checkliste
|
||||
|
||||
- [ ] Collection `worksheets` in Appwrite erstellt
|
||||
- [ ] Alle Attribute hinzugefügt
|
||||
- [ ] Permissions konfiguriert
|
||||
- [ ] Indexes erstellt
|
||||
- [ ] Collection ID in `.env` / `appwrite.js` eingetragen
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Nächste Schritte
|
||||
|
||||
Nach dem Setup:
|
||||
1. `useWorksheets.js` Hook implementieren
|
||||
2. `CreateWorksheetModal.jsx` Komponente erstellen
|
||||
3. Worksheet-Liste in Ticket-Details einbauen
|
||||
4. Zeitaggregation implementieren
|
||||
|
||||
119
WSID_ATTRIBUTE_SETUP.md
Normal file
119
WSID_ATTRIBUTE_SETUP.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# WSID Attribut Hinzufügen
|
||||
|
||||
Diese Anleitung zeigt dir, wie du das **WSID (Work Sheet ID)** Attribut zur `workorders` Collection hinzufügst.
|
||||
|
||||
## Was ist WSID?
|
||||
|
||||
- **WSID** = Work Sheet ID
|
||||
- **Format**: 6-stellige Nummer (100000-999999)
|
||||
- **Funktion**: Sequentielle ID ähnlich wie WOID, aber mit 6 Stellen
|
||||
- **Beispiele**: 100000, 100001, 100002, etc.
|
||||
|
||||
## Schritt-für-Schritt Anleitung
|
||||
|
||||
### 1. Appwrite Dashboard öffnen
|
||||
|
||||
1. Gehe zu https://appwrite.webklar.com (oder deine Appwrite-Instanz)
|
||||
2. Logge dich ein
|
||||
3. Öffne dein Projekt **woms**
|
||||
|
||||
### 2. Zur workorders Collection navigieren
|
||||
|
||||
1. Klicke auf **Databases** im linken Menü
|
||||
2. Wähle die Datenbank **woms-database** (ID: `6943bf0e0003291f8c35`)
|
||||
3. Klicke auf die Collection **workorders** (ID: `6943bf7d001901baa60c`)
|
||||
|
||||
### 3. WSID Attribut erstellen
|
||||
|
||||
1. Klicke auf den Tab **Attributes**
|
||||
2. Klicke auf **Create Attribute**
|
||||
3. Wähle **String** als Typ
|
||||
4. Fülle die Felder wie folgt aus:
|
||||
|
||||
**Attribut-Konfiguration:**
|
||||
- **Attribute ID**: `wsid`
|
||||
- **Attribute Name**: `wsid`
|
||||
- **Size**: `10` (für 6-stellige Zahlen + Reserve)
|
||||
- **Required**: ❌ Nein (nicht required, damit alte Tickets ohne WSID weiter funktionieren)
|
||||
- **Array**: ❌ Nein
|
||||
- **Default Value**: Leer lassen
|
||||
|
||||
5. Klicke auf **Create**
|
||||
|
||||
### 4. Index für WSID erstellen (Optional, aber empfohlen)
|
||||
|
||||
Ein Index verbessert die Performance beim Suchen und Filtern:
|
||||
|
||||
1. Klicke auf den Tab **Indexes**
|
||||
2. Klicke auf **Create Index**
|
||||
3. Fülle die Felder wie folgt aus:
|
||||
|
||||
**Index-Konfiguration:**
|
||||
- **Key**: `wsid`
|
||||
- **Type**: `key`
|
||||
- **Attributes**: Wähle `wsid`
|
||||
- **Order**: `ASC` (aufsteigend)
|
||||
|
||||
4. Klicke auf **Create**
|
||||
|
||||
### 5. Fertig! ✅
|
||||
|
||||
Das WSID-Attribut ist jetzt in deiner Collection verfügbar. Die Anwendung wird automatisch:
|
||||
|
||||
- Für neue Tickets eine sequentielle WSID generieren (startend bei 100000)
|
||||
- Die WSID in der Ticket-Liste anzeigen
|
||||
- Die WSID im Detail-View anzeigen
|
||||
|
||||
## Hinweise
|
||||
|
||||
### Sequentielle Generierung
|
||||
|
||||
Die WSID wird ähnlich wie WOID automatisch generiert:
|
||||
|
||||
```javascript
|
||||
// Beispiel:
|
||||
Erstes Ticket: WSID = 100000
|
||||
Zweites Ticket: WSID = 100001
|
||||
Drittes Ticket: WSID = 100002
|
||||
// etc.
|
||||
```
|
||||
|
||||
### Unterschied WOID vs WSID
|
||||
|
||||
| Feld | Stellen | Start | Format | Beispiel |
|
||||
|------|---------|-------|--------|----------|
|
||||
| WOID | 5 | 10000 | String | "10000" |
|
||||
| WSID | 6 | 100000 | String | "100000" |
|
||||
|
||||
### Alte Tickets
|
||||
|
||||
Tickets, die vor dem Hinzufügen des WSID-Attributs erstellt wurden, haben keine WSID. In der UI wird dann "-" angezeigt.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Problem: Attribut kann nicht erstellt werden
|
||||
|
||||
**Lösung**:
|
||||
- Überprüfe, ob das Attribut `wsid` nicht bereits existiert
|
||||
- Stelle sicher, dass du die richtigen Berechtigungen hast (Admin)
|
||||
|
||||
### Problem: WSID wird nicht angezeigt
|
||||
|
||||
**Lösung**:
|
||||
1. Überprüfe, ob das Attribut erfolgreich erstellt wurde
|
||||
2. Lade die Seite neu (Strg+F5 / Cmd+Shift+R)
|
||||
3. Erstelle ein neues Ticket zum Testen
|
||||
|
||||
### Problem: WSID startet nicht bei 100000
|
||||
|
||||
**Lösung**:
|
||||
- Das ist normal, wenn bereits Tickets mit WSID existieren
|
||||
- Die WSID wird immer sequentiell von der höchsten bestehenden WSID weitergezählt
|
||||
|
||||
## Support
|
||||
|
||||
Bei Fragen oder Problemen:
|
||||
1. Überprüfe die Appwrite Logs
|
||||
2. Schaue in die Browser Console (F12)
|
||||
3. Überprüfe, ob alle Berechtigungen korrekt gesetzt sind
|
||||
|
||||
325
WSID_IMPLEMENTATION_SUMMARY.md
Normal file
325
WSID_IMPLEMENTATION_SUMMARY.md
Normal file
@@ -0,0 +1,325 @@
|
||||
# WSID Implementation - Zusammenfassung
|
||||
|
||||
## ✅ Was wurde implementiert?
|
||||
|
||||
Das **WSID (Work Sheet ID) System** wurde vollständig implementiert, basierend auf dem Original NetWEB Systems WOMS 2.0.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Implementierte Features
|
||||
|
||||
### 1. **Worksheets Collection** ✅
|
||||
- Separate Collection für Arbeitsschritte
|
||||
- 6-stellige WSID (100000, 100001, ...)
|
||||
- Sequentielle, globale Nummerierung
|
||||
- Beziehung zu Work Orders via `woid` und `workorderId`
|
||||
|
||||
### 2. **WSID-Generierung** ✅
|
||||
- Automatische Generierung beim Erstellen eines Worksheets
|
||||
- Sucht höchste existierende WSID und zählt +1
|
||||
- Startet bei 100000 wenn keine Worksheets existieren
|
||||
- Nur Zahlen, keine Buchstaben
|
||||
|
||||
### 3. **Worksheet-Erstellung** ✅
|
||||
- Modal-Dialog zum Erstellen neuer Worksheets
|
||||
- Felder:
|
||||
- Service Type (Remote/On Site/Off Site/COMMENT)
|
||||
- Status-Änderung (Old Status → New Status)
|
||||
- Response Level-Änderung
|
||||
- Arbeitszeit (manuell oder automatisch berechnet)
|
||||
- Start/End Datum & Zeit
|
||||
- Details (Arbeitsbeschreibung)
|
||||
- Kommentar-Flag (ohne Zeiterfassung)
|
||||
- Verknüpfung mit aktuellem Benutzer
|
||||
|
||||
### 4. **Worksheet-Anzeige** ✅
|
||||
- Liste aller Worksheets für ein Ticket
|
||||
- Zeitstrahl-Darstellung (Timeline)
|
||||
- Anzeige von:
|
||||
- WSID
|
||||
- Mitarbeiter (Name + Kürzel)
|
||||
- Arbeitszeit
|
||||
- Service Type
|
||||
- Status-Änderungen
|
||||
- Details
|
||||
- Unterscheidung zwischen Worksheets und Kommentaren
|
||||
|
||||
### 5. **Zeitaggregation** ✅
|
||||
- Gesamtarbeitszeit pro Ticket
|
||||
- Durchschnittszeit pro Worksheet
|
||||
- Arbeitszeit pro Mitarbeiter
|
||||
- Anzahl Worksheets
|
||||
|
||||
### 6. **Status-Historie** ✅
|
||||
- Chronologische Auflistung aller Status-Änderungen
|
||||
- Wer hat wann welchen Status gesetzt
|
||||
- Nachvollziehbarkeit aller Änderungen
|
||||
|
||||
### 7. **Statistiken & Reports** ✅
|
||||
- **Gesamtübersicht:**
|
||||
- Anzahl Worksheets
|
||||
- Gesamtarbeitszeit
|
||||
- Anzahl Kommentare
|
||||
- Durchschnitt pro Worksheet
|
||||
|
||||
- **Nach Mitarbeiter:**
|
||||
- Arbeitszeit pro Person
|
||||
- Anzahl Worksheets pro Person
|
||||
- Mitarbeiter-Kürzel
|
||||
|
||||
- **Service Type Verteilung:**
|
||||
- Wie viele Remote/On Site/etc.
|
||||
|
||||
- **Status-Historie Tabelle:**
|
||||
- Alle Status-Änderungen chronologisch
|
||||
- Mit Datum, Zeit, Mitarbeiter
|
||||
|
||||
### 8. **Automatische Zeitberechnung** ✅
|
||||
- System berechnet automatisch Arbeitszeit aus Start- und Endzeit
|
||||
- Manuelles Überschreiben möglich
|
||||
- Unterstützt Overnight-Zeiten (z.B. 23:00 - 01:00)
|
||||
|
||||
### 9. **Kommentar-Funktion** ✅
|
||||
- Worksheets können als "Nur Kommentar" markiert werden
|
||||
- Zählen nicht zur Arbeitszeit
|
||||
- Für Notizen, Updates, Kommunikation
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Technische Details
|
||||
|
||||
### Architektur
|
||||
|
||||
```
|
||||
Work Order (WOID) 1:n Worksheets (WSID)
|
||||
↓ ↓
|
||||
workorders worksheets
|
||||
Collection Collection
|
||||
```
|
||||
|
||||
### Collections
|
||||
|
||||
**workorders:**
|
||||
- Enthält WOID (5-stellig)
|
||||
- Enthält KEINE WSID mehr ✅
|
||||
- Referenz: `ticket.woid`
|
||||
|
||||
**worksheets:**
|
||||
- Enthält WSID (6-stellig)
|
||||
- Referenziert WOID
|
||||
- Referenziert workorderId (Appwrite Document ID)
|
||||
- Enthält Mitarbeiter-Info
|
||||
- Enthält Zeiterfassung
|
||||
- Enthält Status-Änderungen
|
||||
|
||||
### Datenfluss
|
||||
|
||||
1. **Ticket öffnen:**
|
||||
- System lädt Work Order
|
||||
- System lädt alle Worksheets mit `woid = ticket.woid`
|
||||
|
||||
2. **Worksheet erstellen:**
|
||||
- Benutzer klickt "Add Worksheet"
|
||||
- System generiert neue WSID
|
||||
- System speichert Worksheet
|
||||
- Wenn Status geändert → Work Order wird aktualisiert
|
||||
|
||||
3. **Statistiken:**
|
||||
- Werden in Echtzeit aus Worksheets berechnet
|
||||
- Keine Duplikate, keine Caching-Probleme
|
||||
|
||||
---
|
||||
|
||||
## 📂 Neue Dateien
|
||||
|
||||
| Datei | Beschreibung |
|
||||
|-------|--------------|
|
||||
| `src/hooks/useWorksheets.js` | Hook für Worksheet-Operationen |
|
||||
| `src/components/CreateWorksheetModal.jsx` | Modal zum Erstellen |
|
||||
| `src/components/WorksheetList.jsx` | Liste aller Worksheets |
|
||||
| `src/components/WorksheetStats.jsx` | Statistiken & Reports |
|
||||
| `WORKSHEETS_COLLECTION_SETUP.md` | Setup-Anleitung |
|
||||
| `WSID_IMPLEMENTATION_SUMMARY.md` | Diese Datei |
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Geänderte Dateien
|
||||
|
||||
| Datei | Änderung |
|
||||
|-------|----------|
|
||||
| `src/hooks/useWorkorders.js` | WSID-Generierung **entfernt** ✅ |
|
||||
| `src/components/TicketRow.jsx` | Worksheet-Integration hinzugefügt |
|
||||
| `src/lib/appwrite.js` | WORKSHEETS Collection ID hinzugefügt |
|
||||
| `APPWRITE_SETUP.md` | WSID aus workorders entfernt |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Setup-Schritte
|
||||
|
||||
### 1. Collection in Appwrite erstellen
|
||||
|
||||
```bash
|
||||
1. Gehe zu Appwrite Dashboard → Databases → woms-database
|
||||
2. Erstelle Collection "worksheets"
|
||||
3. Füge alle Attribute hinzu (siehe WORKSHEETS_COLLECTION_SETUP.md)
|
||||
4. Setze Permissions (Users: Read, Create, Update)
|
||||
5. Erstelle Indexes (woid, employeeId, startDate)
|
||||
6. Kopiere Collection ID
|
||||
```
|
||||
|
||||
### 2. Collection ID in Code eintragen
|
||||
|
||||
```javascript
|
||||
// src/lib/appwrite.js
|
||||
export const COLLECTIONS = {
|
||||
WORKORDERS: '6943bf7d001901baa60c',
|
||||
WORKSHEETS: 'DEINE_COLLECTION_ID_HIER', // ← Hier eintragen!
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 3. WSID aus workorders Collection entfernen (Optional)
|
||||
|
||||
Falls du bereits `wsid` als Attribut in der `workorders` Collection hast:
|
||||
|
||||
```bash
|
||||
1. Gehe zu Appwrite Dashboard → workorders Collection
|
||||
2. Gehe zu Attributes
|
||||
3. Lösche Attribut "wsid" (falls vorhanden)
|
||||
```
|
||||
|
||||
⚠️ **Achtung:** Stelle sicher, dass keine wichtigen Daten verloren gehen!
|
||||
|
||||
---
|
||||
|
||||
## 📊 Verbesserungen gegenüber Original
|
||||
|
||||
| Feature | Original | Neu |
|
||||
|---------|----------|-----|
|
||||
| **WSID-Generierung** | Manuell/unklar | Automatisch, sequentiell |
|
||||
| **Zeitberechnung** | Manuell | Automatisch + manuell |
|
||||
| **Statistiken** | Keine/begrenzt | Umfassend (Mitarbeiter, Service Type, Historie) |
|
||||
| **Kommentare** | Unklar | Eigener Typ (ohne Zeit) |
|
||||
| **Status-Historie** | Nicht dokumentiert | Vollständig nachvollziehbar |
|
||||
| **Zeitaggregation** | Manuell | Automatisch |
|
||||
| **Multi-Mitarbeiter** | Schwierig | Einfach trackbar |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Verwendung
|
||||
|
||||
### Worksheet erstellen
|
||||
|
||||
1. Öffne ein Ticket (Lock-Icon klicken)
|
||||
2. Klicke "Add Worksheet"
|
||||
3. Fülle Formular aus:
|
||||
- Service Type wählen
|
||||
- Neuen Status wählen
|
||||
- Start/End Zeit eingeben (automatische Berechnung)
|
||||
- Details schreiben
|
||||
4. Klicke "CREATE NOW"
|
||||
|
||||
### Statistiken ansehen
|
||||
|
||||
Wenn Worksheets vorhanden sind, werden automatisch angezeigt:
|
||||
- Gesamtübersicht (oben)
|
||||
- Mitarbeiter-Statistiken
|
||||
- Service Type Verteilung
|
||||
- Status-Historie (falls Änderungen)
|
||||
|
||||
### Arbeitszeit tracken
|
||||
|
||||
Jedes Worksheet erfasst:
|
||||
- Wer hat gearbeitet
|
||||
- Wann (Datum + Zeit)
|
||||
- Wie lange
|
||||
- Was wurde gemacht
|
||||
- Welcher Status wurde gesetzt
|
||||
|
||||
**Beispiel:**
|
||||
|
||||
```
|
||||
WOID 59203: "Router-Problem"
|
||||
├─ WSID 100001: Max, 30min, Open → Occupied, "Fernanalyse"
|
||||
├─ WSID 100002: Max, 45min, Occupied → Assigned, "Firmware Update"
|
||||
└─ WSID 100003: Lisa, 15min, Assigned → Closed, "Vor-Ort-Check OK"
|
||||
|
||||
Gesamtzeit: 90 Minuten
|
||||
Mitarbeiter: Max (75min), Lisa (15min)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checkliste
|
||||
|
||||
- [x] Worksheets Collection Dokumentation erstellt
|
||||
- [x] useWorksheets Hook mit WSID-Generierung
|
||||
- [x] CreateWorksheetModal UI-Komponente
|
||||
- [x] WorksheetList Komponente
|
||||
- [x] WorksheetStats Komponente (Statistiken)
|
||||
- [x] Integration in TicketRow
|
||||
- [x] WSID aus workorders entfernt
|
||||
- [x] Automatische Zeitberechnung
|
||||
- [x] Status-Historie
|
||||
- [x] Mitarbeiter-Tracking
|
||||
- [x] Kommentar-Funktion
|
||||
- [x] Service Type Tracking
|
||||
- [ ] **Collection in Appwrite erstellen** ← Das musst DU machen!
|
||||
- [ ] **Collection ID eintragen** ← Das musst DU machen!
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Testing
|
||||
|
||||
Nach dem Setup:
|
||||
|
||||
1. **Worksheet erstellen:**
|
||||
```
|
||||
- Öffne ein Ticket
|
||||
- Klicke "Add Worksheet"
|
||||
- Fülle Formular aus
|
||||
- Speichern
|
||||
- → WSID sollte 100000 sein (erste)
|
||||
```
|
||||
|
||||
2. **Zweites Worksheet:**
|
||||
```
|
||||
- Erstelle weiteres Worksheet
|
||||
- → WSID sollte 100001 sein
|
||||
```
|
||||
|
||||
3. **Statistiken prüfen:**
|
||||
```
|
||||
- Öffne Ticket mit mehreren Worksheets
|
||||
- Statistiken sollten angezeigt werden
|
||||
- Gesamtzeit sollte korrekt sein
|
||||
```
|
||||
|
||||
4. **Status-Änderung:**
|
||||
```
|
||||
- Erstelle Worksheet mit Status-Änderung
|
||||
- Work Order Status sollte aktualisiert werden
|
||||
- Historie sollte angezeigt werden
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 Weiterführende Dokumentation
|
||||
|
||||
- **Setup:** `WORKSHEETS_COLLECTION_SETUP.md`
|
||||
- **Appwrite:** `APPWRITE_SETUP.md`
|
||||
- **Code:** Siehe Inline-Kommentare in den Komponenten
|
||||
|
||||
---
|
||||
|
||||
## 🎉 Fertig!
|
||||
|
||||
Das WSID-System ist vollständig implementiert und einsatzbereit!
|
||||
|
||||
**Nächste Schritte:**
|
||||
1. Collection in Appwrite erstellen
|
||||
2. Collection ID eintragen
|
||||
3. Testen!
|
||||
|
||||
Bei Fragen: Siehe Dokumentation oder Code-Kommentare.
|
||||
|
||||
195
WSID_QUICKSTART.md
Normal file
195
WSID_QUICKSTART.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# WSID System - Quick Start Guide
|
||||
|
||||
## 🚀 Schnellstart in 5 Minuten
|
||||
|
||||
### Schritt 1: Collection in Appwrite erstellen
|
||||
|
||||
1. Gehe zu **https://appwrite.webklar.com**
|
||||
2. Login mit deinen Credentials
|
||||
3. Öffne Projekt **woms**
|
||||
4. Gehe zu **Databases** → **woms-database**
|
||||
5. Klicke **Create Collection**
|
||||
- Collection ID: `worksheets`
|
||||
- Name: `Work Sheets`
|
||||
6. Klicke **Create**
|
||||
|
||||
### Schritt 2: Attribute hinzufügen
|
||||
|
||||
Klicke auf **Attributes** und füge folgende Attribute hinzu:
|
||||
|
||||
#### Required Attributes (Pflicht):
|
||||
|
||||
```
|
||||
1. wsid | String | Size: 10 | Required: ✓ | Default: -
|
||||
2. woid | String | Size: 10 | Required: ✓ | Default: -
|
||||
3. workorderId | String | Size: 50 | Required: ✓ | Default: -
|
||||
4. employeeId | String | Size: 50 | Required: ✓ | Default: -
|
||||
5. employeeName | String | Size: 255 | Required: ✓ | Default: -
|
||||
6. serviceType | String | Size: 50 | Required: ✓ | Default: Remote
|
||||
7. oldStatus | String | Size: 50 | Required: ✓ | Default: -
|
||||
8. newStatus | String | Size: 50 | Required: ✓ | Default: -
|
||||
9. totalTime | Integer | - | Required: ✓ | Default: 0
|
||||
10. startDate | String | Size: 50 | Required: ✓ | Default: -
|
||||
11. endDate | String | Size: 50 | Required: ✓ | Default: -
|
||||
12. details | String | Size: 10000 | Required: ✓ | Default: -
|
||||
13. createdAt | DateTime| - | Required: - | Default: -
|
||||
```
|
||||
|
||||
#### Optional Attributes (Optional):
|
||||
|
||||
```
|
||||
14. employeeShort | String | Size: 10 | Required: - | Default: -
|
||||
15. oldResponseLevel | String | Size: 50 | Required: - | Default: -
|
||||
16. newResponseLevel | String | Size: 50 | Required: - | Default: -
|
||||
17. startTime | String | Size: 10 | Required: - | Default: -
|
||||
18. endTime | String | Size: 10 | Required: - | Default: -
|
||||
19. isComment | Boolean | - | Required: - | Default: false
|
||||
```
|
||||
|
||||
### Schritt 3: Permissions setzen
|
||||
|
||||
1. Gehe zu **Settings** → **Permissions**
|
||||
2. Setze folgende Berechtigungen:
|
||||
|
||||
**Read Access:**
|
||||
- ✅ `Users` (All users)
|
||||
|
||||
**Create Access:**
|
||||
- ✅ `Users` (All users)
|
||||
|
||||
**Update Access:**
|
||||
- ✅ `Users` (All users)
|
||||
|
||||
**Delete Access:**
|
||||
- ❌ Keine (für Audit Trail)
|
||||
|
||||
### Schritt 4: Indexes erstellen
|
||||
|
||||
Gehe zu **Indexes** und erstelle:
|
||||
|
||||
1. **woid_index**
|
||||
- Key: `woid`
|
||||
- Type: `key`
|
||||
- Order: `DESC`
|
||||
|
||||
2. **employee_index**
|
||||
- Key: `employeeId`
|
||||
- Type: `key`
|
||||
- Order: `DESC`
|
||||
|
||||
3. **date_index**
|
||||
- Key: `startDate`
|
||||
- Type: `key`
|
||||
- Order: `DESC`
|
||||
|
||||
### Schritt 5: Collection ID kopieren
|
||||
|
||||
1. Gehe zurück zur Collection-Übersicht
|
||||
2. Kopiere die **Collection ID** (z.B. `694xyz123abc456def789`)
|
||||
|
||||
### Schritt 6: Collection ID im Code eintragen
|
||||
|
||||
Öffne `src/lib/appwrite.js` und trage die Collection ID ein:
|
||||
|
||||
```javascript
|
||||
export const COLLECTIONS = {
|
||||
WORKORDERS: '6943bf7d001901baa60c',
|
||||
CUSTOMERS: '694bd1fb002b2e583d13',
|
||||
EMPLOYEES: '695280510031c6c6153b',
|
||||
WORKSHEETS: '694xyz123abc456def789', // ← Hier deine Collection ID eintragen!
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Schritt 7: Optional - WSID aus workorders entfernen
|
||||
|
||||
Falls du bereits ein `wsid`-Attribut in der `workorders` Collection hast:
|
||||
|
||||
1. Gehe zu **workorders** Collection
|
||||
2. Gehe zu **Attributes**
|
||||
3. Lösche Attribut `wsid` (falls vorhanden)
|
||||
|
||||
⚠️ **Hinweis:** Stelle sicher, dass du ein Backup hast!
|
||||
|
||||
### Schritt 8: Anwendung neu starten
|
||||
|
||||
```bash
|
||||
# Terminal öffnen
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Schritt 9: Testen!
|
||||
|
||||
1. Öffne die Anwendung im Browser
|
||||
2. Gehe zu **Tickets**
|
||||
3. Öffne ein Ticket (Lock-Icon klicken)
|
||||
4. Klicke **"Add Worksheet"**
|
||||
5. Fülle das Formular aus:
|
||||
- Service Type: `Remote`
|
||||
- New Status: `Occupied`
|
||||
- Start Time: `1000`
|
||||
- End Time: `1030`
|
||||
- Details: `Test-Worksheet`
|
||||
6. Klicke **"CREATE NOW"**
|
||||
|
||||
**Erwartetes Ergebnis:**
|
||||
- WSID sollte `100000` sein
|
||||
- Worksheet sollte in der Liste erscheinen
|
||||
- Statistiken sollten angezeigt werden
|
||||
|
||||
---
|
||||
|
||||
## ✅ Fertig!
|
||||
|
||||
Das WSID-System ist jetzt einsatzbereit!
|
||||
|
||||
### Nächste Schritte:
|
||||
|
||||
- Erstelle weitere Worksheets
|
||||
- Prüfe Statistiken
|
||||
- Teste Status-Änderungen
|
||||
- Prüfe Zeitaggregation
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Problem: "Collection nicht gefunden"
|
||||
|
||||
**Lösung:**
|
||||
- Überprüfe Collection ID in `src/lib/appwrite.js`
|
||||
- Stelle sicher, dass Collection wirklich existiert
|
||||
|
||||
### Problem: "Unauthorized"
|
||||
|
||||
**Lösung:**
|
||||
- Überprüfe Permissions in Appwrite
|
||||
- `Users` muss Read/Create Zugriff haben
|
||||
|
||||
### Problem: "Attribute nicht gefunden"
|
||||
|
||||
**Lösung:**
|
||||
- Überprüfe, ob alle Required Attributes erstellt wurden
|
||||
- Attribute-Namen müssen exakt übereinstimmen (case-sensitive!)
|
||||
|
||||
### Problem: "WSID wird nicht generiert"
|
||||
|
||||
**Lösung:**
|
||||
- Überprüfe Browser-Konsole auf Fehler
|
||||
- Stelle sicher, dass `wsid` Attribut existiert
|
||||
- Überprüfe, ob Collection ID korrekt ist
|
||||
|
||||
---
|
||||
|
||||
## 📚 Weitere Dokumentation
|
||||
|
||||
- **Detaillierte Setup-Anleitung:** `WORKSHEETS_COLLECTION_SETUP.md`
|
||||
- **Implementierungs-Details:** `WSID_IMPLEMENTATION_SUMMARY.md`
|
||||
- **Appwrite Setup:** `APPWRITE_SETUP.md`
|
||||
|
||||
---
|
||||
|
||||
## 🎉 Viel Erfolg!
|
||||
|
||||
Bei Fragen: Siehe ausführliche Dokumentation oder Code-Kommentare.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NetWEB Systems WOMS 2.0</title>
|
||||
<title>Webklar WOMS 2.0</title>
|
||||
<link href="https://fonts.googleapis.com/css?family=Lato|Open+Sans" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
134
node_modules/.package-lock.json
generated
vendored
134
node_modules/.package-lock.json
generated
vendored
@@ -363,6 +363,32 @@
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@tabler/icons": {
|
||||
"version": "3.36.0",
|
||||
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.36.0.tgz",
|
||||
"integrity": "sha512-z9OfTEG6QbaQWM9KBOxxUdpgvMUn0atageXyiaSc2gmYm51ORO8Ua7eUcjlks+Dc0YMK4rrodAFdK9SfjJ4ZcA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/codecalm"
|
||||
}
|
||||
},
|
||||
"node_modules/@tabler/icons-react": {
|
||||
"version": "3.36.0",
|
||||
"resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.36.0.tgz",
|
||||
"integrity": "sha512-sSZ00bEjTdTTskVFykq294RJq+9cFatwy4uYa78HcYBCXU1kSD1DIp5yoFsQXmybkIOKCjp18OnhAYk553UIfQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tabler/icons": "3.36.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/codecalm"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -543,6 +569,15 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@@ -737,6 +772,33 @@
|
||||
"node": ">= 0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.23.26",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz",
|
||||
"integrity": "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.23.23",
|
||||
"motion-utils": "^12.23.6",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -934,6 +996,47 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/motion": {
|
||||
"version": "12.23.26",
|
||||
"resolved": "https://registry.npmjs.org/motion/-/motion-12.23.26.tgz",
|
||||
"integrity": "sha512-Ll8XhVxY8LXMVYTCfme27WH2GjBrCIzY4+ndr5QKxsK+YwCtOi2B/oBi5jcIbik5doXuWT/4KKDOVAZJkeY5VQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"framer-motion": "^12.23.26",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.23.23",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
|
||||
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "12.23.6",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
|
||||
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -1017,6 +1120,15 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postprocessing": {
|
||||
"version": "6.38.2",
|
||||
"resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.38.2.tgz",
|
||||
"integrity": "sha512-7DwuT7Tkst41ZjSj287g7C9c5/D3Xx5rMgBosg0dadbUPoZD2HNzkadKPol1d2PJAoI9f+Jeh1/v9YfLzpFGVw==",
|
||||
"license": "Zlib",
|
||||
"peerDependencies": {
|
||||
"three": ">= 0.157.0 < 0.183.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
@@ -1173,11 +1285,33 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwind-merge": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
|
||||
"integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/dcastil"
|
||||
}
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.182.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz",
|
||||
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
|
||||
92
node_modules/.vite/deps/_metadata.json
generated
vendored
92
node_modules/.vite/deps/_metadata.json
generated
vendored
@@ -1,91 +1,133 @@
|
||||
{
|
||||
"hash": "8745a243",
|
||||
"configHash": "d74aae15",
|
||||
"lockfileHash": "8786fedf",
|
||||
"browserHash": "6bc53e11",
|
||||
"hash": "d78f2739",
|
||||
"configHash": "b1cb6c91",
|
||||
"lockfileHash": "4b2b8d7d",
|
||||
"browserHash": "9a736d5a",
|
||||
"optimized": {
|
||||
"react": {
|
||||
"src": "../../react/index.js",
|
||||
"file": "react.js",
|
||||
"fileHash": "0ccae341",
|
||||
"fileHash": "2dd90c89",
|
||||
"needsInterop": true
|
||||
},
|
||||
"react-dom": {
|
||||
"src": "../../react-dom/index.js",
|
||||
"file": "react-dom.js",
|
||||
"fileHash": "b3aa3ef2",
|
||||
"fileHash": "8229fa46",
|
||||
"needsInterop": true
|
||||
},
|
||||
"react/jsx-dev-runtime": {
|
||||
"src": "../../react/jsx-dev-runtime.js",
|
||||
"file": "react_jsx-dev-runtime.js",
|
||||
"fileHash": "898bc344",
|
||||
"fileHash": "6db368d6",
|
||||
"needsInterop": true
|
||||
},
|
||||
"react/jsx-runtime": {
|
||||
"src": "../../react/jsx-runtime.js",
|
||||
"file": "react_jsx-runtime.js",
|
||||
"fileHash": "49a33e04",
|
||||
"fileHash": "a8fbd3f7",
|
||||
"needsInterop": true
|
||||
},
|
||||
"appwrite": {
|
||||
"src": "../../appwrite/dist/esm/sdk.js",
|
||||
"file": "appwrite.js",
|
||||
"fileHash": "1f73c770",
|
||||
"fileHash": "a2fa5c99",
|
||||
"needsInterop": false
|
||||
},
|
||||
"date-fns": {
|
||||
"src": "../../date-fns/esm/index.js",
|
||||
"file": "date-fns.js",
|
||||
"fileHash": "9fa70dfb",
|
||||
"fileHash": "157aa30f",
|
||||
"needsInterop": false
|
||||
},
|
||||
"date-fns/locale": {
|
||||
"src": "../../date-fns/esm/locale/index.js",
|
||||
"file": "date-fns_locale.js",
|
||||
"fileHash": "af0a0b06",
|
||||
"fileHash": "cd94d345",
|
||||
"needsInterop": false
|
||||
},
|
||||
"react-dom/client": {
|
||||
"src": "../../react-dom/client.js",
|
||||
"file": "react-dom_client.js",
|
||||
"fileHash": "f6b57c12",
|
||||
"fileHash": "8948be38",
|
||||
"needsInterop": true
|
||||
},
|
||||
"react-icons/fa": {
|
||||
"src": "../../react-icons/fa/index.esm.js",
|
||||
"file": "react-icons_fa.js",
|
||||
"fileHash": "750bb1d4",
|
||||
"fileHash": "c064e92c",
|
||||
"needsInterop": false
|
||||
},
|
||||
"react-icons/fa6": {
|
||||
"src": "../../react-icons/fa6/index.esm.js",
|
||||
"file": "react-icons_fa6.js",
|
||||
"fileHash": "e0a50b57",
|
||||
"fileHash": "0e64eca2",
|
||||
"needsInterop": false
|
||||
},
|
||||
"react-router-dom": {
|
||||
"src": "../../react-router-dom/dist/index.js",
|
||||
"file": "react-router-dom.js",
|
||||
"fileHash": "061d1fe8",
|
||||
"fileHash": "0004d718",
|
||||
"needsInterop": false
|
||||
},
|
||||
"motion/react": {
|
||||
"src": "../../motion/dist/es/react.mjs",
|
||||
"file": "motion_react.js",
|
||||
"fileHash": "42596b33",
|
||||
"needsInterop": false
|
||||
},
|
||||
"@tabler/icons-react": {
|
||||
"src": "../../@tabler/icons-react/dist/esm/tabler-icons-react.mjs",
|
||||
"file": "@tabler_icons-react.js",
|
||||
"fileHash": "14b4db2f",
|
||||
"needsInterop": false
|
||||
},
|
||||
"clsx": {
|
||||
"src": "../../clsx/dist/clsx.mjs",
|
||||
"file": "clsx.js",
|
||||
"fileHash": "87b6d9e4",
|
||||
"needsInterop": false
|
||||
},
|
||||
"tailwind-merge": {
|
||||
"src": "../../tailwind-merge/dist/bundle-mjs.mjs",
|
||||
"file": "tailwind-merge.js",
|
||||
"fileHash": "adee159c",
|
||||
"needsInterop": false
|
||||
},
|
||||
"three": {
|
||||
"src": "../../three/build/three.module.js",
|
||||
"file": "three.js",
|
||||
"fileHash": "f10da6a8",
|
||||
"needsInterop": false
|
||||
},
|
||||
"postprocessing": {
|
||||
"src": "../../postprocessing/build/index.js",
|
||||
"file": "postprocessing.js",
|
||||
"fileHash": "205bd067",
|
||||
"needsInterop": false
|
||||
}
|
||||
},
|
||||
"chunks": {
|
||||
"chunk-EHLE63A5": {
|
||||
"file": "chunk-EHLE63A5.js"
|
||||
"chunk-DQJKJRV5": {
|
||||
"file": "chunk-DQJKJRV5.js"
|
||||
},
|
||||
"chunk-TOOCKHFL": {
|
||||
"file": "chunk-TOOCKHFL.js"
|
||||
"chunk-IFCYBMKG": {
|
||||
"file": "chunk-IFCYBMKG.js"
|
||||
},
|
||||
"chunk-TYILIMWK": {
|
||||
"file": "chunk-TYILIMWK.js"
|
||||
"chunk-6PXSGDAH": {
|
||||
"file": "chunk-6PXSGDAH.js"
|
||||
},
|
||||
"chunk-CANBAPAS": {
|
||||
"file": "chunk-CANBAPAS.js"
|
||||
"chunk-PJEEZAML": {
|
||||
"file": "chunk-PJEEZAML.js"
|
||||
},
|
||||
"chunk-5WRI5ZAA": {
|
||||
"file": "chunk-5WRI5ZAA.js"
|
||||
"chunk-DRWLMN53": {
|
||||
"file": "chunk-DRWLMN53.js"
|
||||
},
|
||||
"chunk-SJKHQ62W": {
|
||||
"file": "chunk-SJKHQ62W.js"
|
||||
},
|
||||
"chunk-G3PMV62Z": {
|
||||
"file": "chunk-G3PMV62Z.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
2
node_modules/.vite/deps/appwrite.js
generated
vendored
2
node_modules/.vite/deps/appwrite.js
generated
vendored
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
__commonJS,
|
||||
__toESM
|
||||
} from "./chunk-5WRI5ZAA.js";
|
||||
} from "./chunk-G3PMV62Z.js";
|
||||
|
||||
// node_modules/isomorphic-form-data/lib/browser.js
|
||||
var require_browser = __commonJS({
|
||||
|
||||
31
node_modules/.vite/deps/chunk-5WRI5ZAA.js
generated
vendored
31
node_modules/.vite/deps/chunk-5WRI5ZAA.js
generated
vendored
@@ -1,31 +0,0 @@
|
||||
var __create = Object.create;
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __getProtoOf = Object.getPrototypeOf;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __commonJS = (cb, mod) => function __require() {
|
||||
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
||||
// If the importer is in node compatibility mode or this is not an ESM
|
||||
// file that has been converted to a CommonJS file using a Babel-
|
||||
// compatible transform (i.e. "__esModule" has not been set), then set
|
||||
// "default" to the CommonJS "module.exports" for node compatibility.
|
||||
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
||||
mod
|
||||
));
|
||||
|
||||
export {
|
||||
__commonJS,
|
||||
__toESM
|
||||
};
|
||||
//# sourceMappingURL=chunk-5WRI5ZAA.js.map
|
||||
7
node_modules/.vite/deps/chunk-5WRI5ZAA.js.map
generated
vendored
7
node_modules/.vite/deps/chunk-5WRI5ZAA.js.map
generated
vendored
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": [],
|
||||
"sourcesContent": [],
|
||||
"mappings": "",
|
||||
"names": []
|
||||
}
|
||||
1906
node_modules/.vite/deps/chunk-CANBAPAS.js
generated
vendored
1906
node_modules/.vite/deps/chunk-CANBAPAS.js
generated
vendored
File diff suppressed because it is too large
Load Diff
7
node_modules/.vite/deps/chunk-CANBAPAS.js.map
generated
vendored
7
node_modules/.vite/deps/chunk-CANBAPAS.js.map
generated
vendored
File diff suppressed because one or more lines are too long
84
node_modules/.vite/deps/chunk-EHLE63A5.js
generated
vendored
84
node_modules/.vite/deps/chunk-EHLE63A5.js
generated
vendored
@@ -1,84 +0,0 @@
|
||||
import {
|
||||
require_react
|
||||
} from "./chunk-CANBAPAS.js";
|
||||
import {
|
||||
__toESM
|
||||
} from "./chunk-5WRI5ZAA.js";
|
||||
|
||||
// node_modules/react-icons/lib/esm/iconBase.js
|
||||
var import_react2 = __toESM(require_react());
|
||||
|
||||
// node_modules/react-icons/lib/esm/iconContext.js
|
||||
var import_react = __toESM(require_react());
|
||||
var DefaultContext = {
|
||||
color: void 0,
|
||||
size: void 0,
|
||||
className: void 0,
|
||||
style: void 0,
|
||||
attr: void 0
|
||||
};
|
||||
var IconContext = import_react.default.createContext && import_react.default.createContext(DefaultContext);
|
||||
|
||||
// node_modules/react-icons/lib/esm/iconBase.js
|
||||
var __assign = function() {
|
||||
__assign = Object.assign || function(t) {
|
||||
for (var s, i = 1, n = arguments.length; i < n; i++) {
|
||||
s = arguments[i];
|
||||
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p];
|
||||
}
|
||||
return t;
|
||||
};
|
||||
return __assign.apply(this, arguments);
|
||||
};
|
||||
var __rest = function(s, e) {
|
||||
var t = {};
|
||||
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p];
|
||||
if (s != null && typeof Object.getOwnPropertySymbols === "function") for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
|
||||
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]];
|
||||
}
|
||||
return t;
|
||||
};
|
||||
function Tree2Element(tree) {
|
||||
return tree && tree.map(function(node, i) {
|
||||
return import_react2.default.createElement(node.tag, __assign({
|
||||
key: i
|
||||
}, node.attr), Tree2Element(node.child));
|
||||
});
|
||||
}
|
||||
function GenIcon(data) {
|
||||
return function(props) {
|
||||
return import_react2.default.createElement(IconBase, __assign({
|
||||
attr: __assign({}, data.attr)
|
||||
}, props), Tree2Element(data.child));
|
||||
};
|
||||
}
|
||||
function IconBase(props) {
|
||||
var elem = function(conf) {
|
||||
var attr = props.attr, size = props.size, title = props.title, svgProps = __rest(props, ["attr", "size", "title"]);
|
||||
var computedSize = size || conf.size || "1em";
|
||||
var className;
|
||||
if (conf.className) className = conf.className;
|
||||
if (props.className) className = (className ? className + " " : "") + props.className;
|
||||
return import_react2.default.createElement("svg", __assign({
|
||||
stroke: "currentColor",
|
||||
fill: "currentColor",
|
||||
strokeWidth: "0"
|
||||
}, conf.attr, attr, svgProps, {
|
||||
className,
|
||||
style: __assign(__assign({
|
||||
color: props.color || conf.color
|
||||
}, conf.style), props.style),
|
||||
height: computedSize,
|
||||
width: computedSize,
|
||||
xmlns: "http://www.w3.org/2000/svg"
|
||||
}), title && import_react2.default.createElement("title", null, title), props.children);
|
||||
};
|
||||
return IconContext !== void 0 ? import_react2.default.createElement(IconContext.Consumer, null, function(conf) {
|
||||
return elem(conf);
|
||||
}) : elem(DefaultContext);
|
||||
}
|
||||
|
||||
export {
|
||||
GenIcon
|
||||
};
|
||||
//# sourceMappingURL=chunk-EHLE63A5.js.map
|
||||
7
node_modules/.vite/deps/chunk-EHLE63A5.js.map
generated
vendored
7
node_modules/.vite/deps/chunk-EHLE63A5.js.map
generated
vendored
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": ["../../react-icons/lib/esm/iconBase.js", "../../react-icons/lib/esm/iconContext.js"],
|
||||
"sourcesContent": ["var __assign = this && this.__assign || function () {\n __assign = Object.assign || function (t) {\n for (var s, i = 1, n = arguments.length; i < n; i++) {\n s = arguments[i];\n for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p];\n }\n return t;\n };\n return __assign.apply(this, arguments);\n};\nvar __rest = this && this.__rest || function (s, e) {\n var t = {};\n for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p];\n if (s != null && typeof Object.getOwnPropertySymbols === \"function\") for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {\n if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]];\n }\n return t;\n};\nimport React from \"react\";\nimport { IconContext, DefaultContext } from \"./iconContext\";\nfunction Tree2Element(tree) {\n return tree && tree.map(function (node, i) {\n return React.createElement(node.tag, __assign({\n key: i\n }, node.attr), Tree2Element(node.child));\n });\n}\nexport function GenIcon(data) {\n // eslint-disable-next-line react/display-name\n return function (props) {\n return React.createElement(IconBase, __assign({\n attr: __assign({}, data.attr)\n }, props), Tree2Element(data.child));\n };\n}\nexport function IconBase(props) {\n var elem = function (conf) {\n var attr = props.attr,\n size = props.size,\n title = props.title,\n svgProps = __rest(props, [\"attr\", \"size\", \"title\"]);\n var computedSize = size || conf.size || \"1em\";\n var className;\n if (conf.className) className = conf.className;\n if (props.className) className = (className ? className + \" \" : \"\") + props.className;\n return React.createElement(\"svg\", __assign({\n stroke: \"currentColor\",\n fill: \"currentColor\",\n strokeWidth: \"0\"\n }, conf.attr, attr, svgProps, {\n className: className,\n style: __assign(__assign({\n color: props.color || conf.color\n }, conf.style), props.style),\n height: computedSize,\n width: computedSize,\n xmlns: \"http://www.w3.org/2000/svg\"\n }), title && React.createElement(\"title\", null, title), props.children);\n };\n return IconContext !== undefined ? React.createElement(IconContext.Consumer, null, function (conf) {\n return elem(conf);\n }) : elem(DefaultContext);\n}", "import React from \"react\";\nexport var DefaultContext = {\n color: undefined,\n size: undefined,\n className: undefined,\n style: undefined,\n attr: undefined\n};\nexport var IconContext = React.createContext && React.createContext(DefaultContext);"],
|
||||
"mappings": ";;;;;;;;AAkBA,IAAAA,gBAAkB;;;AClBlB,mBAAkB;AACX,IAAI,iBAAiB;AAAA,EAC1B,OAAO;AAAA,EACP,MAAM;AAAA,EACN,WAAW;AAAA,EACX,OAAO;AAAA,EACP,MAAM;AACR;AACO,IAAI,cAAc,aAAAC,QAAM,iBAAiB,aAAAA,QAAM,cAAc,cAAc;;;ADRlF,IAAI,WAAoC,WAAY;AAClD,aAAW,OAAO,UAAU,SAAU,GAAG;AACvC,aAAS,GAAG,IAAI,GAAG,IAAI,UAAU,QAAQ,IAAI,GAAG,KAAK;AACnD,UAAI,UAAU,CAAC;AACf,eAAS,KAAK,EAAG,KAAI,OAAO,UAAU,eAAe,KAAK,GAAG,CAAC,EAAG,GAAE,CAAC,IAAI,EAAE,CAAC;AAAA,IAC7E;AACA,WAAO;AAAA,EACT;AACA,SAAO,SAAS,MAAM,MAAM,SAAS;AACvC;AACA,IAAI,SAAgC,SAAU,GAAG,GAAG;AAClD,MAAI,IAAI,CAAC;AACT,WAAS,KAAK,EAAG,KAAI,OAAO,UAAU,eAAe,KAAK,GAAG,CAAC,KAAK,EAAE,QAAQ,CAAC,IAAI,EAAG,GAAE,CAAC,IAAI,EAAE,CAAC;AAC/F,MAAI,KAAK,QAAQ,OAAO,OAAO,0BAA0B,WAAY,UAAS,IAAI,GAAG,IAAI,OAAO,sBAAsB,CAAC,GAAG,IAAI,EAAE,QAAQ,KAAK;AAC3I,QAAI,EAAE,QAAQ,EAAE,CAAC,CAAC,IAAI,KAAK,OAAO,UAAU,qBAAqB,KAAK,GAAG,EAAE,CAAC,CAAC,EAAG,GAAE,EAAE,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;AAAA,EAClG;AACA,SAAO;AACT;AAGA,SAAS,aAAa,MAAM;AAC1B,SAAO,QAAQ,KAAK,IAAI,SAAU,MAAM,GAAG;AACzC,WAAO,cAAAC,QAAM,cAAc,KAAK,KAAK,SAAS;AAAA,MAC5C,KAAK;AAAA,IACP,GAAG,KAAK,IAAI,GAAG,aAAa,KAAK,KAAK,CAAC;AAAA,EACzC,CAAC;AACH;AACO,SAAS,QAAQ,MAAM;AAE5B,SAAO,SAAU,OAAO;AACtB,WAAO,cAAAA,QAAM,cAAc,UAAU,SAAS;AAAA,MAC5C,MAAM,SAAS,CAAC,GAAG,KAAK,IAAI;AAAA,IAC9B,GAAG,KAAK,GAAG,aAAa,KAAK,KAAK,CAAC;AAAA,EACrC;AACF;AACO,SAAS,SAAS,OAAO;AAC9B,MAAI,OAAO,SAAU,MAAM;AACzB,QAAI,OAAO,MAAM,MACf,OAAO,MAAM,MACb,QAAQ,MAAM,OACd,WAAW,OAAO,OAAO,CAAC,QAAQ,QAAQ,OAAO,CAAC;AACpD,QAAI,eAAe,QAAQ,KAAK,QAAQ;AACxC,QAAI;AACJ,QAAI,KAAK,UAAW,aAAY,KAAK;AACrC,QAAI,MAAM,UAAW,cAAa,YAAY,YAAY,MAAM,MAAM,MAAM;AAC5E,WAAO,cAAAA,QAAM,cAAc,OAAO,SAAS;AAAA,MACzC,QAAQ;AAAA,MACR,MAAM;AAAA,MACN,aAAa;AAAA,IACf,GAAG,KAAK,MAAM,MAAM,UAAU;AAAA,MAC5B;AAAA,MACA,OAAO,SAAS,SAAS;AAAA,QACvB,OAAO,MAAM,SAAS,KAAK;AAAA,MAC7B,GAAG,KAAK,KAAK,GAAG,MAAM,KAAK;AAAA,MAC3B,QAAQ;AAAA,MACR,OAAO;AAAA,MACP,OAAO;AAAA,IACT,CAAC,GAAG,SAAS,cAAAA,QAAM,cAAc,SAAS,MAAM,KAAK,GAAG,MAAM,QAAQ;AAAA,EACxE;AACA,SAAO,gBAAgB,SAAY,cAAAA,QAAM,cAAc,YAAY,UAAU,MAAM,SAAU,MAAM;AACjG,WAAO,KAAK,IAAI;AAAA,EAClB,CAAC,IAAI,KAAK,cAAc;AAC1B;",
|
||||
"names": ["import_react", "React", "React"]
|
||||
}
|
||||
7261
node_modules/.vite/deps/chunk-TOOCKHFL.js
generated
vendored
7261
node_modules/.vite/deps/chunk-TOOCKHFL.js
generated
vendored
File diff suppressed because it is too large
Load Diff
7
node_modules/.vite/deps/chunk-TOOCKHFL.js.map
generated
vendored
7
node_modules/.vite/deps/chunk-TOOCKHFL.js.map
generated
vendored
File diff suppressed because one or more lines are too long
21628
node_modules/.vite/deps/chunk-TYILIMWK.js
generated
vendored
21628
node_modules/.vite/deps/chunk-TYILIMWK.js
generated
vendored
File diff suppressed because it is too large
Load Diff
7
node_modules/.vite/deps/chunk-TYILIMWK.js.map
generated
vendored
7
node_modules/.vite/deps/chunk-TYILIMWK.js.map
generated
vendored
File diff suppressed because one or more lines are too long
4
node_modules/.vite/deps/date-fns.js
generated
vendored
4
node_modules/.vite/deps/date-fns.js
generated
vendored
@@ -256,8 +256,8 @@ import {
|
||||
weeksToDays,
|
||||
yearsToMonths,
|
||||
yearsToQuarters
|
||||
} from "./chunk-TOOCKHFL.js";
|
||||
import "./chunk-5WRI5ZAA.js";
|
||||
} from "./chunk-SJKHQ62W.js";
|
||||
import "./chunk-G3PMV62Z.js";
|
||||
export {
|
||||
add,
|
||||
addBusinessDays,
|
||||
|
||||
4
node_modules/.vite/deps/date-fns_locale.js
generated
vendored
4
node_modules/.vite/deps/date-fns_locale.js
generated
vendored
@@ -11,8 +11,8 @@ import {
|
||||
requiredArgs,
|
||||
startOfUTCWeek,
|
||||
toDate
|
||||
} from "./chunk-TOOCKHFL.js";
|
||||
import "./chunk-5WRI5ZAA.js";
|
||||
} from "./chunk-SJKHQ62W.js";
|
||||
import "./chunk-G3PMV62Z.js";
|
||||
|
||||
// node_modules/date-fns/esm/locale/af/_lib/formatDistance/index.js
|
||||
var formatDistanceLocale = {
|
||||
|
||||
6
node_modules/.vite/deps/react-dom.js
generated
vendored
6
node_modules/.vite/deps/react-dom.js
generated
vendored
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
require_react_dom
|
||||
} from "./chunk-TYILIMWK.js";
|
||||
import "./chunk-CANBAPAS.js";
|
||||
import "./chunk-5WRI5ZAA.js";
|
||||
} from "./chunk-PJEEZAML.js";
|
||||
import "./chunk-DRWLMN53.js";
|
||||
import "./chunk-G3PMV62Z.js";
|
||||
export default require_react_dom();
|
||||
//# sourceMappingURL=react-dom.js.map
|
||||
|
||||
6
node_modules/.vite/deps/react-dom_client.js
generated
vendored
6
node_modules/.vite/deps/react-dom_client.js
generated
vendored
@@ -1,10 +1,10 @@
|
||||
import {
|
||||
require_react_dom
|
||||
} from "./chunk-TYILIMWK.js";
|
||||
import "./chunk-CANBAPAS.js";
|
||||
} from "./chunk-PJEEZAML.js";
|
||||
import "./chunk-DRWLMN53.js";
|
||||
import {
|
||||
__commonJS
|
||||
} from "./chunk-5WRI5ZAA.js";
|
||||
} from "./chunk-G3PMV62Z.js";
|
||||
|
||||
// node_modules/react-dom/client.js
|
||||
var require_client = __commonJS({
|
||||
|
||||
6
node_modules/.vite/deps/react-icons_fa.js
generated
vendored
6
node_modules/.vite/deps/react-icons_fa.js
generated
vendored
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
GenIcon
|
||||
} from "./chunk-EHLE63A5.js";
|
||||
import "./chunk-CANBAPAS.js";
|
||||
import "./chunk-5WRI5ZAA.js";
|
||||
} from "./chunk-DQJKJRV5.js";
|
||||
import "./chunk-DRWLMN53.js";
|
||||
import "./chunk-G3PMV62Z.js";
|
||||
|
||||
// node_modules/react-icons/fa/index.esm.js
|
||||
function Fa500Px(props) {
|
||||
|
||||
6
node_modules/.vite/deps/react-icons_fa6.js
generated
vendored
6
node_modules/.vite/deps/react-icons_fa6.js
generated
vendored
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
GenIcon
|
||||
} from "./chunk-EHLE63A5.js";
|
||||
import "./chunk-CANBAPAS.js";
|
||||
import "./chunk-5WRI5ZAA.js";
|
||||
} from "./chunk-DQJKJRV5.js";
|
||||
import "./chunk-DRWLMN53.js";
|
||||
import "./chunk-G3PMV62Z.js";
|
||||
|
||||
// node_modules/react-icons/fa6/index.esm.js
|
||||
function Fa42Group(props) {
|
||||
|
||||
6
node_modules/.vite/deps/react-router-dom.js
generated
vendored
6
node_modules/.vite/deps/react-router-dom.js
generated
vendored
@@ -1,12 +1,12 @@
|
||||
import {
|
||||
require_react_dom
|
||||
} from "./chunk-TYILIMWK.js";
|
||||
} from "./chunk-PJEEZAML.js";
|
||||
import {
|
||||
require_react
|
||||
} from "./chunk-CANBAPAS.js";
|
||||
} from "./chunk-DRWLMN53.js";
|
||||
import {
|
||||
__toESM
|
||||
} from "./chunk-5WRI5ZAA.js";
|
||||
} from "./chunk-G3PMV62Z.js";
|
||||
|
||||
// node_modules/react-router-dom/dist/index.js
|
||||
var React2 = __toESM(require_react());
|
||||
|
||||
4
node_modules/.vite/deps/react.js
generated
vendored
4
node_modules/.vite/deps/react.js
generated
vendored
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
require_react
|
||||
} from "./chunk-CANBAPAS.js";
|
||||
import "./chunk-5WRI5ZAA.js";
|
||||
} from "./chunk-DRWLMN53.js";
|
||||
import "./chunk-G3PMV62Z.js";
|
||||
export default require_react();
|
||||
//# sourceMappingURL=react.js.map
|
||||
|
||||
4
node_modules/.vite/deps/react_jsx-dev-runtime.js
generated
vendored
4
node_modules/.vite/deps/react_jsx-dev-runtime.js
generated
vendored
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
require_react
|
||||
} from "./chunk-CANBAPAS.js";
|
||||
} from "./chunk-DRWLMN53.js";
|
||||
import {
|
||||
__commonJS
|
||||
} from "./chunk-5WRI5ZAA.js";
|
||||
} from "./chunk-G3PMV62Z.js";
|
||||
|
||||
// node_modules/react/cjs/react-jsx-dev-runtime.development.js
|
||||
var require_react_jsx_dev_runtime_development = __commonJS({
|
||||
|
||||
926
node_modules/.vite/deps/react_jsx-runtime.js
generated
vendored
926
node_modules/.vite/deps/react_jsx-runtime.js
generated
vendored
@@ -1,925 +1,7 @@
|
||||
import {
|
||||
require_react
|
||||
} from "./chunk-CANBAPAS.js";
|
||||
import {
|
||||
__commonJS
|
||||
} from "./chunk-5WRI5ZAA.js";
|
||||
|
||||
// node_modules/react/cjs/react-jsx-runtime.development.js
|
||||
var require_react_jsx_runtime_development = __commonJS({
|
||||
"node_modules/react/cjs/react-jsx-runtime.development.js"(exports) {
|
||||
"use strict";
|
||||
if (true) {
|
||||
(function() {
|
||||
"use strict";
|
||||
var React = require_react();
|
||||
var REACT_ELEMENT_TYPE = Symbol.for("react.element");
|
||||
var REACT_PORTAL_TYPE = Symbol.for("react.portal");
|
||||
var REACT_FRAGMENT_TYPE = Symbol.for("react.fragment");
|
||||
var REACT_STRICT_MODE_TYPE = Symbol.for("react.strict_mode");
|
||||
var REACT_PROFILER_TYPE = Symbol.for("react.profiler");
|
||||
var REACT_PROVIDER_TYPE = Symbol.for("react.provider");
|
||||
var REACT_CONTEXT_TYPE = Symbol.for("react.context");
|
||||
var REACT_FORWARD_REF_TYPE = Symbol.for("react.forward_ref");
|
||||
var REACT_SUSPENSE_TYPE = Symbol.for("react.suspense");
|
||||
var REACT_SUSPENSE_LIST_TYPE = Symbol.for("react.suspense_list");
|
||||
var REACT_MEMO_TYPE = Symbol.for("react.memo");
|
||||
var REACT_LAZY_TYPE = Symbol.for("react.lazy");
|
||||
var REACT_OFFSCREEN_TYPE = Symbol.for("react.offscreen");
|
||||
var MAYBE_ITERATOR_SYMBOL = Symbol.iterator;
|
||||
var FAUX_ITERATOR_SYMBOL = "@@iterator";
|
||||
function getIteratorFn(maybeIterable) {
|
||||
if (maybeIterable === null || typeof maybeIterable !== "object") {
|
||||
return null;
|
||||
}
|
||||
var maybeIterator = MAYBE_ITERATOR_SYMBOL && maybeIterable[MAYBE_ITERATOR_SYMBOL] || maybeIterable[FAUX_ITERATOR_SYMBOL];
|
||||
if (typeof maybeIterator === "function") {
|
||||
return maybeIterator;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
var ReactSharedInternals = React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;
|
||||
function error(format) {
|
||||
{
|
||||
{
|
||||
for (var _len2 = arguments.length, args = new Array(_len2 > 1 ? _len2 - 1 : 0), _key2 = 1; _key2 < _len2; _key2++) {
|
||||
args[_key2 - 1] = arguments[_key2];
|
||||
}
|
||||
printWarning("error", format, args);
|
||||
}
|
||||
}
|
||||
}
|
||||
function printWarning(level, format, args) {
|
||||
{
|
||||
var ReactDebugCurrentFrame2 = ReactSharedInternals.ReactDebugCurrentFrame;
|
||||
var stack = ReactDebugCurrentFrame2.getStackAddendum();
|
||||
if (stack !== "") {
|
||||
format += "%s";
|
||||
args = args.concat([stack]);
|
||||
}
|
||||
var argsWithFormat = args.map(function(item) {
|
||||
return String(item);
|
||||
});
|
||||
argsWithFormat.unshift("Warning: " + format);
|
||||
Function.prototype.apply.call(console[level], console, argsWithFormat);
|
||||
}
|
||||
}
|
||||
var enableScopeAPI = false;
|
||||
var enableCacheElement = false;
|
||||
var enableTransitionTracing = false;
|
||||
var enableLegacyHidden = false;
|
||||
var enableDebugTracing = false;
|
||||
var REACT_MODULE_REFERENCE;
|
||||
{
|
||||
REACT_MODULE_REFERENCE = Symbol.for("react.module.reference");
|
||||
}
|
||||
function isValidElementType(type) {
|
||||
if (typeof type === "string" || typeof type === "function") {
|
||||
return true;
|
||||
}
|
||||
if (type === REACT_FRAGMENT_TYPE || type === REACT_PROFILER_TYPE || enableDebugTracing || type === REACT_STRICT_MODE_TYPE || type === REACT_SUSPENSE_TYPE || type === REACT_SUSPENSE_LIST_TYPE || enableLegacyHidden || type === REACT_OFFSCREEN_TYPE || enableScopeAPI || enableCacheElement || enableTransitionTracing) {
|
||||
return true;
|
||||
}
|
||||
if (typeof type === "object" && type !== null) {
|
||||
if (type.$$typeof === REACT_LAZY_TYPE || type.$$typeof === REACT_MEMO_TYPE || type.$$typeof === REACT_PROVIDER_TYPE || type.$$typeof === REACT_CONTEXT_TYPE || type.$$typeof === REACT_FORWARD_REF_TYPE || // This needs to include all possible module reference object
|
||||
// types supported by any Flight configuration anywhere since
|
||||
// we don't know which Flight build this will end up being used
|
||||
// with.
|
||||
type.$$typeof === REACT_MODULE_REFERENCE || type.getModuleId !== void 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function getWrappedName(outerType, innerType, wrapperName) {
|
||||
var displayName = outerType.displayName;
|
||||
if (displayName) {
|
||||
return displayName;
|
||||
}
|
||||
var functionName = innerType.displayName || innerType.name || "";
|
||||
return functionName !== "" ? wrapperName + "(" + functionName + ")" : wrapperName;
|
||||
}
|
||||
function getContextName(type) {
|
||||
return type.displayName || "Context";
|
||||
}
|
||||
function getComponentNameFromType(type) {
|
||||
if (type == null) {
|
||||
return null;
|
||||
}
|
||||
{
|
||||
if (typeof type.tag === "number") {
|
||||
error("Received an unexpected object in getComponentNameFromType(). This is likely a bug in React. Please file an issue.");
|
||||
}
|
||||
}
|
||||
if (typeof type === "function") {
|
||||
return type.displayName || type.name || null;
|
||||
}
|
||||
if (typeof type === "string") {
|
||||
return type;
|
||||
}
|
||||
switch (type) {
|
||||
case REACT_FRAGMENT_TYPE:
|
||||
return "Fragment";
|
||||
case REACT_PORTAL_TYPE:
|
||||
return "Portal";
|
||||
case REACT_PROFILER_TYPE:
|
||||
return "Profiler";
|
||||
case REACT_STRICT_MODE_TYPE:
|
||||
return "StrictMode";
|
||||
case REACT_SUSPENSE_TYPE:
|
||||
return "Suspense";
|
||||
case REACT_SUSPENSE_LIST_TYPE:
|
||||
return "SuspenseList";
|
||||
}
|
||||
if (typeof type === "object") {
|
||||
switch (type.$$typeof) {
|
||||
case REACT_CONTEXT_TYPE:
|
||||
var context = type;
|
||||
return getContextName(context) + ".Consumer";
|
||||
case REACT_PROVIDER_TYPE:
|
||||
var provider = type;
|
||||
return getContextName(provider._context) + ".Provider";
|
||||
case REACT_FORWARD_REF_TYPE:
|
||||
return getWrappedName(type, type.render, "ForwardRef");
|
||||
case REACT_MEMO_TYPE:
|
||||
var outerName = type.displayName || null;
|
||||
if (outerName !== null) {
|
||||
return outerName;
|
||||
}
|
||||
return getComponentNameFromType(type.type) || "Memo";
|
||||
case REACT_LAZY_TYPE: {
|
||||
var lazyComponent = type;
|
||||
var payload = lazyComponent._payload;
|
||||
var init = lazyComponent._init;
|
||||
try {
|
||||
return getComponentNameFromType(init(payload));
|
||||
} catch (x) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
var assign = Object.assign;
|
||||
var disabledDepth = 0;
|
||||
var prevLog;
|
||||
var prevInfo;
|
||||
var prevWarn;
|
||||
var prevError;
|
||||
var prevGroup;
|
||||
var prevGroupCollapsed;
|
||||
var prevGroupEnd;
|
||||
function disabledLog() {
|
||||
}
|
||||
disabledLog.__reactDisabledLog = true;
|
||||
function disableLogs() {
|
||||
{
|
||||
if (disabledDepth === 0) {
|
||||
prevLog = console.log;
|
||||
prevInfo = console.info;
|
||||
prevWarn = console.warn;
|
||||
prevError = console.error;
|
||||
prevGroup = console.group;
|
||||
prevGroupCollapsed = console.groupCollapsed;
|
||||
prevGroupEnd = console.groupEnd;
|
||||
var props = {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: disabledLog,
|
||||
writable: true
|
||||
};
|
||||
Object.defineProperties(console, {
|
||||
info: props,
|
||||
log: props,
|
||||
warn: props,
|
||||
error: props,
|
||||
group: props,
|
||||
groupCollapsed: props,
|
||||
groupEnd: props
|
||||
});
|
||||
}
|
||||
disabledDepth++;
|
||||
}
|
||||
}
|
||||
function reenableLogs() {
|
||||
{
|
||||
disabledDepth--;
|
||||
if (disabledDepth === 0) {
|
||||
var props = {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
writable: true
|
||||
};
|
||||
Object.defineProperties(console, {
|
||||
log: assign({}, props, {
|
||||
value: prevLog
|
||||
}),
|
||||
info: assign({}, props, {
|
||||
value: prevInfo
|
||||
}),
|
||||
warn: assign({}, props, {
|
||||
value: prevWarn
|
||||
}),
|
||||
error: assign({}, props, {
|
||||
value: prevError
|
||||
}),
|
||||
group: assign({}, props, {
|
||||
value: prevGroup
|
||||
}),
|
||||
groupCollapsed: assign({}, props, {
|
||||
value: prevGroupCollapsed
|
||||
}),
|
||||
groupEnd: assign({}, props, {
|
||||
value: prevGroupEnd
|
||||
})
|
||||
});
|
||||
}
|
||||
if (disabledDepth < 0) {
|
||||
error("disabledDepth fell below zero. This is a bug in React. Please file an issue.");
|
||||
}
|
||||
}
|
||||
}
|
||||
var ReactCurrentDispatcher = ReactSharedInternals.ReactCurrentDispatcher;
|
||||
var prefix;
|
||||
function describeBuiltInComponentFrame(name, source, ownerFn) {
|
||||
{
|
||||
if (prefix === void 0) {
|
||||
try {
|
||||
throw Error();
|
||||
} catch (x) {
|
||||
var match = x.stack.trim().match(/\n( *(at )?)/);
|
||||
prefix = match && match[1] || "";
|
||||
}
|
||||
}
|
||||
return "\n" + prefix + name;
|
||||
}
|
||||
}
|
||||
var reentry = false;
|
||||
var componentFrameCache;
|
||||
{
|
||||
var PossiblyWeakMap = typeof WeakMap === "function" ? WeakMap : Map;
|
||||
componentFrameCache = new PossiblyWeakMap();
|
||||
}
|
||||
function describeNativeComponentFrame(fn, construct) {
|
||||
if (!fn || reentry) {
|
||||
return "";
|
||||
}
|
||||
{
|
||||
var frame = componentFrameCache.get(fn);
|
||||
if (frame !== void 0) {
|
||||
return frame;
|
||||
}
|
||||
}
|
||||
var control;
|
||||
reentry = true;
|
||||
var previousPrepareStackTrace = Error.prepareStackTrace;
|
||||
Error.prepareStackTrace = void 0;
|
||||
var previousDispatcher;
|
||||
{
|
||||
previousDispatcher = ReactCurrentDispatcher.current;
|
||||
ReactCurrentDispatcher.current = null;
|
||||
disableLogs();
|
||||
}
|
||||
try {
|
||||
if (construct) {
|
||||
var Fake = function() {
|
||||
throw Error();
|
||||
};
|
||||
Object.defineProperty(Fake.prototype, "props", {
|
||||
set: function() {
|
||||
throw Error();
|
||||
}
|
||||
});
|
||||
if (typeof Reflect === "object" && Reflect.construct) {
|
||||
try {
|
||||
Reflect.construct(Fake, []);
|
||||
} catch (x) {
|
||||
control = x;
|
||||
}
|
||||
Reflect.construct(fn, [], Fake);
|
||||
} else {
|
||||
try {
|
||||
Fake.call();
|
||||
} catch (x) {
|
||||
control = x;
|
||||
}
|
||||
fn.call(Fake.prototype);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
throw Error();
|
||||
} catch (x) {
|
||||
control = x;
|
||||
}
|
||||
fn();
|
||||
}
|
||||
} catch (sample) {
|
||||
if (sample && control && typeof sample.stack === "string") {
|
||||
var sampleLines = sample.stack.split("\n");
|
||||
var controlLines = control.stack.split("\n");
|
||||
var s = sampleLines.length - 1;
|
||||
var c = controlLines.length - 1;
|
||||
while (s >= 1 && c >= 0 && sampleLines[s] !== controlLines[c]) {
|
||||
c--;
|
||||
}
|
||||
for (; s >= 1 && c >= 0; s--, c--) {
|
||||
if (sampleLines[s] !== controlLines[c]) {
|
||||
if (s !== 1 || c !== 1) {
|
||||
do {
|
||||
s--;
|
||||
c--;
|
||||
if (c < 0 || sampleLines[s] !== controlLines[c]) {
|
||||
var _frame = "\n" + sampleLines[s].replace(" at new ", " at ");
|
||||
if (fn.displayName && _frame.includes("<anonymous>")) {
|
||||
_frame = _frame.replace("<anonymous>", fn.displayName);
|
||||
}
|
||||
{
|
||||
if (typeof fn === "function") {
|
||||
componentFrameCache.set(fn, _frame);
|
||||
}
|
||||
}
|
||||
return _frame;
|
||||
}
|
||||
} while (s >= 1 && c >= 0);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reentry = false;
|
||||
{
|
||||
ReactCurrentDispatcher.current = previousDispatcher;
|
||||
reenableLogs();
|
||||
}
|
||||
Error.prepareStackTrace = previousPrepareStackTrace;
|
||||
}
|
||||
var name = fn ? fn.displayName || fn.name : "";
|
||||
var syntheticFrame = name ? describeBuiltInComponentFrame(name) : "";
|
||||
{
|
||||
if (typeof fn === "function") {
|
||||
componentFrameCache.set(fn, syntheticFrame);
|
||||
}
|
||||
}
|
||||
return syntheticFrame;
|
||||
}
|
||||
function describeFunctionComponentFrame(fn, source, ownerFn) {
|
||||
{
|
||||
return describeNativeComponentFrame(fn, false);
|
||||
}
|
||||
}
|
||||
function shouldConstruct(Component) {
|
||||
var prototype = Component.prototype;
|
||||
return !!(prototype && prototype.isReactComponent);
|
||||
}
|
||||
function describeUnknownElementTypeFrameInDEV(type, source, ownerFn) {
|
||||
if (type == null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof type === "function") {
|
||||
{
|
||||
return describeNativeComponentFrame(type, shouldConstruct(type));
|
||||
}
|
||||
}
|
||||
if (typeof type === "string") {
|
||||
return describeBuiltInComponentFrame(type);
|
||||
}
|
||||
switch (type) {
|
||||
case REACT_SUSPENSE_TYPE:
|
||||
return describeBuiltInComponentFrame("Suspense");
|
||||
case REACT_SUSPENSE_LIST_TYPE:
|
||||
return describeBuiltInComponentFrame("SuspenseList");
|
||||
}
|
||||
if (typeof type === "object") {
|
||||
switch (type.$$typeof) {
|
||||
case REACT_FORWARD_REF_TYPE:
|
||||
return describeFunctionComponentFrame(type.render);
|
||||
case REACT_MEMO_TYPE:
|
||||
return describeUnknownElementTypeFrameInDEV(type.type, source, ownerFn);
|
||||
case REACT_LAZY_TYPE: {
|
||||
var lazyComponent = type;
|
||||
var payload = lazyComponent._payload;
|
||||
var init = lazyComponent._init;
|
||||
try {
|
||||
return describeUnknownElementTypeFrameInDEV(init(payload), source, ownerFn);
|
||||
} catch (x) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
var hasOwnProperty = Object.prototype.hasOwnProperty;
|
||||
var loggedTypeFailures = {};
|
||||
var ReactDebugCurrentFrame = ReactSharedInternals.ReactDebugCurrentFrame;
|
||||
function setCurrentlyValidatingElement(element) {
|
||||
{
|
||||
if (element) {
|
||||
var owner = element._owner;
|
||||
var stack = describeUnknownElementTypeFrameInDEV(element.type, element._source, owner ? owner.type : null);
|
||||
ReactDebugCurrentFrame.setExtraStackFrame(stack);
|
||||
} else {
|
||||
ReactDebugCurrentFrame.setExtraStackFrame(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
function checkPropTypes(typeSpecs, values, location, componentName, element) {
|
||||
{
|
||||
var has = Function.call.bind(hasOwnProperty);
|
||||
for (var typeSpecName in typeSpecs) {
|
||||
if (has(typeSpecs, typeSpecName)) {
|
||||
var error$1 = void 0;
|
||||
try {
|
||||
if (typeof typeSpecs[typeSpecName] !== "function") {
|
||||
var err = Error((componentName || "React class") + ": " + location + " type `" + typeSpecName + "` is invalid; it must be a function, usually from the `prop-types` package, but received `" + typeof typeSpecs[typeSpecName] + "`.This often happens because of typos such as `PropTypes.function` instead of `PropTypes.func`.");
|
||||
err.name = "Invariant Violation";
|
||||
throw err;
|
||||
}
|
||||
error$1 = typeSpecs[typeSpecName](values, typeSpecName, componentName, location, null, "SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED");
|
||||
} catch (ex) {
|
||||
error$1 = ex;
|
||||
}
|
||||
if (error$1 && !(error$1 instanceof Error)) {
|
||||
setCurrentlyValidatingElement(element);
|
||||
error("%s: type specification of %s `%s` is invalid; the type checker function must return `null` or an `Error` but returned a %s. You may have forgotten to pass an argument to the type checker creator (arrayOf, instanceOf, objectOf, oneOf, oneOfType, and shape all require an argument).", componentName || "React class", location, typeSpecName, typeof error$1);
|
||||
setCurrentlyValidatingElement(null);
|
||||
}
|
||||
if (error$1 instanceof Error && !(error$1.message in loggedTypeFailures)) {
|
||||
loggedTypeFailures[error$1.message] = true;
|
||||
setCurrentlyValidatingElement(element);
|
||||
error("Failed %s type: %s", location, error$1.message);
|
||||
setCurrentlyValidatingElement(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var isArrayImpl = Array.isArray;
|
||||
function isArray(a) {
|
||||
return isArrayImpl(a);
|
||||
}
|
||||
function typeName(value) {
|
||||
{
|
||||
var hasToStringTag = typeof Symbol === "function" && Symbol.toStringTag;
|
||||
var type = hasToStringTag && value[Symbol.toStringTag] || value.constructor.name || "Object";
|
||||
return type;
|
||||
}
|
||||
}
|
||||
function willCoercionThrow(value) {
|
||||
{
|
||||
try {
|
||||
testStringCoercion(value);
|
||||
return false;
|
||||
} catch (e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
function testStringCoercion(value) {
|
||||
return "" + value;
|
||||
}
|
||||
function checkKeyStringCoercion(value) {
|
||||
{
|
||||
if (willCoercionThrow(value)) {
|
||||
error("The provided key is an unsupported type %s. This value must be coerced to a string before before using it here.", typeName(value));
|
||||
return testStringCoercion(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
var ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
|
||||
var RESERVED_PROPS = {
|
||||
key: true,
|
||||
ref: true,
|
||||
__self: true,
|
||||
__source: true
|
||||
};
|
||||
var specialPropKeyWarningShown;
|
||||
var specialPropRefWarningShown;
|
||||
var didWarnAboutStringRefs;
|
||||
{
|
||||
didWarnAboutStringRefs = {};
|
||||
}
|
||||
function hasValidRef(config) {
|
||||
{
|
||||
if (hasOwnProperty.call(config, "ref")) {
|
||||
var getter = Object.getOwnPropertyDescriptor(config, "ref").get;
|
||||
if (getter && getter.isReactWarning) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return config.ref !== void 0;
|
||||
}
|
||||
function hasValidKey(config) {
|
||||
{
|
||||
if (hasOwnProperty.call(config, "key")) {
|
||||
var getter = Object.getOwnPropertyDescriptor(config, "key").get;
|
||||
if (getter && getter.isReactWarning) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return config.key !== void 0;
|
||||
}
|
||||
function warnIfStringRefCannotBeAutoConverted(config, self) {
|
||||
{
|
||||
if (typeof config.ref === "string" && ReactCurrentOwner.current && self && ReactCurrentOwner.current.stateNode !== self) {
|
||||
var componentName = getComponentNameFromType(ReactCurrentOwner.current.type);
|
||||
if (!didWarnAboutStringRefs[componentName]) {
|
||||
error('Component "%s" contains the string ref "%s". Support for string refs will be removed in a future major release. This case cannot be automatically converted to an arrow function. We ask you to manually fix this case by using useRef() or createRef() instead. Learn more about using refs safely here: https://reactjs.org/link/strict-mode-string-ref', getComponentNameFromType(ReactCurrentOwner.current.type), config.ref);
|
||||
didWarnAboutStringRefs[componentName] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
function defineKeyPropWarningGetter(props, displayName) {
|
||||
{
|
||||
var warnAboutAccessingKey = function() {
|
||||
if (!specialPropKeyWarningShown) {
|
||||
specialPropKeyWarningShown = true;
|
||||
error("%s: `key` is not a prop. Trying to access it will result in `undefined` being returned. If you need to access the same value within the child component, you should pass it as a different prop. (https://reactjs.org/link/special-props)", displayName);
|
||||
}
|
||||
};
|
||||
warnAboutAccessingKey.isReactWarning = true;
|
||||
Object.defineProperty(props, "key", {
|
||||
get: warnAboutAccessingKey,
|
||||
configurable: true
|
||||
});
|
||||
}
|
||||
}
|
||||
function defineRefPropWarningGetter(props, displayName) {
|
||||
{
|
||||
var warnAboutAccessingRef = function() {
|
||||
if (!specialPropRefWarningShown) {
|
||||
specialPropRefWarningShown = true;
|
||||
error("%s: `ref` is not a prop. Trying to access it will result in `undefined` being returned. If you need to access the same value within the child component, you should pass it as a different prop. (https://reactjs.org/link/special-props)", displayName);
|
||||
}
|
||||
};
|
||||
warnAboutAccessingRef.isReactWarning = true;
|
||||
Object.defineProperty(props, "ref", {
|
||||
get: warnAboutAccessingRef,
|
||||
configurable: true
|
||||
});
|
||||
}
|
||||
}
|
||||
var ReactElement = function(type, key, ref, self, source, owner, props) {
|
||||
var element = {
|
||||
// This tag allows us to uniquely identify this as a React Element
|
||||
$$typeof: REACT_ELEMENT_TYPE,
|
||||
// Built-in properties that belong on the element
|
||||
type,
|
||||
key,
|
||||
ref,
|
||||
props,
|
||||
// Record the component responsible for creating this element.
|
||||
_owner: owner
|
||||
};
|
||||
{
|
||||
element._store = {};
|
||||
Object.defineProperty(element._store, "validated", {
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
writable: true,
|
||||
value: false
|
||||
});
|
||||
Object.defineProperty(element, "_self", {
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
value: self
|
||||
});
|
||||
Object.defineProperty(element, "_source", {
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
value: source
|
||||
});
|
||||
if (Object.freeze) {
|
||||
Object.freeze(element.props);
|
||||
Object.freeze(element);
|
||||
}
|
||||
}
|
||||
return element;
|
||||
};
|
||||
function jsxDEV(type, config, maybeKey, source, self) {
|
||||
{
|
||||
var propName;
|
||||
var props = {};
|
||||
var key = null;
|
||||
var ref = null;
|
||||
if (maybeKey !== void 0) {
|
||||
{
|
||||
checkKeyStringCoercion(maybeKey);
|
||||
}
|
||||
key = "" + maybeKey;
|
||||
}
|
||||
if (hasValidKey(config)) {
|
||||
{
|
||||
checkKeyStringCoercion(config.key);
|
||||
}
|
||||
key = "" + config.key;
|
||||
}
|
||||
if (hasValidRef(config)) {
|
||||
ref = config.ref;
|
||||
warnIfStringRefCannotBeAutoConverted(config, self);
|
||||
}
|
||||
for (propName in config) {
|
||||
if (hasOwnProperty.call(config, propName) && !RESERVED_PROPS.hasOwnProperty(propName)) {
|
||||
props[propName] = config[propName];
|
||||
}
|
||||
}
|
||||
if (type && type.defaultProps) {
|
||||
var defaultProps = type.defaultProps;
|
||||
for (propName in defaultProps) {
|
||||
if (props[propName] === void 0) {
|
||||
props[propName] = defaultProps[propName];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (key || ref) {
|
||||
var displayName = typeof type === "function" ? type.displayName || type.name || "Unknown" : type;
|
||||
if (key) {
|
||||
defineKeyPropWarningGetter(props, displayName);
|
||||
}
|
||||
if (ref) {
|
||||
defineRefPropWarningGetter(props, displayName);
|
||||
}
|
||||
}
|
||||
return ReactElement(type, key, ref, self, source, ReactCurrentOwner.current, props);
|
||||
}
|
||||
}
|
||||
var ReactCurrentOwner$1 = ReactSharedInternals.ReactCurrentOwner;
|
||||
var ReactDebugCurrentFrame$1 = ReactSharedInternals.ReactDebugCurrentFrame;
|
||||
function setCurrentlyValidatingElement$1(element) {
|
||||
{
|
||||
if (element) {
|
||||
var owner = element._owner;
|
||||
var stack = describeUnknownElementTypeFrameInDEV(element.type, element._source, owner ? owner.type : null);
|
||||
ReactDebugCurrentFrame$1.setExtraStackFrame(stack);
|
||||
} else {
|
||||
ReactDebugCurrentFrame$1.setExtraStackFrame(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
var propTypesMisspellWarningShown;
|
||||
{
|
||||
propTypesMisspellWarningShown = false;
|
||||
}
|
||||
function isValidElement(object) {
|
||||
{
|
||||
return typeof object === "object" && object !== null && object.$$typeof === REACT_ELEMENT_TYPE;
|
||||
}
|
||||
}
|
||||
function getDeclarationErrorAddendum() {
|
||||
{
|
||||
if (ReactCurrentOwner$1.current) {
|
||||
var name = getComponentNameFromType(ReactCurrentOwner$1.current.type);
|
||||
if (name) {
|
||||
return "\n\nCheck the render method of `" + name + "`.";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
function getSourceInfoErrorAddendum(source) {
|
||||
{
|
||||
if (source !== void 0) {
|
||||
var fileName = source.fileName.replace(/^.*[\\\/]/, "");
|
||||
var lineNumber = source.lineNumber;
|
||||
return "\n\nCheck your code at " + fileName + ":" + lineNumber + ".";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
var ownerHasKeyUseWarning = {};
|
||||
function getCurrentComponentErrorInfo(parentType) {
|
||||
{
|
||||
var info = getDeclarationErrorAddendum();
|
||||
if (!info) {
|
||||
var parentName = typeof parentType === "string" ? parentType : parentType.displayName || parentType.name;
|
||||
if (parentName) {
|
||||
info = "\n\nCheck the top-level render call using <" + parentName + ">.";
|
||||
}
|
||||
}
|
||||
return info;
|
||||
}
|
||||
}
|
||||
function validateExplicitKey(element, parentType) {
|
||||
{
|
||||
if (!element._store || element._store.validated || element.key != null) {
|
||||
return;
|
||||
}
|
||||
element._store.validated = true;
|
||||
var currentComponentErrorInfo = getCurrentComponentErrorInfo(parentType);
|
||||
if (ownerHasKeyUseWarning[currentComponentErrorInfo]) {
|
||||
return;
|
||||
}
|
||||
ownerHasKeyUseWarning[currentComponentErrorInfo] = true;
|
||||
var childOwner = "";
|
||||
if (element && element._owner && element._owner !== ReactCurrentOwner$1.current) {
|
||||
childOwner = " It was passed a child from " + getComponentNameFromType(element._owner.type) + ".";
|
||||
}
|
||||
setCurrentlyValidatingElement$1(element);
|
||||
error('Each child in a list should have a unique "key" prop.%s%s See https://reactjs.org/link/warning-keys for more information.', currentComponentErrorInfo, childOwner);
|
||||
setCurrentlyValidatingElement$1(null);
|
||||
}
|
||||
}
|
||||
function validateChildKeys(node, parentType) {
|
||||
{
|
||||
if (typeof node !== "object") {
|
||||
return;
|
||||
}
|
||||
if (isArray(node)) {
|
||||
for (var i = 0; i < node.length; i++) {
|
||||
var child = node[i];
|
||||
if (isValidElement(child)) {
|
||||
validateExplicitKey(child, parentType);
|
||||
}
|
||||
}
|
||||
} else if (isValidElement(node)) {
|
||||
if (node._store) {
|
||||
node._store.validated = true;
|
||||
}
|
||||
} else if (node) {
|
||||
var iteratorFn = getIteratorFn(node);
|
||||
if (typeof iteratorFn === "function") {
|
||||
if (iteratorFn !== node.entries) {
|
||||
var iterator = iteratorFn.call(node);
|
||||
var step;
|
||||
while (!(step = iterator.next()).done) {
|
||||
if (isValidElement(step.value)) {
|
||||
validateExplicitKey(step.value, parentType);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
function validatePropTypes(element) {
|
||||
{
|
||||
var type = element.type;
|
||||
if (type === null || type === void 0 || typeof type === "string") {
|
||||
return;
|
||||
}
|
||||
var propTypes;
|
||||
if (typeof type === "function") {
|
||||
propTypes = type.propTypes;
|
||||
} else if (typeof type === "object" && (type.$$typeof === REACT_FORWARD_REF_TYPE || // Note: Memo only checks outer props here.
|
||||
// Inner props are checked in the reconciler.
|
||||
type.$$typeof === REACT_MEMO_TYPE)) {
|
||||
propTypes = type.propTypes;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
if (propTypes) {
|
||||
var name = getComponentNameFromType(type);
|
||||
checkPropTypes(propTypes, element.props, "prop", name, element);
|
||||
} else if (type.PropTypes !== void 0 && !propTypesMisspellWarningShown) {
|
||||
propTypesMisspellWarningShown = true;
|
||||
var _name = getComponentNameFromType(type);
|
||||
error("Component %s declared `PropTypes` instead of `propTypes`. Did you misspell the property assignment?", _name || "Unknown");
|
||||
}
|
||||
if (typeof type.getDefaultProps === "function" && !type.getDefaultProps.isReactClassApproved) {
|
||||
error("getDefaultProps is only used on classic React.createClass definitions. Use a static property named `defaultProps` instead.");
|
||||
}
|
||||
}
|
||||
}
|
||||
function validateFragmentProps(fragment) {
|
||||
{
|
||||
var keys = Object.keys(fragment.props);
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
var key = keys[i];
|
||||
if (key !== "children" && key !== "key") {
|
||||
setCurrentlyValidatingElement$1(fragment);
|
||||
error("Invalid prop `%s` supplied to `React.Fragment`. React.Fragment can only have `key` and `children` props.", key);
|
||||
setCurrentlyValidatingElement$1(null);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (fragment.ref !== null) {
|
||||
setCurrentlyValidatingElement$1(fragment);
|
||||
error("Invalid attribute `ref` supplied to `React.Fragment`.");
|
||||
setCurrentlyValidatingElement$1(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
var didWarnAboutKeySpread = {};
|
||||
function jsxWithValidation(type, props, key, isStaticChildren, source, self) {
|
||||
{
|
||||
var validType = isValidElementType(type);
|
||||
if (!validType) {
|
||||
var info = "";
|
||||
if (type === void 0 || typeof type === "object" && type !== null && Object.keys(type).length === 0) {
|
||||
info += " You likely forgot to export your component from the file it's defined in, or you might have mixed up default and named imports.";
|
||||
}
|
||||
var sourceInfo = getSourceInfoErrorAddendum(source);
|
||||
if (sourceInfo) {
|
||||
info += sourceInfo;
|
||||
} else {
|
||||
info += getDeclarationErrorAddendum();
|
||||
}
|
||||
var typeString;
|
||||
if (type === null) {
|
||||
typeString = "null";
|
||||
} else if (isArray(type)) {
|
||||
typeString = "array";
|
||||
} else if (type !== void 0 && type.$$typeof === REACT_ELEMENT_TYPE) {
|
||||
typeString = "<" + (getComponentNameFromType(type.type) || "Unknown") + " />";
|
||||
info = " Did you accidentally export a JSX literal instead of a component?";
|
||||
} else {
|
||||
typeString = typeof type;
|
||||
}
|
||||
error("React.jsx: type is invalid -- expected a string (for built-in components) or a class/function (for composite components) but got: %s.%s", typeString, info);
|
||||
}
|
||||
var element = jsxDEV(type, props, key, source, self);
|
||||
if (element == null) {
|
||||
return element;
|
||||
}
|
||||
if (validType) {
|
||||
var children = props.children;
|
||||
if (children !== void 0) {
|
||||
if (isStaticChildren) {
|
||||
if (isArray(children)) {
|
||||
for (var i = 0; i < children.length; i++) {
|
||||
validateChildKeys(children[i], type);
|
||||
}
|
||||
if (Object.freeze) {
|
||||
Object.freeze(children);
|
||||
}
|
||||
} else {
|
||||
error("React.jsx: Static children should always be an array. You are likely explicitly calling React.jsxs or React.jsxDEV. Use the Babel transform instead.");
|
||||
}
|
||||
} else {
|
||||
validateChildKeys(children, type);
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
if (hasOwnProperty.call(props, "key")) {
|
||||
var componentName = getComponentNameFromType(type);
|
||||
var keys = Object.keys(props).filter(function(k) {
|
||||
return k !== "key";
|
||||
});
|
||||
var beforeExample = keys.length > 0 ? "{key: someKey, " + keys.join(": ..., ") + ": ...}" : "{key: someKey}";
|
||||
if (!didWarnAboutKeySpread[componentName + beforeExample]) {
|
||||
var afterExample = keys.length > 0 ? "{" + keys.join(": ..., ") + ": ...}" : "{}";
|
||||
error('A props object containing a "key" prop is being spread into JSX:\n let props = %s;\n <%s {...props} />\nReact keys must be passed directly to JSX without using spread:\n let props = %s;\n <%s key={someKey} {...props} />', beforeExample, componentName, afterExample, componentName);
|
||||
didWarnAboutKeySpread[componentName + beforeExample] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (type === REACT_FRAGMENT_TYPE) {
|
||||
validateFragmentProps(element);
|
||||
} else {
|
||||
validatePropTypes(element);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
}
|
||||
function jsxWithValidationStatic(type, props, key) {
|
||||
{
|
||||
return jsxWithValidation(type, props, key, true);
|
||||
}
|
||||
}
|
||||
function jsxWithValidationDynamic(type, props, key) {
|
||||
{
|
||||
return jsxWithValidation(type, props, key, false);
|
||||
}
|
||||
}
|
||||
var jsx = jsxWithValidationDynamic;
|
||||
var jsxs = jsxWithValidationStatic;
|
||||
exports.Fragment = REACT_FRAGMENT_TYPE;
|
||||
exports.jsx = jsx;
|
||||
exports.jsxs = jsxs;
|
||||
})();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// node_modules/react/jsx-runtime.js
|
||||
var require_jsx_runtime = __commonJS({
|
||||
"node_modules/react/jsx-runtime.js"(exports, module) {
|
||||
if (false) {
|
||||
module.exports = null;
|
||||
} else {
|
||||
module.exports = require_react_jsx_runtime_development();
|
||||
}
|
||||
}
|
||||
});
|
||||
require_jsx_runtime
|
||||
} from "./chunk-6PXSGDAH.js";
|
||||
import "./chunk-DRWLMN53.js";
|
||||
import "./chunk-G3PMV62Z.js";
|
||||
export default require_jsx_runtime();
|
||||
/*! Bundled license information:
|
||||
|
||||
react/cjs/react-jsx-runtime.development.js:
|
||||
(**
|
||||
* @license React
|
||||
* react-jsx-runtime.development.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*)
|
||||
*/
|
||||
//# sourceMappingURL=react_jsx-runtime.js.map
|
||||
|
||||
8
node_modules/.vite/deps/react_jsx-runtime.js.map
generated
vendored
8
node_modules/.vite/deps/react_jsx-runtime.js.map
generated
vendored
File diff suppressed because one or more lines are too long
142
package-lock.json
generated
142
package-lock.json
generated
@@ -8,12 +8,18 @@
|
||||
"name": "woms-react",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@tabler/icons-react": "^3.36.0",
|
||||
"appwrite": "^13.0.0",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^2.30.0",
|
||||
"motion": "^12.23.26",
|
||||
"postprocessing": "^6.38.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^4.12.0",
|
||||
"react-router-dom": "^6.20.0"
|
||||
"react-router-dom": "^6.20.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"three": "^0.182.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.0",
|
||||
@@ -1006,6 +1012,32 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@tabler/icons": {
|
||||
"version": "3.36.0",
|
||||
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.36.0.tgz",
|
||||
"integrity": "sha512-z9OfTEG6QbaQWM9KBOxxUdpgvMUn0atageXyiaSc2gmYm51ORO8Ua7eUcjlks+Dc0YMK4rrodAFdK9SfjJ4ZcA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/codecalm"
|
||||
}
|
||||
},
|
||||
"node_modules/@tabler/icons-react": {
|
||||
"version": "3.36.0",
|
||||
"resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.36.0.tgz",
|
||||
"integrity": "sha512-sSZ00bEjTdTTskVFykq294RJq+9cFatwy4uYa78HcYBCXU1kSD1DIp5yoFsQXmybkIOKCjp18OnhAYk553UIfQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tabler/icons": "3.36.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/codecalm"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -1186,6 +1218,15 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@@ -1380,6 +1421,33 @@
|
||||
"node": ">= 0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.23.26",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz",
|
||||
"integrity": "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.23.23",
|
||||
"motion-utils": "^12.23.6",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1577,6 +1645,47 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/motion": {
|
||||
"version": "12.23.26",
|
||||
"resolved": "https://registry.npmjs.org/motion/-/motion-12.23.26.tgz",
|
||||
"integrity": "sha512-Ll8XhVxY8LXMVYTCfme27WH2GjBrCIzY4+ndr5QKxsK+YwCtOi2B/oBi5jcIbik5doXuWT/4KKDOVAZJkeY5VQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"framer-motion": "^12.23.26",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.23.23",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
|
||||
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "12.23.6",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
|
||||
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -1660,6 +1769,15 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postprocessing": {
|
||||
"version": "6.38.2",
|
||||
"resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.38.2.tgz",
|
||||
"integrity": "sha512-7DwuT7Tkst41ZjSj287g7C9c5/D3Xx5rMgBosg0dadbUPoZD2HNzkadKPol1d2PJAoI9f+Jeh1/v9YfLzpFGVw==",
|
||||
"license": "Zlib",
|
||||
"peerDependencies": {
|
||||
"three": ">= 0.157.0 < 0.183.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
@@ -1816,11 +1934,33 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwind-merge": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
|
||||
"integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/dcastil"
|
||||
}
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.182.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz",
|
||||
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
|
||||
18
package.json
18
package.json
@@ -3,18 +3,24 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@tabler/icons-react": "^3.36.0",
|
||||
"appwrite": "^13.0.0",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^2.30.0",
|
||||
"motion": "^12.23.26",
|
||||
"postprocessing": "^6.38.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"appwrite": "^13.0.0",
|
||||
"react-icons": "^4.12.0",
|
||||
"date-fns": "^2.30.0"
|
||||
"react-router-dom": "^6.20.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"three": "^0.182.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^5.0.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0"
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"vite": "^5.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
69
src/App.jsx
69
src/App.jsx
@@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||
import Navbar from './components/Navbar'
|
||||
import Footer from './components/Footer'
|
||||
import PixelBlast from './components/PixelBlast'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import TicketsPage from './pages/TicketsPage'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
@@ -10,6 +11,7 @@ import PlanboardPage from './pages/PlanboardPage'
|
||||
import ProjectsPage from './pages/ProjectsPage'
|
||||
import ReportsPage from './pages/ReportsPage'
|
||||
import DocsPage from './pages/DocsPage'
|
||||
import AdminPage from './pages/AdminPage'
|
||||
|
||||
function ProtectedRoute({ children }) {
|
||||
const { user, loading } = useAuth()
|
||||
@@ -44,6 +46,7 @@ function AppRoutes() {
|
||||
<Route path="/projects" element={<ProtectedRoute><ProjectsPage /></ProtectedRoute>} />
|
||||
<Route path="/reports" element={<ProtectedRoute><ReportsPage /></ProtectedRoute>} />
|
||||
<Route path="/docs" element={<ProtectedRoute><DocsPage /></ProtectedRoute>} />
|
||||
<Route path="/admin" element={<ProtectedRoute><AdminPage /></ProtectedRoute>} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
@@ -51,11 +54,71 @@ function AppRoutes() {
|
||||
function AppContent() {
|
||||
const { user } = useAuth()
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<>
|
||||
{/* PixelBlast Background für Login-Seite */}
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
zIndex: 0,
|
||||
background: '#1a202c'
|
||||
}}>
|
||||
<PixelBlast
|
||||
color="#10b981"
|
||||
variant="circle"
|
||||
pixelSize={4}
|
||||
patternScale={2}
|
||||
patternDensity={0.8}
|
||||
enableRipples={true}
|
||||
rippleIntensityScale={1.5}
|
||||
speed={0.3}
|
||||
edgeFade={0.3}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
||||
<AppRoutes />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{user && <Navbar />}
|
||||
<AppRoutes />
|
||||
{user && <Footer />}
|
||||
{/* PixelBlast Background für Haupt-App */}
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
zIndex: 0,
|
||||
background: '#1a202c'
|
||||
}}>
|
||||
<PixelBlast
|
||||
color="#10b981"
|
||||
variant="circle"
|
||||
pixelSize={4}
|
||||
patternScale={2}
|
||||
patternDensity={0.8}
|
||||
enableRipples={true}
|
||||
rippleIntensityScale={1.5}
|
||||
speed={0.3}
|
||||
edgeFade={0.3}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ position: 'relative', zIndex: 1, display: 'flex', height: '100vh', overflow: 'hidden' }}>
|
||||
<Navbar />
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
<AppRoutes />
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,28 +1,31 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { FaTimes } from 'react-icons/fa'
|
||||
import { useAdminConfig } from '../hooks/useAdminConfig'
|
||||
import { useEmployees } from '../hooks/useEmployees'
|
||||
|
||||
const TICKET_TYPES = [
|
||||
// Fallback-Werte falls Config nicht geladen werden kann
|
||||
const DEFAULT_TICKET_TYPES = [
|
||||
'Home Office', 'Holidays', 'Trip', 'Supportrequest', 'Change Request',
|
||||
'Maintenance', 'Project', 'Controlling', 'Development', 'Documentation',
|
||||
'Meeting/Conference', 'IT Management', 'IT Security', 'Procurement',
|
||||
'Rollout', 'Emergency Call', 'Other Services'
|
||||
]
|
||||
|
||||
const SYSTEMS = [
|
||||
const DEFAULT_SYSTEMS = [
|
||||
'Account View', 'Client', 'Cofano', 'Credentials', 'Diamant', 'Docuware',
|
||||
'EDI', 'eMail', 'Employee', 'Invoice', 'LBase', 'Medical Office', 'Network',
|
||||
'O365', 'PDF Viewer', 'Printer', 'Reports', 'Server', 'Time Tracking',
|
||||
'TK', 'TOS', 'Vivendi NG', 'VGM', '(W)LAN', '(W)WAN', 'WOMS', 'n/a'
|
||||
]
|
||||
|
||||
const RESPONSE_LEVELS = [
|
||||
const DEFAULT_RESPONSE_LEVELS = [
|
||||
'USER', 'KEY USER', 'Helpdesk', 'Support', 'Admin', 'FS/FE', '24/7',
|
||||
'TECH MGMT', 'Backoffice', 'BUSI MGMT', 'n/a'
|
||||
]
|
||||
|
||||
const SERVICE_TYPES = ['Remote', 'On Site', 'Off Site']
|
||||
const DEFAULT_SERVICE_TYPES = ['Remote', 'On Site', 'Off Site']
|
||||
|
||||
const PRIORITIES = [
|
||||
const DEFAULT_PRIORITIES = [
|
||||
{ value: 0, label: 'None' },
|
||||
{ value: 1, label: 'Low' },
|
||||
{ value: 2, label: 'Medium' },
|
||||
@@ -33,16 +36,27 @@ const PRIORITIES = [
|
||||
const today = new Date().toLocaleDateString('de-DE')
|
||||
|
||||
export default function CreateTicketModal({ isOpen, onClose, onCreate, customers = [] }) {
|
||||
const { config } = useAdminConfig()
|
||||
const { employees } = useEmployees()
|
||||
|
||||
// Verwende Config-Werte oder Fallbacks
|
||||
const TICKET_TYPES = config?.ticketTypes || DEFAULT_TICKET_TYPES
|
||||
const SYSTEMS = config?.systems || DEFAULT_SYSTEMS
|
||||
const RESPONSE_LEVELS = config?.responseLevels || DEFAULT_RESPONSE_LEVELS
|
||||
const SERVICE_TYPES = config?.serviceTypes || DEFAULT_SERVICE_TYPES
|
||||
const PRIORITIES = config?.priorities || DEFAULT_PRIORITIES
|
||||
const [formData, setFormData] = useState({
|
||||
customerId: '',
|
||||
type: 'Supportrequest',
|
||||
type: '',
|
||||
systemType: '',
|
||||
responseLevel: '',
|
||||
serviceType: 'Remote',
|
||||
serviceType: '',
|
||||
priority: 1,
|
||||
topic: '',
|
||||
requestedBy: '',
|
||||
requestedFor: '',
|
||||
assignedTo: '', // Zugewiesener Mitarbeiter (User ID)
|
||||
status: 'Open', // Status wird automatisch gesetzt basierend auf assignedTo
|
||||
startDate: today,
|
||||
startTime: '',
|
||||
deadline: today,
|
||||
@@ -54,39 +68,64 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
|
||||
})
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Setze Default-Werte wenn Config geladen ist oder Modal geöffnet wird
|
||||
useEffect(() => {
|
||||
if (isOpen && (TICKET_TYPES.length > 0 || SERVICE_TYPES.length > 0)) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
type: prev.type || TICKET_TYPES[0] || 'Supportrequest',
|
||||
serviceType: prev.serviceType || SERVICE_TYPES[0] || 'Remote',
|
||||
priority: prev.priority || (PRIORITIES.find(p => p.value === 1)?.value || 1)
|
||||
}))
|
||||
}
|
||||
// Reset error when modal opens
|
||||
if (isOpen) {
|
||||
setError('')
|
||||
}
|
||||
}, [isOpen, TICKET_TYPES, SERVICE_TYPES, PRIORITIES])
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }))
|
||||
// Clear error when user makes changes
|
||||
if (error) setError('')
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
await onCreate(formData)
|
||||
onClose()
|
||||
setFormData({
|
||||
customerId: '',
|
||||
type: 'Supportrequest',
|
||||
systemType: '',
|
||||
responseLevel: '',
|
||||
serviceType: 'Remote',
|
||||
priority: 1,
|
||||
topic: '',
|
||||
requestedBy: '',
|
||||
requestedFor: '',
|
||||
startDate: today,
|
||||
startTime: '',
|
||||
deadline: today,
|
||||
endTime: '',
|
||||
estimate: '30',
|
||||
mailCopyTo: '',
|
||||
sendNotification: false,
|
||||
details: ''
|
||||
})
|
||||
const result = await onCreate(formData)
|
||||
if (result.success) {
|
||||
onClose()
|
||||
setFormData({
|
||||
customerId: '',
|
||||
type: TICKET_TYPES[0] || 'Supportrequest',
|
||||
systemType: '',
|
||||
responseLevel: '',
|
||||
serviceType: SERVICE_TYPES[0] || 'Remote',
|
||||
priority: PRIORITIES.find(p => p.value === 1)?.value || 1,
|
||||
topic: '',
|
||||
requestedBy: '',
|
||||
requestedFor: '',
|
||||
startDate: today,
|
||||
startTime: '',
|
||||
deadline: today,
|
||||
endTime: '',
|
||||
estimate: '30',
|
||||
mailCopyTo: '',
|
||||
sendNotification: false,
|
||||
details: ''
|
||||
})
|
||||
} else {
|
||||
setError(result.error || 'Fehler beim Erstellen des Tickets')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating ticket:', error)
|
||||
setError(error.message || 'Ein unerwarteter Fehler ist aufgetreten')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -102,6 +141,12 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
|
||||
<div className="overlay-content">
|
||||
<h2 className="mb-2">Create New Ticket</h2>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="row">
|
||||
<div className="col col-6">
|
||||
@@ -115,7 +160,7 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
|
||||
>
|
||||
<option value="">Affected Customer</option>
|
||||
{customers.map(c => (
|
||||
<option key={c.id} value={c.id}>({c.code}) {c.name}</option>
|
||||
<option key={c.$id} value={c.$id}>({c.code || ''}) {c.name || 'Unnamed'}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
@@ -189,6 +234,32 @@ export default function CreateTicketModal({ isOpen, onClose, onCreate, customers
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Assigned To</label>
|
||||
<select
|
||||
className="form-control"
|
||||
value={formData.assignedTo}
|
||||
onChange={(e) => {
|
||||
const userId = e.target.value
|
||||
handleChange('assignedTo', userId)
|
||||
// Status-Automatik: Wenn Mitarbeiter zugewiesen → Status = "Assigned"
|
||||
// Wenn kein Mitarbeiter → Status = "Open"
|
||||
if (userId) {
|
||||
handleChange('status', 'Assigned')
|
||||
} else {
|
||||
handleChange('status', 'Open')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
{employees.map(emp => (
|
||||
<option key={emp.$id} value={emp.userId}>
|
||||
{emp.displayName}{emp.shortcode ? ` (${emp.shortcode})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col col-6">
|
||||
|
||||
440
src/components/CreateWorksheetModal.jsx
Normal file
440
src/components/CreateWorksheetModal.jsx
Normal file
@@ -0,0 +1,440 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
const SERVICE_TYPES = ['Remote', 'On Site', 'Off Site', 'COMMENT']
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
'Open',
|
||||
'Closed',
|
||||
'Awaiting',
|
||||
'Added Info',
|
||||
'Occupied',
|
||||
'Halted',
|
||||
'Cancelled',
|
||||
'Aborted',
|
||||
'Assigned',
|
||||
'In Test'
|
||||
]
|
||||
|
||||
const RESPONSE_LEVELS = [
|
||||
'KEY USER',
|
||||
'1st Level',
|
||||
'2nd Level',
|
||||
'3rd Level',
|
||||
'FS/FE',
|
||||
'24/7',
|
||||
'TECH MGMT',
|
||||
'Backoffice',
|
||||
'BUSI MGMT',
|
||||
'n/a'
|
||||
]
|
||||
|
||||
export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCreate }) {
|
||||
const { user } = useAuth()
|
||||
const today = new Date().toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
})
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
serviceType: 'Remote',
|
||||
newStatus: workorder?.status || 'Open',
|
||||
newResponseLevel: workorder?.responseLevel || '',
|
||||
totalTime: 0,
|
||||
startDate: today,
|
||||
startTime: '',
|
||||
endDate: today,
|
||||
endTime: '',
|
||||
details: '',
|
||||
isComment: false
|
||||
})
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [autoCalculate, setAutoCalculate] = useState(true)
|
||||
|
||||
// Reset form wenn Modal geöffnet wird
|
||||
useEffect(() => {
|
||||
if (isOpen && workorder) {
|
||||
setFormData({
|
||||
serviceType: 'Remote',
|
||||
newStatus: workorder.status || 'Open',
|
||||
newResponseLevel: workorder.responseLevel || '',
|
||||
totalTime: 0,
|
||||
startDate: today,
|
||||
startTime: '',
|
||||
endDate: today,
|
||||
endTime: '',
|
||||
details: '',
|
||||
isComment: false
|
||||
})
|
||||
setError('')
|
||||
setAutoCalculate(true)
|
||||
}
|
||||
}, [isOpen, workorder, today])
|
||||
|
||||
// Automatische Zeitberechnung
|
||||
useEffect(() => {
|
||||
if (autoCalculate && formData.startTime && formData.endTime && !formData.isComment) {
|
||||
try {
|
||||
const startHour = parseInt(formData.startTime.substring(0, 2))
|
||||
const startMin = parseInt(formData.startTime.substring(2, 4))
|
||||
const endHour = parseInt(formData.endTime.substring(0, 2))
|
||||
const endMin = parseInt(formData.endTime.substring(2, 4))
|
||||
|
||||
if (!isNaN(startHour) && !isNaN(startMin) && !isNaN(endHour) && !isNaN(endMin)) {
|
||||
const startTotal = startHour * 60 + startMin
|
||||
const endTotal = endHour * 60 + endMin
|
||||
|
||||
let diff = endTotal - startTotal
|
||||
if (diff < 0) {
|
||||
diff += 24 * 60 // Overnight
|
||||
}
|
||||
|
||||
setFormData(prev => ({ ...prev, totalTime: diff }))
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignoriere Fehler
|
||||
}
|
||||
}
|
||||
}, [formData.startTime, formData.endTime, formData.isComment, autoCalculate])
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }))
|
||||
|
||||
// Wenn totalTime manuell geändert wird, deaktiviere Auto-Berechnung
|
||||
if (field === 'totalTime') {
|
||||
setAutoCalculate(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
if (!formData.details.trim()) {
|
||||
setError('Bitte Details eingeben')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const worksheetData = {
|
||||
woid: workorder.woid,
|
||||
workorderId: workorder.$id,
|
||||
serviceType: formData.serviceType,
|
||||
oldStatus: workorder.status,
|
||||
newStatus: formData.newStatus,
|
||||
oldResponseLevel: workorder.responseLevel || '',
|
||||
newResponseLevel: formData.newResponseLevel,
|
||||
totalTime: formData.isComment ? 0 : parseInt(formData.totalTime) || 0,
|
||||
startDate: formData.startDate,
|
||||
startTime: formData.startTime,
|
||||
endDate: formData.endDate,
|
||||
endTime: formData.endTime,
|
||||
details: formData.details,
|
||||
isComment: formData.isComment,
|
||||
employeeShort: user?.prefs?.shortCode || '' // Aus User-Preferences
|
||||
}
|
||||
|
||||
const result = await onCreate(worksheetData, user)
|
||||
|
||||
if (result.success) {
|
||||
onClose()
|
||||
} else {
|
||||
setError(result.error || 'Fehler beim Erstellen des Worksheets')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error creating worksheet:', err)
|
||||
setError(err.message || 'Ein unerwarteter Fehler ist aufgetreten')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen || !workorder) return null
|
||||
|
||||
return (
|
||||
<div className="overlay" style={{
|
||||
width: '100%',
|
||||
background: 'rgba(0,0,0,0.95)'
|
||||
}}>
|
||||
<a href="#" className="closebtn" onClick={(e) => { e.preventDefault(); onClose(); }} style={{
|
||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
borderRadius: '50%',
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '2rem',
|
||||
transition: 'transform 0.2s ease'
|
||||
}} onMouseEnter={(e) => e.currentTarget.style.transform = 'rotate(90deg)'} onMouseLeave={(e) => e.currentTarget.style.transform = 'rotate(0deg)'}>×</a>
|
||||
|
||||
<div className="overlay-content text-white text-left">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-1"> </div>
|
||||
<div className="col-10">
|
||||
<div className="mb-4 p-4 rounded-3" style={{
|
||||
background: 'linear-gradient(135deg, #2d3748 0%, #1a202c 100%)',
|
||||
boxShadow: '0 8px 32px rgba(45, 55, 72, 0.3)'
|
||||
}}>
|
||||
<h2 className="mb-0 d-flex align-items-center">
|
||||
<span className="me-3" style={{
|
||||
background: 'rgba(16, 185, 129, 0.4)',
|
||||
borderRadius: '10px',
|
||||
padding: '10px 15px'
|
||||
}}>📝</span>
|
||||
Create New Worksheet
|
||||
<span className="ms-3 badge" style={{
|
||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
fontSize: '1rem'
|
||||
}}>WOID {workorder.woid}</span>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-1"> </div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-1"> </div>
|
||||
<div className="col-10">
|
||||
<div className="alert p-4 rounded-3 border-0" style={{
|
||||
background: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
|
||||
color: 'white',
|
||||
boxShadow: '0 4px 16px rgba(239, 68, 68, 0.3)'
|
||||
}} role="alert">
|
||||
<strong>⚠️ {error}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-1"> </div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-1"> </div>
|
||||
|
||||
{/* Linke Spalte */}
|
||||
<div className="col-5">
|
||||
<span className="text-left">Service Type</span><br />
|
||||
<select
|
||||
className="form-select bg-dark text-white"
|
||||
value={formData.serviceType}
|
||||
onChange={(e) => handleChange('serviceType', e.target.value)}
|
||||
required
|
||||
>
|
||||
{SERVICE_TYPES.map(type => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
))}
|
||||
</select>
|
||||
<br /><br />
|
||||
|
||||
<span className="text-left">New Status</span><br />
|
||||
<select
|
||||
className="form-select bg-dark text-white"
|
||||
value={formData.newStatus}
|
||||
onChange={(e) => handleChange('newStatus', e.target.value)}
|
||||
required
|
||||
>
|
||||
{STATUS_OPTIONS.map(status => (
|
||||
<option key={status} value={status}>{status}</option>
|
||||
))}
|
||||
</select>
|
||||
<br /><br />
|
||||
|
||||
<span className="text-left">New Response Level</span><br />
|
||||
<select
|
||||
className="form-select bg-dark text-white"
|
||||
value={formData.newResponseLevel}
|
||||
onChange={(e) => handleChange('newResponseLevel', e.target.value)}
|
||||
>
|
||||
<option value="">Select</option>
|
||||
{RESPONSE_LEVELS.map(level => (
|
||||
<option key={level} value={level}>{level}</option>
|
||||
))}
|
||||
</select>
|
||||
<br /><br />
|
||||
|
||||
<div className="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
id="isComment"
|
||||
checked={formData.isComment}
|
||||
onChange={(e) => handleChange('isComment', e.target.checked)}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="isComment">
|
||||
Nur Kommentar (keine Arbeitszeit)
|
||||
</label>
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
|
||||
{/* Rechte Spalte */}
|
||||
<div className="col-5">
|
||||
<span className="text-left">Total Time (Minuten)</span><br />
|
||||
<input
|
||||
type="number"
|
||||
className="form-control bg-dark text-white"
|
||||
min="0"
|
||||
step="15"
|
||||
value={formData.totalTime}
|
||||
onChange={(e) => handleChange('totalTime', e.target.value)}
|
||||
disabled={formData.isComment}
|
||||
placeholder="0"
|
||||
/>
|
||||
<small className="text-muted">
|
||||
{autoCalculate && formData.startTime && formData.endTime
|
||||
? '✓ Automatisch berechnet'
|
||||
: 'Manuell eingeben'}
|
||||
</small>
|
||||
<br /><br />
|
||||
|
||||
<span className="text-left">Start Date (dd.mm.yyyy)</span><br />
|
||||
<input
|
||||
type="text"
|
||||
className="form-control bg-dark text-white"
|
||||
value={formData.startDate}
|
||||
onChange={(e) => handleChange('startDate', e.target.value)}
|
||||
pattern="^[0-3][0-9]\.[0-1][0-9]\.[1-2][0-9][0-9][0-9]$"
|
||||
required
|
||||
/>
|
||||
<br /><br />
|
||||
|
||||
<span className="text-left">End Date (dd.mm.yyyy)</span><br />
|
||||
<input
|
||||
type="text"
|
||||
className="form-control bg-dark text-white"
|
||||
value={formData.endDate}
|
||||
onChange={(e) => handleChange('endDate', e.target.value)}
|
||||
pattern="^[0-3][0-9]\.[0-1][0-9]\.[1-2][0-9][0-9][0-9]$"
|
||||
required
|
||||
/>
|
||||
<br /><br />
|
||||
|
||||
<span className="text-left">Start Time (hhmm)</span><br />
|
||||
<input
|
||||
type="text"
|
||||
className="form-control bg-dark text-white"
|
||||
value={formData.startTime}
|
||||
onChange={(e) => handleChange('startTime', e.target.value)}
|
||||
pattern="[0-2][0-9][0-5][0-9]"
|
||||
placeholder="1000"
|
||||
maxLength="4"
|
||||
/>
|
||||
<br /><br />
|
||||
|
||||
<span className="text-left">End Time (hhmm)</span><br />
|
||||
<input
|
||||
type="text"
|
||||
className="form-control bg-dark text-white"
|
||||
value={formData.endTime}
|
||||
onChange={(e) => handleChange('endTime', e.target.value)}
|
||||
pattern="[0-2][0-9][0-5][0-9]"
|
||||
placeholder="1030"
|
||||
maxLength="4"
|
||||
/>
|
||||
<br /><br />
|
||||
</div>
|
||||
|
||||
<div className="col-1"> </div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-1"> </div>
|
||||
<div className="col-10">
|
||||
<span className="text-left">Action Details</span><br />
|
||||
<textarea
|
||||
className="form-control bg-dark text-white"
|
||||
rows="10"
|
||||
value={formData.details}
|
||||
onChange={(e) => handleChange('details', e.target.value)}
|
||||
placeholder="Beschreibe die durchgeführten Arbeiten..."
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
<div className="col-1"> </div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-1"> </div>
|
||||
<div className="col-10 text-center">
|
||||
<p> </p>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-lg px-5 py-3 border-0"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
color: 'white',
|
||||
fontSize: '1.2rem',
|
||||
fontWeight: 'bold',
|
||||
boxShadow: '0 8px 32px rgba(16, 185, 129, 0.4)',
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||
e.currentTarget.style.boxShadow = '0 12px 40px rgba(16, 185, 129, 0.5)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
e.currentTarget.style.boxShadow = '0 8px 32px rgba(16, 185, 129, 0.4)'
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '⏳ Erstelle...' : '✨ CREATE NOW'}
|
||||
</button>
|
||||
<p> </p>
|
||||
</div>
|
||||
<div className="col-1"> </div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-1"> </div>
|
||||
<div className="col-10">
|
||||
<div className="p-4 rounded-3 border-0" style={{
|
||||
background: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
|
||||
color: 'white',
|
||||
boxShadow: '0 4px 16px rgba(74, 85, 104, 0.3)'
|
||||
}} role="alert">
|
||||
<strong className="d-block mb-2">📋 Current Work Order</strong>
|
||||
<div className="d-flex flex-wrap gap-3">
|
||||
<span className="badge px-3 py-2" style={{
|
||||
background: 'rgba(16, 185, 129, 0.4)',
|
||||
fontSize: '0.9rem'
|
||||
}}>WOID: {workorder.woid}</span>
|
||||
<span className="badge px-3 py-2" style={{
|
||||
background: 'rgba(16, 185, 129, 0.4)',
|
||||
fontSize: '0.9rem'
|
||||
}}>Status: {workorder.status}</span>
|
||||
<span className="badge px-3 py-2" style={{
|
||||
background: 'rgba(16, 185, 129, 0.4)',
|
||||
fontSize: '0.9rem'
|
||||
}}>Topic: {workorder.topic}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-1"> </div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,44 +1,34 @@
|
||||
import { FaUserPlus } from 'react-icons/fa6'
|
||||
|
||||
// Diese Liste sollte aus der Datenbank kommen
|
||||
const EDITORS = [
|
||||
{ id: 'CHLE', name: 'Christian Lehmann' },
|
||||
{ id: 'DIBR', name: 'Dietmar Bruckauf' },
|
||||
{ id: 'DOAR', name: 'Dominik Armata' },
|
||||
{ id: 'GRVO', name: 'Gregor Vowinkel' },
|
||||
{ id: 'HADW', name: 'Hasan Dwiko' },
|
||||
{ id: 'JEDI', name: 'Jessica Diaz' },
|
||||
{ id: 'KNSO', name: 'Kenso Grimm' },
|
||||
{ id: 'LUPL', name: 'Lukas Placzek' },
|
||||
{ id: 'NIKI', name: 'Nikita Gaidach' },
|
||||
{ id: 'MARK', name: 'Marco Kobza' },
|
||||
{ id: 'MABA', name: 'Markus Bauer' },
|
||||
{ id: 'MATS', name: 'Maksim Tschetschjotkin' },
|
||||
{ id: 'PASI', name: 'Pascal Siegfried' },
|
||||
{ id: 'NICT', name: 'Nico Stegmann' },
|
||||
{ id: 'MEQU', name: 'Melissa Quednau' },
|
||||
{ id: 'SASC', name: 'Saskia Schmahl' },
|
||||
{ id: 'CHPA', name: 'Christin Paulus' },
|
||||
{ id: 'SOSC', name: 'Sonja Schulze' },
|
||||
{ id: 'WAWA', name: 'Walter Wawer' },
|
||||
{ id: 'YAFO', name: 'Yannick Föller' },
|
||||
{ id: 'TODE', name: 'Tobias Decker' }
|
||||
]
|
||||
import { useEmployees } from '../hooks/useEmployees'
|
||||
|
||||
export default function EditorDropdown({ value, onChange }) {
|
||||
const { employees } = useEmployees()
|
||||
|
||||
// Finde den zugewiesenen Mitarbeiter anhand der userId
|
||||
const assignedEmployee = employees.find(emp => emp.userId === value)
|
||||
|
||||
// Zeige Kürzel wenn zugewiesen, sonst Icon
|
||||
const displayValue = assignedEmployee?.shortcode || <FaUserPlus size={20} />
|
||||
|
||||
return (
|
||||
<div className="dropdown">
|
||||
<button className="btn" style={{ background: 'inherit', color: 'inherit' }}>
|
||||
{value || <FaUserPlus size={20} />}
|
||||
{displayValue}
|
||||
</button>
|
||||
<div className="dropdown-content">
|
||||
{EDITORS.map(editor => (
|
||||
<span
|
||||
className="dropdown-item"
|
||||
onClick={() => onChange('')}
|
||||
>
|
||||
<em>(Unassigned)</em>
|
||||
</span>
|
||||
{employees.map(employee => (
|
||||
<span
|
||||
key={editor.id}
|
||||
key={employee.$id}
|
||||
className="dropdown-item"
|
||||
onClick={() => onChange(editor.id)}
|
||||
onClick={() => onChange(employee.userId)}
|
||||
>
|
||||
{editor.name}
|
||||
{employee.displayName}{employee.shortcode ? ` (${employee.shortcode})` : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
138
src/components/ModernSidebar.css
Normal file
138
src/components/ModernSidebar.css
Normal file
@@ -0,0 +1,138 @@
|
||||
/* Modern Sidebar Styles */
|
||||
|
||||
.modern-sidebar-desktop {
|
||||
height: 100vh;
|
||||
padding: 1rem;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
background: var(--sidebar-bg, #f5f5f5);
|
||||
border-right: 1px solid var(--border-color, #e0e0e0);
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.modern-sidebar-desktop {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile Sidebar */
|
||||
.modern-sidebar-mobile-header {
|
||||
height: 60px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--sidebar-bg, #f5f5f5);
|
||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.modern-sidebar-mobile-header {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.modern-sidebar-mobile-toggle {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
color: var(--text-color, #333);
|
||||
}
|
||||
|
||||
.modern-sidebar-mobile-toggle svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.modern-sidebar-mobile-menu {
|
||||
position: fixed;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
inset: 0;
|
||||
background: var(--sidebar-bg, #fff);
|
||||
padding: 2.5rem;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.modern-sidebar-mobile-close {
|
||||
position: absolute;
|
||||
right: 2.5rem;
|
||||
top: 2.5rem;
|
||||
z-index: 50;
|
||||
cursor: pointer;
|
||||
color: var(--text-color, #333);
|
||||
}
|
||||
|
||||
.modern-sidebar-mobile-close svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* Sidebar Link */
|
||||
.modern-sidebar-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
color: var(--text-color, #333);
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.modern-sidebar-link:hover {
|
||||
background: var(--hover-bg, rgba(0, 0, 0, 0.05));
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.modern-sidebar-link-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modern-sidebar-link-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.modern-sidebar-link-label {
|
||||
font-size: 0.875rem;
|
||||
white-space: pre;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.modern-sidebar-desktop,
|
||||
.modern-sidebar-mobile-header,
|
||||
.modern-sidebar-mobile-menu {
|
||||
background: var(--dark-sidebar-bg, #1a1a1a);
|
||||
border-color: var(--dark-border-color, #333);
|
||||
}
|
||||
|
||||
.modern-sidebar-link {
|
||||
color: var(--dark-text-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.modern-sidebar-link:hover {
|
||||
background: var(--dark-hover-bg, rgba(255, 255, 255, 0.1));
|
||||
}
|
||||
|
||||
.modern-sidebar-mobile-toggle,
|
||||
.modern-sidebar-mobile-close {
|
||||
color: var(--dark-text-color, #e0e0e0);
|
||||
}
|
||||
}
|
||||
|
||||
116
src/components/ModernSidebar.jsx
Normal file
116
src/components/ModernSidebar.jsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useState, createContext, useContext } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { IconMenu2, IconX } from '@tabler/icons-react'
|
||||
import { cn } from '../lib/utils'
|
||||
import './ModernSidebar.css'
|
||||
|
||||
const SidebarContext = createContext(undefined)
|
||||
|
||||
export const useSidebar = () => {
|
||||
const context = useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error('useSidebar must be used within a SidebarProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export const SidebarProvider = ({ children, open: openProp, setOpen: setOpenProp, animate = true }) => {
|
||||
const [openState, setOpenState] = useState(false)
|
||||
const open = openProp !== undefined ? openProp : openState
|
||||
const setOpen = setOpenProp !== undefined ? setOpenProp : setOpenState
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={{ open, setOpen, animate }}>
|
||||
{children}
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const Sidebar = ({ children, open, setOpen, animate }) => {
|
||||
return (
|
||||
<SidebarProvider open={open} setOpen={setOpen} animate={animate}>
|
||||
{children}
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export const SidebarBody = ({ className, children, ...props }) => {
|
||||
return (
|
||||
<>
|
||||
<DesktopSidebar className={className} {...props}>{children}</DesktopSidebar>
|
||||
<MobileSidebar className={className}>{children}</MobileSidebar>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const DesktopSidebar = ({ className, children, ...props }) => {
|
||||
const { open, setOpen, animate } = useSidebar()
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={cn('modern-sidebar-desktop', className)}
|
||||
animate={{
|
||||
width: animate ? (open ? '300px' : '60px') : '300px',
|
||||
}}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export const MobileSidebar = ({ className, children }) => {
|
||||
const { open, setOpen } = useSidebar()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="modern-sidebar-mobile-header">
|
||||
<div className="modern-sidebar-mobile-toggle">
|
||||
<IconMenu2 onClick={() => setOpen(!open)} />
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ x: '-100%', opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: '-100%', opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
className={cn('modern-sidebar-mobile-menu', className)}
|
||||
>
|
||||
<div className="modern-sidebar-mobile-close" onClick={() => setOpen(!open)}>
|
||||
<IconX />
|
||||
</div>
|
||||
{children}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const SidebarLink = ({ link, className, ...props }) => {
|
||||
const { open, animate } = useSidebar()
|
||||
|
||||
return (
|
||||
<a
|
||||
href={link.href}
|
||||
className={cn('modern-sidebar-link', className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="modern-sidebar-link-icon">{link.icon}</span>
|
||||
<motion.span
|
||||
animate={{
|
||||
display: animate ? (open ? 'inline-block' : 'none') : 'inline-block',
|
||||
opacity: animate ? (open ? 1 : 0) : 1,
|
||||
}}
|
||||
className="modern-sidebar-link-label"
|
||||
>
|
||||
{link.label}
|
||||
</motion.span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,49 +1,174 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { FaUser } from 'react-icons/fa'
|
||||
import { motion } from 'motion/react'
|
||||
import {
|
||||
IconTicket,
|
||||
IconDeviceDesktop,
|
||||
IconChartBar,
|
||||
IconCalendar,
|
||||
IconFolders,
|
||||
IconFileText,
|
||||
IconBook,
|
||||
IconSettings,
|
||||
IconLogout
|
||||
} from '@tabler/icons-react'
|
||||
import { Sidebar, SidebarBody, SidebarLink } from './ModernSidebar'
|
||||
|
||||
export default function Navbar() {
|
||||
const { user, logout } = useAuth()
|
||||
const { user, logout, isAdmin } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="navbar">
|
||||
<div className="navbar-brand">
|
||||
<span>NetWEB</span>
|
||||
<img src="/logo.png" alt="" height="18" width="18" />
|
||||
<span>Systems</span>
|
||||
</div>
|
||||
|
||||
<ul className="navbar-nav">
|
||||
<li><Link to="/tickets" className="nav-link">Tickets</Link></li>
|
||||
<li><Link to="/assets" className="nav-link">Assets</Link></li>
|
||||
<li><Link to="/dashboard" className="nav-link">Dashboard</Link></li>
|
||||
<li><Link to="/planboard" className="nav-link">Planboard</Link></li>
|
||||
<li><Link to="/projects" className="nav-link">Projects</Link></li>
|
||||
<li><Link to="/reports" className="nav-link">Reports</Link></li>
|
||||
<li><Link to="/docs" className="nav-link">Docs</Link></li>
|
||||
</ul>
|
||||
const links = [
|
||||
{
|
||||
label: 'Tickets',
|
||||
href: '/tickets',
|
||||
icon: <IconTicket className="icon" />,
|
||||
},
|
||||
{
|
||||
label: 'Assets',
|
||||
href: '/assets',
|
||||
icon: <IconDeviceDesktop className="icon" />,
|
||||
},
|
||||
{
|
||||
label: 'Dashboard',
|
||||
href: '/dashboard',
|
||||
icon: <IconChartBar className="icon" />,
|
||||
},
|
||||
{
|
||||
label: 'Planboard',
|
||||
href: '/planboard',
|
||||
icon: <IconCalendar className="icon" />,
|
||||
},
|
||||
{
|
||||
label: 'Projects',
|
||||
href: '/projects',
|
||||
icon: <IconFolders className="icon" />,
|
||||
},
|
||||
{
|
||||
label: 'Reports',
|
||||
href: '/reports',
|
||||
icon: <IconFileText className="icon" />,
|
||||
},
|
||||
{
|
||||
label: 'Docs',
|
||||
href: '/docs',
|
||||
icon: <IconBook className="icon" />,
|
||||
},
|
||||
]
|
||||
|
||||
<div className="nav-right">
|
||||
{user ? (
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="nav-link"
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
<FaUser /> Logout, {user.name || user.email}
|
||||
</button>
|
||||
) : (
|
||||
<Link to="/login" className="nav-link">
|
||||
<FaUser /> Login
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
// Admin link nur wenn isAdmin
|
||||
if (isAdmin) {
|
||||
links.push({
|
||||
label: 'Admin',
|
||||
href: '/admin',
|
||||
icon: <IconSettings className="icon" />,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar open={open} setOpen={setOpen}>
|
||||
<SidebarBody className="justify-between gap-10">
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflowX: 'hidden', overflowY: 'auto' }}>
|
||||
<Logo />
|
||||
<div style={{ marginTop: '2rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
{links.map((link, idx) => (
|
||||
<SidebarLink key={idx} link={link} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{user ? (
|
||||
<>
|
||||
<SidebarLink
|
||||
link={{
|
||||
label: user.name || user.email,
|
||||
href: '#',
|
||||
icon: (
|
||||
<div style={{
|
||||
height: '28px',
|
||||
width: '28px',
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{(user.name || user.email).charAt(0).toUpperCase()}
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<div onClick={handleLogout} style={{ cursor: 'pointer', marginTop: '0.5rem' }}>
|
||||
<SidebarLink
|
||||
link={{
|
||||
label: 'Logout',
|
||||
href: '#',
|
||||
icon: <IconLogout className="icon" />,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<SidebarLink
|
||||
link={{
|
||||
label: 'Login',
|
||||
href: '/login',
|
||||
icon: <IconLogout className="icon" />,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SidebarBody>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
|
||||
const Logo = () => {
|
||||
return (
|
||||
<Link
|
||||
to="/"
|
||||
style={{
|
||||
position: 'relative',
|
||||
zIndex: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.25rem 0',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'normal',
|
||||
color: 'var(--text-color, #333)',
|
||||
textDecoration: 'none'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
height: '20px',
|
||||
width: '24px',
|
||||
flexShrink: 0,
|
||||
borderRadius: '4px 2px 4px 2px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
|
||||
}} />
|
||||
<motion.span
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
whiteSpace: 'pre',
|
||||
color: 'var(--text-color, #333)'
|
||||
}}
|
||||
>
|
||||
Webklar
|
||||
</motion.span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
7
src/components/PixelBlast.css
Normal file
7
src/components/PixelBlast.css
Normal file
@@ -0,0 +1,7 @@
|
||||
.pixel-blast-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
674
src/components/PixelBlast.jsx
Normal file
674
src/components/PixelBlast.jsx
Normal file
@@ -0,0 +1,674 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { EffectComposer, EffectPass, RenderPass, Effect } from 'postprocessing';
|
||||
import './PixelBlast.css';
|
||||
|
||||
const createTouchTexture = () => {
|
||||
const size = 64;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('2D context not available');
|
||||
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const texture = new THREE.Texture(canvas);
|
||||
texture.minFilter = THREE.LinearFilter;
|
||||
texture.magFilter = THREE.LinearFilter;
|
||||
texture.generateMipmaps = false;
|
||||
|
||||
const trail = [];
|
||||
let last = null;
|
||||
const maxAge = 64;
|
||||
let radius = 0.1 * size;
|
||||
const speed = 1 / maxAge;
|
||||
|
||||
const clear = () => {
|
||||
ctx.fillStyle = 'black';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
};
|
||||
|
||||
const drawPoint = p => {
|
||||
const pos = { x: p.x * size, y: (1 - p.y) * size };
|
||||
let intensity = 1;
|
||||
|
||||
const easeOutSine = t => Math.sin((t * Math.PI) / 2);
|
||||
const easeOutQuad = t => -t * (t - 2);
|
||||
|
||||
if (p.age < maxAge * 0.3) intensity = easeOutSine(p.age / (maxAge * 0.3));
|
||||
else intensity = easeOutQuad(1 - (p.age - maxAge * 0.3) / (maxAge * 0.7)) || 0;
|
||||
|
||||
intensity *= p.force;
|
||||
|
||||
const color = `${((p.vx + 1) / 2) * 255}, ${((p.vy + 1) / 2) * 255}, ${intensity * 255}`;
|
||||
const offset = size * 5;
|
||||
ctx.shadowOffsetX = offset;
|
||||
ctx.shadowOffsetY = offset;
|
||||
ctx.shadowBlur = radius;
|
||||
ctx.shadowColor = `rgba(${color},${0.22 * intensity})`;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = 'rgba(255,0,0,1)';
|
||||
ctx.arc(pos.x - offset, pos.y - offset, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
};
|
||||
|
||||
const addTouch = norm => {
|
||||
let force = 0;
|
||||
let vx = 0;
|
||||
let vy = 0;
|
||||
|
||||
if (last) {
|
||||
const dx = norm.x - last.x;
|
||||
const dy = norm.y - last.y;
|
||||
if (dx === 0 && dy === 0) return;
|
||||
|
||||
const dd = dx * dx + dy * dy;
|
||||
const d = Math.sqrt(dd);
|
||||
vx = dx / (d || 1);
|
||||
vy = dy / (d || 1);
|
||||
force = Math.min(dd * 10000, 1);
|
||||
}
|
||||
|
||||
last = { x: norm.x, y: norm.y };
|
||||
trail.push({ x: norm.x, y: norm.y, age: 0, force, vx, vy });
|
||||
};
|
||||
|
||||
const update = () => {
|
||||
clear();
|
||||
for (let i = trail.length - 1; i >= 0; i--) {
|
||||
const point = trail[i];
|
||||
const f = point.force * speed * (1 - point.age / maxAge);
|
||||
point.x += point.vx * f;
|
||||
point.y += point.vy * f;
|
||||
point.age++;
|
||||
if (point.age > maxAge) trail.splice(i, 1);
|
||||
}
|
||||
for (let i = 0; i < trail.length; i++) drawPoint(trail[i]);
|
||||
texture.needsUpdate = true;
|
||||
};
|
||||
|
||||
return {
|
||||
canvas,
|
||||
texture,
|
||||
addTouch,
|
||||
update,
|
||||
set radiusScale(v) {
|
||||
radius = 0.1 * size * v;
|
||||
},
|
||||
get radiusScale() {
|
||||
return radius / (0.1 * size);
|
||||
},
|
||||
size
|
||||
};
|
||||
};
|
||||
|
||||
const createLiquidEffect = (texture, opts) => {
|
||||
const fragment = `
|
||||
uniform sampler2D uTexture;
|
||||
uniform float uStrength;
|
||||
uniform float uTime;
|
||||
uniform float uFreq;
|
||||
|
||||
void mainUv(inout vec2 uv) {
|
||||
vec4 tex = texture2D(uTexture, uv);
|
||||
float vx = tex.r * 2.0 - 1.0;
|
||||
float vy = tex.g * 2.0 - 1.0;
|
||||
float intensity = tex.b;
|
||||
float wave = 0.5 + 0.5 * sin(uTime * uFreq + intensity * 6.2831853);
|
||||
float amt = uStrength * intensity * wave;
|
||||
uv += vec2(vx, vy) * amt;
|
||||
}
|
||||
`;
|
||||
|
||||
return new Effect('LiquidEffect', fragment, {
|
||||
uniforms: new Map([
|
||||
['uTexture', new THREE.Uniform(texture)],
|
||||
['uStrength', new THREE.Uniform(opts?.strength ?? 0.025)],
|
||||
['uTime', new THREE.Uniform(0)],
|
||||
['uFreq', new THREE.Uniform(opts?.freq ?? 4.5)]
|
||||
])
|
||||
});
|
||||
};
|
||||
|
||||
const SHAPE_MAP = {
|
||||
square: 0,
|
||||
circle: 1,
|
||||
triangle: 2,
|
||||
diamond: 3
|
||||
};
|
||||
|
||||
const VERTEX_SRC = `
|
||||
void main() {
|
||||
gl_Position = vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const FRAGMENT_SRC = `
|
||||
precision highp float;
|
||||
|
||||
uniform vec3 uColor;
|
||||
uniform vec2 uResolution;
|
||||
uniform float uTime;
|
||||
uniform float uPixelSize;
|
||||
uniform float uScale;
|
||||
uniform float uDensity;
|
||||
uniform float uPixelJitter;
|
||||
uniform int uEnableRipples;
|
||||
uniform float uRippleSpeed;
|
||||
uniform float uRippleThickness;
|
||||
uniform float uRippleIntensity;
|
||||
uniform float uEdgeFade;
|
||||
uniform int uShapeType;
|
||||
|
||||
const int SHAPE_SQUARE = 0;
|
||||
const int SHAPE_CIRCLE = 1;
|
||||
const int SHAPE_TRIANGLE = 2;
|
||||
const int SHAPE_DIAMOND = 3;
|
||||
|
||||
const int MAX_CLICKS = 10;
|
||||
uniform vec2 uClickPos [MAX_CLICKS];
|
||||
uniform float uClickTimes[MAX_CLICKS];
|
||||
|
||||
out vec4 fragColor;
|
||||
|
||||
float Bayer2(vec2 a) {
|
||||
a = floor(a);
|
||||
return fract(a.x / 2. + a.y * a.y * .75);
|
||||
}
|
||||
|
||||
#define Bayer4(a) (Bayer2(.5*(a))*0.25 + Bayer2(a))
|
||||
#define Bayer8(a) (Bayer4(.5*(a))*0.25 + Bayer2(a))
|
||||
|
||||
#define FBM_OCTAVES 5
|
||||
#define FBM_LACUNARITY 1.25
|
||||
#define FBM_GAIN 1.0
|
||||
|
||||
float hash11(float n){ return fract(sin(n)*43758.5453); }
|
||||
|
||||
float vnoise(vec3 p){
|
||||
vec3 ip = floor(p);
|
||||
vec3 fp = fract(p);
|
||||
float n000 = hash11(dot(ip + vec3(0.0,0.0,0.0), vec3(1.0,57.0,113.0)));
|
||||
float n100 = hash11(dot(ip + vec3(1.0,0.0,0.0), vec3(1.0,57.0,113.0)));
|
||||
float n010 = hash11(dot(ip + vec3(0.0,1.0,0.0), vec3(1.0,57.0,113.0)));
|
||||
float n110 = hash11(dot(ip + vec3(1.0,1.0,0.0), vec3(1.0,57.0,113.0)));
|
||||
float n001 = hash11(dot(ip + vec3(0.0,0.0,1.0), vec3(1.0,57.0,113.0)));
|
||||
float n101 = hash11(dot(ip + vec3(1.0,0.0,1.0), vec3(1.0,57.0,113.0)));
|
||||
float n011 = hash11(dot(ip + vec3(0.0,1.0,1.0), vec3(1.0,57.0,113.0)));
|
||||
float n111 = hash11(dot(ip + vec3(1.0,1.0,1.0), vec3(1.0,57.0,113.0)));
|
||||
|
||||
vec3 w = fp*fp*fp*(fp*(fp*6.0-15.0)+10.0);
|
||||
|
||||
float x00 = mix(n000, n100, w.x);
|
||||
float x10 = mix(n010, n110, w.x);
|
||||
float x01 = mix(n001, n101, w.x);
|
||||
float x11 = mix(n011, n111, w.x);
|
||||
|
||||
float y0 = mix(x00, x10, w.y);
|
||||
float y1 = mix(x01, x11, w.y);
|
||||
|
||||
return mix(y0, y1, w.z) * 2.0 - 1.0;
|
||||
}
|
||||
|
||||
float fbm2(vec2 uv, float t){
|
||||
vec3 p = vec3(uv * uScale, t);
|
||||
float amp = 1.0;
|
||||
float freq = 1.0;
|
||||
float sum = 1.0;
|
||||
|
||||
for (int i = 0; i < FBM_OCTAVES; ++i){
|
||||
sum += amp * vnoise(p * freq);
|
||||
freq *= FBM_LACUNARITY;
|
||||
amp *= FBM_GAIN;
|
||||
}
|
||||
|
||||
return sum * 0.5 + 0.5;
|
||||
}
|
||||
|
||||
float maskCircle(vec2 p, float cov){
|
||||
float r = sqrt(cov) * .25;
|
||||
float d = length(p - 0.5) - r;
|
||||
float aa = 0.5 * fwidth(d);
|
||||
return cov * (1.0 - smoothstep(-aa, aa, d * 2.0));
|
||||
}
|
||||
|
||||
float maskTriangle(vec2 p, vec2 id, float cov){
|
||||
bool flip = mod(id.x + id.y, 2.0) > 0.5;
|
||||
if (flip) p.x = 1.0 - p.x;
|
||||
|
||||
float r = sqrt(cov);
|
||||
float d = p.y - r*(1.0 - p.x);
|
||||
float aa = fwidth(d);
|
||||
|
||||
return cov * clamp(0.5 - d/aa, 0.0, 1.0);
|
||||
}
|
||||
|
||||
float maskDiamond(vec2 p, float cov){
|
||||
float r = sqrt(cov) * 0.564;
|
||||
return step(abs(p.x - 0.49) + abs(p.y - 0.49), r);
|
||||
}
|
||||
|
||||
void main(){
|
||||
float pixelSize = uPixelSize;
|
||||
|
||||
vec2 fragCoord = gl_FragCoord.xy - uResolution * .5;
|
||||
float aspectRatio = uResolution.x / uResolution.y;
|
||||
|
||||
vec2 pixelId = floor(fragCoord / pixelSize);
|
||||
vec2 pixelUV = fract(fragCoord / pixelSize);
|
||||
|
||||
float cellPixelSize = 8.0 * pixelSize;
|
||||
vec2 cellId = floor(fragCoord / cellPixelSize);
|
||||
vec2 cellCoord = cellId * cellPixelSize;
|
||||
|
||||
vec2 uv = cellCoord / uResolution * vec2(aspectRatio, 1.0);
|
||||
|
||||
float base = fbm2(uv, uTime * 0.05);
|
||||
base = base * 0.5 - 0.65;
|
||||
|
||||
float feed = base + (uDensity - 0.5) * 0.3;
|
||||
|
||||
float speed = uRippleSpeed;
|
||||
float thickness = uRippleThickness;
|
||||
const float dampT = 1.0;
|
||||
const float dampR = 10.0;
|
||||
|
||||
if (uEnableRipples == 1) {
|
||||
for (int i = 0; i < MAX_CLICKS; ++i){
|
||||
vec2 pos = uClickPos[i];
|
||||
if (pos.x < 0.0) continue;
|
||||
|
||||
float cellPixelSize = 8.0 * pixelSize;
|
||||
vec2 cuv = (((pos - uResolution * .5 - cellPixelSize * .5) / (uResolution))) * vec2(aspectRatio, 1.0);
|
||||
|
||||
float t = max(uTime - uClickTimes[i], 0.0);
|
||||
float r = distance(uv, cuv);
|
||||
|
||||
float waveR = speed * t;
|
||||
float ring = exp(-pow((r - waveR) / thickness, 2.0));
|
||||
float atten = exp(-dampT * t) * exp(-dampR * r);
|
||||
|
||||
feed = max(feed, ring * atten * uRippleIntensity);
|
||||
}
|
||||
}
|
||||
|
||||
float bayer = Bayer8(fragCoord / uPixelSize) - 0.5;
|
||||
float bw = step(0.5, feed + bayer);
|
||||
|
||||
float h = fract(sin(dot(floor(fragCoord / uPixelSize), vec2(127.1, 311.7))) * 43758.5453);
|
||||
float jitterScale = 1.0 + (h - 0.5) * uPixelJitter;
|
||||
float coverage = bw * jitterScale;
|
||||
|
||||
float M;
|
||||
if (uShapeType == SHAPE_CIRCLE) M = maskCircle (pixelUV, coverage);
|
||||
else if (uShapeType == SHAPE_TRIANGLE) M = maskTriangle(pixelUV, pixelId, coverage);
|
||||
else if (uShapeType == SHAPE_DIAMOND) M = maskDiamond(pixelUV, coverage);
|
||||
else M = coverage;
|
||||
|
||||
if (uEdgeFade > 0.0) {
|
||||
vec2 norm = gl_FragCoord.xy / uResolution;
|
||||
float edge = min(min(norm.x, norm.y), min(1.0 - norm.x, 1.0 - norm.y));
|
||||
float fade = smoothstep(0.0, uEdgeFade, edge);
|
||||
M *= fade;
|
||||
}
|
||||
|
||||
vec3 color = uColor;
|
||||
fragColor = vec4(color, M);
|
||||
}
|
||||
`;
|
||||
|
||||
const MAX_CLICKS = 10;
|
||||
|
||||
const PixelBlast = ({
|
||||
variant = 'square',
|
||||
pixelSize = 3,
|
||||
color = '#10b981',
|
||||
className,
|
||||
style,
|
||||
antialias = true,
|
||||
patternScale = 2,
|
||||
patternDensity = 1,
|
||||
liquid = false,
|
||||
liquidStrength = 0.1,
|
||||
liquidRadius = 1,
|
||||
pixelSizeJitter = 0,
|
||||
enableRipples = true,
|
||||
rippleIntensityScale = 1,
|
||||
rippleThickness = 0.1,
|
||||
rippleSpeed = 0.3,
|
||||
liquidWobbleSpeed = 4.5,
|
||||
autoPauseOffscreen = true,
|
||||
speed = 0.5,
|
||||
transparent = true,
|
||||
edgeFade = 0.5,
|
||||
noiseAmount = 0
|
||||
}) => {
|
||||
const containerRef = useRef(null);
|
||||
const visibilityRef = useRef({ visible: true });
|
||||
const speedRef = useRef(speed);
|
||||
const threeRef = useRef(null);
|
||||
const prevConfigRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
speedRef.current = speed;
|
||||
|
||||
const needsReinitKeys = ['antialias', 'liquid', 'noiseAmount'];
|
||||
const cfg = { antialias, liquid, noiseAmount };
|
||||
let mustReinit = false;
|
||||
|
||||
if (!threeRef.current) mustReinit = true;
|
||||
else if (prevConfigRef.current) {
|
||||
for (const k of needsReinitKeys)
|
||||
if (prevConfigRef.current[k] !== cfg[k]) {
|
||||
mustReinit = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (mustReinit) {
|
||||
if (threeRef.current) {
|
||||
const t = threeRef.current;
|
||||
t.resizeObserver?.disconnect();
|
||||
cancelAnimationFrame(t.raf);
|
||||
t.quad?.geometry.dispose();
|
||||
t.material.dispose();
|
||||
t.composer?.dispose();
|
||||
t.renderer.dispose();
|
||||
if (t.renderer.domElement.parentElement === container) container.removeChild(t.renderer.domElement);
|
||||
threeRef.current = null;
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
canvas,
|
||||
antialias,
|
||||
alpha: true,
|
||||
powerPreference: 'high-performance'
|
||||
});
|
||||
|
||||
renderer.domElement.style.width = '100%';
|
||||
renderer.domElement.style.height = '100%';
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
if (transparent) renderer.setClearAlpha(0);
|
||||
else renderer.setClearColor(0x000000, 1);
|
||||
|
||||
const uniforms = {
|
||||
uResolution: { value: new THREE.Vector2(0, 0) },
|
||||
uTime: { value: 0 },
|
||||
uColor: { value: new THREE.Color(color) },
|
||||
uClickPos: {
|
||||
value: Array.from({ length: MAX_CLICKS }, () => new THREE.Vector2(-1, -1))
|
||||
},
|
||||
uClickTimes: { value: new Float32Array(MAX_CLICKS) },
|
||||
uShapeType: { value: SHAPE_MAP[variant] ?? 0 },
|
||||
uPixelSize: { value: pixelSize * renderer.getPixelRatio() },
|
||||
uScale: { value: patternScale },
|
||||
uDensity: { value: patternDensity },
|
||||
uPixelJitter: { value: pixelSizeJitter },
|
||||
uEnableRipples: { value: enableRipples ? 1 : 0 },
|
||||
uRippleSpeed: { value: rippleSpeed },
|
||||
uRippleThickness: { value: rippleThickness },
|
||||
uRippleIntensity: { value: rippleIntensityScale },
|
||||
uEdgeFade: { value: edgeFade }
|
||||
};
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
||||
|
||||
const material = new THREE.ShaderMaterial({
|
||||
vertexShader: VERTEX_SRC,
|
||||
fragmentShader: FRAGMENT_SRC,
|
||||
uniforms,
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
glslVersion: THREE.GLSL3
|
||||
});
|
||||
|
||||
const quadGeom = new THREE.PlaneGeometry(2, 2);
|
||||
const quad = new THREE.Mesh(quadGeom, material);
|
||||
scene.add(quad);
|
||||
|
||||
const clock = new THREE.Clock();
|
||||
|
||||
const setSize = () => {
|
||||
const w = container.clientWidth || 1;
|
||||
const h = container.clientHeight || 1;
|
||||
renderer.setSize(w, h, false);
|
||||
uniforms.uResolution.value.set(renderer.domElement.width, renderer.domElement.height);
|
||||
if (threeRef.current?.composer)
|
||||
threeRef.current.composer.setSize(renderer.domElement.width, renderer.domElement.height);
|
||||
uniforms.uPixelSize.value = pixelSize * renderer.getPixelRatio();
|
||||
};
|
||||
|
||||
setSize();
|
||||
|
||||
const ro = new ResizeObserver(setSize);
|
||||
ro.observe(container);
|
||||
|
||||
const randomFloat = () => {
|
||||
if (typeof window !== 'undefined' && window.crypto?.getRandomValues) {
|
||||
const u32 = new Uint32Array(1);
|
||||
window.crypto.getRandomValues(u32);
|
||||
return u32[0] / 0xffffffff;
|
||||
}
|
||||
return Math.random();
|
||||
};
|
||||
|
||||
const timeOffset = randomFloat() * 1000;
|
||||
|
||||
let composer;
|
||||
let touch;
|
||||
let liquidEffect;
|
||||
|
||||
if (liquid) {
|
||||
touch = createTouchTexture();
|
||||
touch.radiusScale = liquidRadius;
|
||||
|
||||
composer = new EffectComposer(renderer);
|
||||
const renderPass = new RenderPass(scene, camera);
|
||||
|
||||
liquidEffect = createLiquidEffect(touch.texture, {
|
||||
strength: liquidStrength,
|
||||
freq: liquidWobbleSpeed
|
||||
});
|
||||
|
||||
const effectPass = new EffectPass(camera, liquidEffect);
|
||||
effectPass.renderToScreen = true;
|
||||
|
||||
composer.addPass(renderPass);
|
||||
composer.addPass(effectPass);
|
||||
}
|
||||
|
||||
if (noiseAmount > 0) {
|
||||
if (!composer) {
|
||||
composer = new EffectComposer(renderer);
|
||||
composer.addPass(new RenderPass(scene, camera));
|
||||
}
|
||||
|
||||
const noiseEffect = new Effect(
|
||||
'NoiseEffect',
|
||||
`uniform float uTime; uniform float uAmount; float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7))) * 43758.5453);} void mainUv(inout vec2 uv){} void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor){ float n=hash(floor(uv*vec2(1920.0,1080.0))+floor(uTime*60.0)); float g=(n-0.5)*uAmount; outputColor=inputColor+vec4(vec3(g),0.0);} `,
|
||||
{
|
||||
uniforms: new Map([
|
||||
['uTime', new THREE.Uniform(0)],
|
||||
['uAmount', new THREE.Uniform(noiseAmount)]
|
||||
])
|
||||
}
|
||||
);
|
||||
|
||||
const noisePass = new EffectPass(camera, noiseEffect);
|
||||
noisePass.renderToScreen = true;
|
||||
|
||||
if (composer && composer.passes.length > 0) composer.passes.forEach(p => (p.renderToScreen = false));
|
||||
composer.addPass(noisePass);
|
||||
}
|
||||
|
||||
if (composer) composer.setSize(renderer.domElement.width, renderer.domElement.height);
|
||||
|
||||
const mapToPixels = e => {
|
||||
const rect = renderer.domElement.getBoundingClientRect();
|
||||
const scaleX = renderer.domElement.width / rect.width;
|
||||
const scaleY = renderer.domElement.height / rect.height;
|
||||
const fx = (e.clientX - rect.left) * scaleX;
|
||||
const fy = (rect.height - (e.clientY - rect.top)) * scaleY;
|
||||
return {
|
||||
fx,
|
||||
fy,
|
||||
w: renderer.domElement.width,
|
||||
h: renderer.domElement.height
|
||||
};
|
||||
};
|
||||
|
||||
const onPointerDown = e => {
|
||||
const { fx, fy } = mapToPixels(e);
|
||||
const ix = threeRef.current?.clickIx ?? 0;
|
||||
uniforms.uClickPos.value[ix].set(fx, fy);
|
||||
uniforms.uClickTimes.value[ix] = uniforms.uTime.value;
|
||||
if (threeRef.current) threeRef.current.clickIx = (ix + 1) % MAX_CLICKS;
|
||||
};
|
||||
|
||||
const onPointerMove = e => {
|
||||
if (!touch) return;
|
||||
const { fx, fy, w, h } = mapToPixels(e);
|
||||
touch.addTouch({ x: fx / w, y: fy / h });
|
||||
};
|
||||
|
||||
renderer.domElement.addEventListener('pointerdown', onPointerDown, {
|
||||
passive: true
|
||||
});
|
||||
renderer.domElement.addEventListener('pointermove', onPointerMove, {
|
||||
passive: true
|
||||
});
|
||||
|
||||
let raf = 0;
|
||||
|
||||
const animate = () => {
|
||||
if (autoPauseOffscreen && !visibilityRef.current.visible) {
|
||||
raf = requestAnimationFrame(animate);
|
||||
return;
|
||||
}
|
||||
|
||||
uniforms.uTime.value = timeOffset + clock.getElapsedTime() * speedRef.current;
|
||||
if (liquidEffect) liquidEffect.uniforms.get('uTime').value = uniforms.uTime.value;
|
||||
|
||||
if (composer) {
|
||||
if (touch) touch.update();
|
||||
composer.passes.forEach(p => {
|
||||
const effs = p.effects;
|
||||
if (effs)
|
||||
effs.forEach(eff => {
|
||||
const u = eff.uniforms?.get('uTime');
|
||||
if (u) u.value = uniforms.uTime.value;
|
||||
});
|
||||
});
|
||||
composer.render();
|
||||
} else renderer.render(scene, camera);
|
||||
|
||||
raf = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
raf = requestAnimationFrame(animate);
|
||||
|
||||
threeRef.current = {
|
||||
renderer,
|
||||
scene,
|
||||
camera,
|
||||
material,
|
||||
clock,
|
||||
clickIx: 0,
|
||||
uniforms,
|
||||
resizeObserver: ro,
|
||||
raf,
|
||||
quad,
|
||||
timeOffset,
|
||||
composer,
|
||||
touch,
|
||||
liquidEffect
|
||||
};
|
||||
} else {
|
||||
const t = threeRef.current;
|
||||
t.uniforms.uShapeType.value = SHAPE_MAP[variant] ?? 0;
|
||||
t.uniforms.uPixelSize.value = pixelSize * t.renderer.getPixelRatio();
|
||||
t.uniforms.uColor.value.set(color);
|
||||
t.uniforms.uScale.value = patternScale;
|
||||
t.uniforms.uDensity.value = patternDensity;
|
||||
t.uniforms.uPixelJitter.value = pixelSizeJitter;
|
||||
t.uniforms.uEnableRipples.value = enableRipples ? 1 : 0;
|
||||
t.uniforms.uRippleIntensity.value = rippleIntensityScale;
|
||||
t.uniforms.uRippleThickness.value = rippleThickness;
|
||||
t.uniforms.uRippleSpeed.value = rippleSpeed;
|
||||
t.uniforms.uEdgeFade.value = edgeFade;
|
||||
|
||||
if (transparent) t.renderer.setClearAlpha(0);
|
||||
else t.renderer.setClearColor(0x000000, 1);
|
||||
|
||||
if (t.liquidEffect) {
|
||||
const uStrength = t.liquidEffect.uniforms.get('uStrength');
|
||||
if (uStrength) uStrength.value = liquidStrength;
|
||||
const uFreq = t.liquidEffect.uniforms.get('uFreq');
|
||||
if (uFreq) uFreq.value = liquidWobbleSpeed;
|
||||
}
|
||||
|
||||
if (t.touch) t.touch.radiusScale = liquidRadius;
|
||||
}
|
||||
|
||||
prevConfigRef.current = cfg;
|
||||
|
||||
return () => {
|
||||
if (threeRef.current && mustReinit) return;
|
||||
if (!threeRef.current) return;
|
||||
|
||||
const t = threeRef.current;
|
||||
t.resizeObserver?.disconnect();
|
||||
cancelAnimationFrame(t.raf);
|
||||
t.quad?.geometry.dispose();
|
||||
t.material.dispose();
|
||||
t.composer?.dispose();
|
||||
t.renderer.dispose();
|
||||
if (t.renderer.domElement.parentElement === container) container.removeChild(t.renderer.domElement);
|
||||
threeRef.current = null;
|
||||
};
|
||||
}, [
|
||||
antialias,
|
||||
liquid,
|
||||
noiseAmount,
|
||||
pixelSize,
|
||||
patternScale,
|
||||
patternDensity,
|
||||
enableRipples,
|
||||
rippleIntensityScale,
|
||||
rippleThickness,
|
||||
rippleSpeed,
|
||||
pixelSizeJitter,
|
||||
edgeFade,
|
||||
transparent,
|
||||
liquidStrength,
|
||||
liquidRadius,
|
||||
liquidWobbleSpeed,
|
||||
autoPauseOffscreen,
|
||||
variant,
|
||||
color,
|
||||
speed
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`pixel-blast-container ${className ?? ''}`}
|
||||
style={style}
|
||||
aria-label="PixelBlast interactive background"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PixelBlast;
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useState } from 'react'
|
||||
import { FaLock, FaLockOpen, FaPlay, FaStop, FaTruck, FaSackDollar, FaUserGear } from 'react-icons/fa6'
|
||||
import { FaLock, FaLockOpen, FaPlay, FaStop, FaTruck, FaSackDollar, FaUserGear, FaPlus } from 'react-icons/fa6'
|
||||
import { formatDistanceToNow, format } from 'date-fns'
|
||||
import { de } from 'date-fns/locale'
|
||||
import StatusDropdown from './StatusDropdown'
|
||||
import PriorityDropdown from './PriorityDropdown'
|
||||
import EditorDropdown from './EditorDropdown'
|
||||
import ResponseDropdown from './ResponseDropdown'
|
||||
import CreateWorksheetModal from './CreateWorksheetModal'
|
||||
import WorksheetList from './WorksheetList'
|
||||
import WorksheetStats from './WorksheetStats'
|
||||
import { useWorksheets } from '../hooks/useWorksheets'
|
||||
|
||||
const PRIORITY_CLASSES = {
|
||||
0: 'priority-none',
|
||||
@@ -41,6 +45,15 @@ const APPROVAL_ICONS = {
|
||||
export default function TicketRow({ ticket, onUpdate, onExpand }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [locked, setLocked] = useState(true)
|
||||
const [showCreateWorksheet, setShowCreateWorksheet] = useState(false)
|
||||
|
||||
// Worksheets für dieses Ticket laden (nur wenn expanded)
|
||||
const {
|
||||
worksheets,
|
||||
loading: worksheetsLoading,
|
||||
createWorksheet,
|
||||
getTotalTime
|
||||
} = useWorksheets(expanded ? ticket.woid : null)
|
||||
|
||||
const createdAt = new Date(ticket.$createdAt || ticket.createdAt)
|
||||
const elapsed = formatDistanceToNow(createdAt, { locale: de })
|
||||
@@ -71,13 +84,27 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
|
||||
setLocked(!locked)
|
||||
}
|
||||
|
||||
const handleCreateWorksheet = async (worksheetData, currentUser) => {
|
||||
const result = await createWorksheet(worksheetData, currentUser)
|
||||
|
||||
// Wenn Status geändert wurde, aktualisiere Work Order
|
||||
if (result.success && worksheetData.newStatus !== ticket.status) {
|
||||
await onUpdate(ticket.$id, {
|
||||
status: worksheetData.newStatus,
|
||||
responseLevel: worksheetData.newResponseLevel || ticket.responseLevel
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const ApprovalIcon = APPROVAL_ICONS[ticket.approvalStatus] || FaUserGear
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr className="ticket-row">
|
||||
<td className="ticket-id" rowSpan={2}>
|
||||
<div>{ticket.woid || ticket.$id?.slice(-5)}</div>
|
||||
<div><strong>WOID:</strong> {ticket.woid || ticket.$id?.slice(-5)}</div>
|
||||
<div className="ticket-time">{elapsed}</div>
|
||||
</td>
|
||||
<td className="ticket-info" rowSpan={2}>
|
||||
@@ -153,16 +180,82 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
|
||||
<tr>
|
||||
<td colSpan={10} className="p-2">
|
||||
<div className="card">
|
||||
<div className="card-header">Details - WOID {ticket.woid || ticket.$id}</div>
|
||||
<div className="card-header d-flex justify-content-between align-items-center" style={{
|
||||
background: 'linear-gradient(135deg, #2d3748 0%, #1a202c 100%)',
|
||||
color: 'white',
|
||||
padding: '1rem 1.5rem'
|
||||
}}>
|
||||
<span className="fs-5 fw-bold">Details - WOID {ticket.woid || ticket.$id}</span>
|
||||
<button
|
||||
className="btn btn-sm px-4 py-2 border-0 fw-bold"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
color: 'white',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(16, 185, 129, 0.4)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
e.currentTarget.style.boxShadow = 'none'
|
||||
}}
|
||||
onClick={() => setShowCreateWorksheet(true)}
|
||||
>
|
||||
<FaPlus className="me-2" /> Add Worksheet
|
||||
</button>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<p><strong>Beschreibung:</strong></p>
|
||||
<p>{ticket.details || 'Keine Details vorhanden.'}</p>
|
||||
<div className="mb-4 p-4 rounded-3 shadow-sm" style={{
|
||||
background: 'linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%)',
|
||||
border: '2px solid #10b981'
|
||||
}}>
|
||||
<h5 className="mb-3" style={{ color: '#1a202c', fontWeight: 'bold' }}>
|
||||
📋 Ticket-Beschreibung
|
||||
</h5>
|
||||
<p style={{
|
||||
fontSize: '1.1rem',
|
||||
lineHeight: '1.8',
|
||||
color: '#1f2937',
|
||||
whiteSpace: 'pre-wrap',
|
||||
margin: 0
|
||||
}}>
|
||||
{ticket.details || 'Keine Details vorhanden.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<h5 className="mt-4 mb-3">Worksheets (Arbeitsschritte)</h5>
|
||||
|
||||
{/* Statistiken */}
|
||||
{worksheets.length > 0 && (
|
||||
<>
|
||||
<WorksheetStats worksheets={worksheets} />
|
||||
<hr />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Worksheet-Liste */}
|
||||
<WorksheetList
|
||||
worksheets={worksheets}
|
||||
totalTime={getTotalTime()}
|
||||
loading={worksheetsLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
)}
|
||||
|
||||
<CreateWorksheetModal
|
||||
isOpen={showCreateWorksheet}
|
||||
onClose={() => setShowCreateWorksheet(false)}
|
||||
workorder={ticket}
|
||||
onCreate={handleCreateWorksheet}
|
||||
/>
|
||||
<tr className="spacer">
|
||||
<td colSpan={10} style={{ height: '8px', background: '#fff' }}></td>
|
||||
</tr>
|
||||
|
||||
189
src/components/WorksheetList.jsx
Normal file
189
src/components/WorksheetList.jsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { FaClock, FaUser, FaExchangeAlt, FaComment } from 'react-icons/fa'
|
||||
|
||||
export default function WorksheetList({ worksheets, totalTime, loading }) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center p-4">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!worksheets || worksheets.length === 0) {
|
||||
return (
|
||||
<div className="alert alert-info" role="alert">
|
||||
<FaComment className="me-2" />
|
||||
Noch keine Worksheets vorhanden. Erstelle das erste Worksheet für dieses Ticket.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const formatTime = (minutes) => {
|
||||
if (!minutes || minutes === 0) return '-'
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const mins = minutes % 60
|
||||
return hours > 0 ? `${hours}h ${mins}min` : `${mins}min`
|
||||
}
|
||||
|
||||
const formatDateTime = (date, time) => {
|
||||
if (!time) return date
|
||||
const hours = time.substring(0, 2)
|
||||
const mins = time.substring(2, 4)
|
||||
return `${date} ${hours}:${mins}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="worksheet-list">
|
||||
{/* Gesamtzeit-Header */}
|
||||
<div className="mb-4 p-4 rounded-3 shadow-sm" style={{
|
||||
background: 'linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%)',
|
||||
border: 'none'
|
||||
}}>
|
||||
<div className="d-flex justify-content-between align-items-center">
|
||||
<div className="d-flex align-items-center">
|
||||
<FaClock className="me-3" size={24} style={{ color: '#059669' }} />
|
||||
<div>
|
||||
<strong className="fs-5 d-block" style={{ color: '#064e3b' }}>Gesamtarbeitszeit</strong>
|
||||
<span className="fs-3 fw-bold" style={{ color: '#059669' }}>{formatTime(totalTime)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-end">
|
||||
<span className="badge px-3 py-2" style={{
|
||||
background: 'rgba(5, 150, 105, 0.2)',
|
||||
color: '#059669',
|
||||
fontSize: '1rem'
|
||||
}}>
|
||||
{worksheets.filter(ws => !ws.isComment).length} Worksheet(s)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Worksheet-Einträge */}
|
||||
<div className="timeline">
|
||||
{worksheets.map((ws, index) => (
|
||||
<div key={ws.$id} className="timeline-item mb-4" style={{
|
||||
animation: `fadeIn 0.5s ease-in-out ${index * 0.1}s backwards`
|
||||
}}>
|
||||
<div className="card border-0 shadow-sm overflow-hidden" style={{
|
||||
borderLeft: ws.isComment ? '4px solid #10b981' : '4px solid #4a5568',
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
|
||||
}} onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||
e.currentTarget.style.boxShadow = '0 8px 16px rgba(0,0,0,0.1)'
|
||||
}} onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
e.currentTarget.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)'
|
||||
}}>
|
||||
<div className="card-header d-flex justify-content-between align-items-center py-3" style={{
|
||||
background: ws.isComment
|
||||
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
||||
: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
|
||||
color: 'white',
|
||||
border: 'none'
|
||||
}}>
|
||||
<div>
|
||||
<strong className="fs-6">WSID {ws.wsid}</strong>
|
||||
{ws.isComment && (
|
||||
<span className="badge ms-2" style={{
|
||||
background: 'rgba(255,255,255,0.3)'
|
||||
}}>
|
||||
<FaComment className="me-1" /> Kommentar
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<small style={{ opacity: 0.9 }}>
|
||||
{formatDateTime(ws.startDate, ws.startTime)}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="card-body p-4">
|
||||
{/* Mitarbeiter & Zeit */}
|
||||
<div className="row mb-3">
|
||||
<div className="col-md-6">
|
||||
<div className="d-flex align-items-center">
|
||||
<FaUser className="me-2" style={{ color: '#10b981' }} />
|
||||
<strong>{ws.employeeName}</strong>
|
||||
{ws.employeeShort && (
|
||||
<span className="badge ms-2" style={{
|
||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
color: 'white'
|
||||
}}>{ws.employeeShort}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-6 text-md-end">
|
||||
{!ws.isComment && (
|
||||
<div className="d-flex align-items-center justify-content-md-end">
|
||||
<FaClock className="me-2" style={{ color: '#10b981' }} />
|
||||
<strong className="fs-5" style={{ color: '#10b981' }}>{formatTime(ws.totalTime)}</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service Type */}
|
||||
<div className="mb-3">
|
||||
<span className="badge px-3 py-2" style={{
|
||||
background: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
|
||||
color: 'white',
|
||||
fontSize: '0.85rem'
|
||||
}}>{ws.serviceType}</span>
|
||||
</div>
|
||||
|
||||
{/* Status-Änderung */}
|
||||
{ws.oldStatus !== ws.newStatus && (
|
||||
<div className="mb-3 p-3 rounded-3" style={{
|
||||
background: 'linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, rgba(5, 150, 105, 0.1) 100%)'
|
||||
}}>
|
||||
<FaExchangeAlt className="me-2" style={{ color: '#10b981' }} />
|
||||
<span className="text-muted">Status:</span>{' '}
|
||||
<span className="badge" style={{ background: '#6b7280', color: 'white' }}>{ws.oldStatus}</span>
|
||||
<span className="mx-2" style={{ color: '#10b981' }}>→</span>
|
||||
<span className="badge" style={{ background: '#10b981', color: 'white' }}>{ws.newStatus}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Response Level-Änderung */}
|
||||
{ws.oldResponseLevel && ws.newResponseLevel && ws.oldResponseLevel !== ws.newResponseLevel && (
|
||||
<div className="mb-3">
|
||||
<span className="text-muted">Response Level:</span>{' '}
|
||||
<span className="badge" style={{ background: '#6b7280', color: 'white' }}>{ws.oldResponseLevel}</span>
|
||||
<span className="mx-2">→</span>
|
||||
<span className="badge" style={{ background: '#10b981', color: 'white' }}>{ws.newResponseLevel}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details */}
|
||||
<div className="mt-3 p-3 rounded-3" style={{
|
||||
background: 'rgba(16, 185, 129, 0.05)',
|
||||
border: '1px solid rgba(16, 185, 129, 0.1)'
|
||||
}}>
|
||||
<small className="text-dark" style={{ whiteSpace: 'pre-wrap', lineHeight: '1.6' }}>
|
||||
{ws.details}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
212
src/components/WorksheetStats.jsx
Normal file
212
src/components/WorksheetStats.jsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import { FaClock, FaUsers, FaHistory, FaChartLine } from 'react-icons/fa'
|
||||
|
||||
export default function WorksheetStats({ worksheets }) {
|
||||
if (!worksheets || worksheets.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Gesamtarbeitszeit
|
||||
const totalMinutes = worksheets
|
||||
.filter(ws => !ws.isComment)
|
||||
.reduce((sum, ws) => sum + (ws.totalTime || 0), 0)
|
||||
|
||||
// Nach Mitarbeiter gruppieren
|
||||
const byEmployee = worksheets.reduce((acc, ws) => {
|
||||
const empId = ws.employeeId
|
||||
if (!acc[empId]) {
|
||||
acc[empId] = {
|
||||
name: ws.employeeName,
|
||||
short: ws.employeeShort,
|
||||
time: 0,
|
||||
count: 0
|
||||
}
|
||||
}
|
||||
if (!ws.isComment) {
|
||||
acc[empId].time += ws.totalTime || 0
|
||||
}
|
||||
acc[empId].count += 1
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
// Status-Historie
|
||||
const statusHistory = worksheets
|
||||
.filter(ws => ws.oldStatus && ws.newStatus && ws.oldStatus !== ws.newStatus)
|
||||
.map(ws => ({
|
||||
date: ws.startDate,
|
||||
time: ws.startTime,
|
||||
from: ws.oldStatus,
|
||||
to: ws.newStatus,
|
||||
employee: ws.employeeName
|
||||
}))
|
||||
|
||||
// Service Type Verteilung
|
||||
const byServiceType = worksheets.reduce((acc, ws) => {
|
||||
const type = ws.serviceType || 'Unknown'
|
||||
acc[type] = (acc[type] || 0) + 1
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const formatTime = (minutes) => {
|
||||
if (!minutes || minutes === 0) return '0min'
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const mins = minutes % 60
|
||||
return hours > 0 ? `${hours}h ${mins}min` : `${mins}min`
|
||||
}
|
||||
|
||||
const formatTimeShort = (time) => {
|
||||
if (!time || time.length !== 4) return '-'
|
||||
return `${time.substring(0, 2)}:${time.substring(2, 4)}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="worksheet-stats mb-4">
|
||||
<div className="row g-4">
|
||||
{/* Gesamtübersicht */}
|
||||
<div className="col-lg-4 col-md-6">
|
||||
<div className="card h-100 border-0 shadow-sm" style={{
|
||||
background: 'linear-gradient(135deg, #2d3748 0%, #1a202c 100%)',
|
||||
color: 'white'
|
||||
}}>
|
||||
<div className="card-body p-4">
|
||||
<h6 className="card-title mb-3 d-flex align-items-center">
|
||||
<FaChartLine className="me-2" size={20} style={{ color: '#4ade80' }} />
|
||||
<strong>Gesamtübersicht</strong>
|
||||
</h6>
|
||||
<div className="mt-3">
|
||||
<div className="d-flex justify-content-between align-items-center mb-3 pb-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.2)' }}>
|
||||
<span style={{ opacity: 0.9 }}>Worksheets:</span>
|
||||
<strong className="fs-5">{worksheets.length}</strong>
|
||||
</div>
|
||||
<div className="d-flex justify-content-between align-items-center mb-3 pb-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.2)' }}>
|
||||
<span style={{ opacity: 0.9 }}>Arbeitszeit:</span>
|
||||
<strong className="fs-5">{formatTime(totalMinutes)}</strong>
|
||||
</div>
|
||||
<div className="d-flex justify-content-between align-items-center mb-3 pb-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.2)' }}>
|
||||
<span style={{ opacity: 0.9 }}>Kommentare:</span>
|
||||
<strong className="fs-5">{worksheets.filter(ws => ws.isComment).length}</strong>
|
||||
</div>
|
||||
<div className="d-flex justify-content-between align-items-center">
|
||||
<span style={{ opacity: 0.9 }}>Ø pro Worksheet:</span>
|
||||
<strong className="fs-5">
|
||||
{formatTime(Math.round(totalMinutes / (worksheets.filter(ws => !ws.isComment).length || 1)))}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nach Mitarbeiter */}
|
||||
<div className="col-lg-4 col-md-6">
|
||||
<div className="card h-100 border-0 shadow-sm" style={{
|
||||
background: 'linear-gradient(135deg, #22c55e 0%, #10b981 100%)',
|
||||
color: 'white'
|
||||
}}>
|
||||
<div className="card-body p-4">
|
||||
<h6 className="card-title mb-3 d-flex align-items-center">
|
||||
<FaUsers className="me-2" size={20} />
|
||||
<strong>Nach Mitarbeiter</strong>
|
||||
</h6>
|
||||
<div className="mt-3">
|
||||
{Object.values(byEmployee).map((emp, idx) => (
|
||||
<div key={idx} className="mb-3 pb-3" style={{ borderBottom: idx < Object.values(byEmployee).length - 1 ? '1px solid rgba(255,255,255,0.2)' : 'none' }}>
|
||||
<div className="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong className="d-block">{emp.name}</strong>
|
||||
{emp.short && (
|
||||
<span className="badge mt-1" style={{
|
||||
background: 'rgba(255,255,255,0.25)'
|
||||
}}>{emp.short}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-end">
|
||||
<div className="fs-5 fw-bold">{formatTime(emp.time)}</div>
|
||||
<small style={{ opacity: 0.8 }}>{emp.count} WS</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service Type Verteilung */}
|
||||
<div className="col-lg-4 col-md-6">
|
||||
<div className="card h-100 border-0 shadow-sm" style={{
|
||||
background: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
|
||||
color: 'white'
|
||||
}}>
|
||||
<div className="card-body p-4">
|
||||
<h6 className="card-title mb-3 d-flex align-items-center">
|
||||
<FaClock className="me-2" size={20} style={{ color: '#4ade80' }} />
|
||||
<strong>Service Types</strong>
|
||||
</h6>
|
||||
<div className="mt-3">
|
||||
{Object.entries(byServiceType).map(([type, count], idx) => (
|
||||
<div key={type} className="d-flex justify-content-between align-items-center mb-3 pb-3" style={{ borderBottom: idx < Object.entries(byServiceType).length - 1 ? '1px solid rgba(255,255,255,0.2)' : 'none' }}>
|
||||
<span className="badge px-3 py-2" style={{
|
||||
background: 'rgba(255,255,255,0.25)',
|
||||
fontSize: '0.9rem'
|
||||
}}>{type}</span>
|
||||
<strong className="fs-5">{count}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status-Historie */}
|
||||
{statusHistory.length > 0 && (
|
||||
<div className="card border-0 shadow-sm mt-3" style={{
|
||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
||||
}}>
|
||||
<div className="card-body p-4">
|
||||
<h6 className="card-title text-white mb-3 d-flex align-items-center">
|
||||
<FaHistory className="me-2" size={20} />
|
||||
<strong>Status-Historie</strong>
|
||||
</h6>
|
||||
<div className="table-responsive mt-3">
|
||||
<table className="table table-sm" style={{ borderColor: 'rgba(255,255,255,0.2)' }}>
|
||||
<thead>
|
||||
<tr style={{ color: 'white', borderColor: 'rgba(255,255,255,0.2)' }}>
|
||||
<th style={{ borderColor: 'rgba(255,255,255,0.2)' }}>Datum</th>
|
||||
<th style={{ borderColor: 'rgba(255,255,255,0.2)' }}>Zeit</th>
|
||||
<th style={{ borderColor: 'rgba(255,255,255,0.2)' }}>Von</th>
|
||||
<th style={{ borderColor: 'rgba(255,255,255,0.2)' }}></th>
|
||||
<th style={{ borderColor: 'rgba(255,255,255,0.2)' }}>Nach</th>
|
||||
<th style={{ borderColor: 'rgba(255,255,255,0.2)' }}>Mitarbeiter</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{statusHistory.reverse().map((change, idx) => (
|
||||
<tr key={idx} style={{ color: 'white', borderColor: 'rgba(255,255,255,0.2)' }}>
|
||||
<td style={{ borderColor: 'rgba(255,255,255,0.2)' }}>{change.date}</td>
|
||||
<td style={{ borderColor: 'rgba(255,255,255,0.2)' }}>{formatTimeShort(change.time)}</td>
|
||||
<td style={{ borderColor: 'rgba(255,255,255,0.2)' }}>
|
||||
<span className="badge" style={{
|
||||
background: 'rgba(255,255,255,0.25)'
|
||||
}}>{change.from}</span>
|
||||
</td>
|
||||
<td style={{ borderColor: 'rgba(255,255,255,0.2)' }}>→</td>
|
||||
<td style={{ borderColor: 'rgba(255,255,255,0.2)' }}>
|
||||
<span className="badge" style={{
|
||||
background: 'rgba(255,255,255,0.4)',
|
||||
fontWeight: 'bold'
|
||||
}}>{change.to}</span>
|
||||
</td>
|
||||
<td style={{ borderColor: 'rgba(255,255,255,0.2)' }}>{change.employee}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,46 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react'
|
||||
import { account } from '../lib/appwrite'
|
||||
import { account, databases, DATABASE_ID, COLLECTIONS, ID, Query } from '../lib/appwrite'
|
||||
|
||||
const AuthContext = createContext()
|
||||
|
||||
// Demo mode when Appwrite is not configured
|
||||
const DEMO_MODE = !import.meta.env.VITE_APPWRITE_PROJECT_ID
|
||||
|
||||
// Hilfsfunktion: Fügt User automatisch zur employees Collection hinzu
|
||||
async function ensureEmployeeExists(user) {
|
||||
if (!user || DEMO_MODE) return
|
||||
|
||||
try {
|
||||
// Prüfe ob User bereits in employees Collection existiert
|
||||
const response = await databases.listDocuments(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.EMPLOYEES,
|
||||
[Query.equal('userId', user.$id)]
|
||||
)
|
||||
|
||||
// Wenn User noch nicht existiert, füge ihn hinzu
|
||||
if (response.documents.length === 0) {
|
||||
await databases.createDocument(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.EMPLOYEES,
|
||||
ID.unique(),
|
||||
{
|
||||
userId: user.$id,
|
||||
displayName: user.name || user.email,
|
||||
email: user.email,
|
||||
shortcode: '' // Kürzel wird später vom Admin hinzugefügt
|
||||
}
|
||||
)
|
||||
console.log('✅ User automatisch zur Mitarbeiter-Liste hinzugefügt')
|
||||
}
|
||||
} catch (error) {
|
||||
// Fehler ignorieren wenn Collection nicht existiert oder Permissions fehlen
|
||||
if (error.code !== 404) {
|
||||
console.warn('Could not add user to employees collection:', error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -28,7 +63,14 @@ export function AuthProvider({ children }) {
|
||||
try {
|
||||
const session = await account.get()
|
||||
setUser(session)
|
||||
// Automatisch zur employees Collection hinzufügen
|
||||
await ensureEmployeeExists(session)
|
||||
} catch (error) {
|
||||
// Kein Fehler loggen beim initialen Check - das ist normal wenn nicht eingeloggt
|
||||
// Nur loggen wenn es ein unerwarteter Fehler ist (nicht 401)
|
||||
if (error.code !== 401 && error.code !== 404) {
|
||||
console.error('Unexpected error checking user:', error)
|
||||
}
|
||||
setUser(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@@ -45,11 +87,38 @@ export function AuthProvider({ children }) {
|
||||
}
|
||||
|
||||
try {
|
||||
await account.createEmailPasswordSession(email, password)
|
||||
await checkUser()
|
||||
// Appwrite 1.5.7 / SDK 13.0 - versuche beide Methoden für Kompatibilität
|
||||
try {
|
||||
await account.createEmailSession(email, password)
|
||||
} catch (e) {
|
||||
// Fallback für ältere API
|
||||
if (account.createEmailPasswordSession) {
|
||||
await account.createEmailPasswordSession(email, password)
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// User-Daten laden und automatisch zur employees Collection hinzufügen
|
||||
const session = await account.get()
|
||||
setUser(session)
|
||||
await ensureEmployeeExists(session)
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message }
|
||||
console.error('Login error:', error)
|
||||
let errorMessage = error.message || 'Login fehlgeschlagen'
|
||||
|
||||
// Bessere Fehlermeldungen
|
||||
if (error.code === 401 || errorMessage.includes('Invalid credentials')) {
|
||||
errorMessage = 'Ungültige Email oder Passwort'
|
||||
} else if (errorMessage.includes('User not found')) {
|
||||
errorMessage = 'Benutzer nicht gefunden. Bitte registriere dich zuerst.'
|
||||
} else if (errorMessage.includes('Email/Password')) {
|
||||
errorMessage = 'Email/Password Authentifizierung ist nicht aktiviert. Bitte aktiviere sie in deinem Appwrite Dashboard unter Auth → Providers.'
|
||||
}
|
||||
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,20 +143,42 @@ export function AuthProvider({ children }) {
|
||||
}
|
||||
|
||||
try {
|
||||
await account.create('unique()', email, password, name)
|
||||
// Appwrite SDK 13.0 verwendet ID.unique() für die User ID
|
||||
await account.create(ID.unique(), email, password, name)
|
||||
// Login ruft automatisch ensureEmployeeExists auf
|
||||
await login(email, password)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message }
|
||||
console.error('Register error:', error)
|
||||
let errorMessage = error.message || 'Registrierung fehlgeschlagen'
|
||||
|
||||
// Bessere Fehlermeldungen
|
||||
if (errorMessage.includes('already exists') || errorMessage.includes('duplicate')) {
|
||||
errorMessage = 'Ein Benutzer mit dieser Email existiert bereits. Bitte logge dich ein.'
|
||||
} else if (errorMessage.includes('Email/Password')) {
|
||||
errorMessage = 'Email/Password Authentifizierung ist nicht aktiviert. Bitte aktiviere sie in deinem Appwrite Dashboard unter Auth → Providers.'
|
||||
} else if (errorMessage.includes('password') && errorMessage.includes('length')) {
|
||||
errorMessage = 'Das Passwort muss mindestens 8 Zeichen lang sein.'
|
||||
}
|
||||
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
}
|
||||
|
||||
// Hilfsfunktion um zu prüfen ob Benutzer Admin ist
|
||||
const isAdmin = () => {
|
||||
if (!user) return false
|
||||
// Prüfe ob Benutzer das "admin" Label hat
|
||||
return user.labels?.includes('admin') || false
|
||||
}
|
||||
|
||||
const value = {
|
||||
user,
|
||||
loading,
|
||||
login,
|
||||
logout,
|
||||
register
|
||||
register,
|
||||
isAdmin: isAdmin()
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
152
src/hooks/useAdminConfig.js
Normal file
152
src/hooks/useAdminConfig.js
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { databases, DATABASE_ID, COLLECTIONS, ID } from '../lib/appwrite'
|
||||
|
||||
const DEMO_MODE = !import.meta.env.VITE_APPWRITE_PROJECT_ID
|
||||
|
||||
// Default-Werte für Demo-Modus
|
||||
const DEFAULT_CONFIG = {
|
||||
ticketTypes: [
|
||||
'Home Office', 'Holidays', 'Trip', 'Supportrequest', 'Change Request',
|
||||
'Maintenance', 'Project', 'Controlling', 'Development', 'Documentation',
|
||||
'Meeting/Conference', 'IT Management', 'IT Security', 'Procurement',
|
||||
'Rollout', 'Emergency Call', 'Other Services'
|
||||
],
|
||||
systems: [
|
||||
'Account View', 'Client', 'Cofano', 'Credentials', 'Diamant', 'Docuware',
|
||||
'EDI', 'eMail', 'Employee', 'Invoice', 'LBase', 'Medical Office', 'Network',
|
||||
'O365', 'PDF Viewer', 'Printer', 'Reports', 'Server', 'Time Tracking',
|
||||
'TK', 'TOS', 'Vivendi NG', 'VGM', '(W)LAN', '(W)WAN', 'WOMS', 'n/a'
|
||||
],
|
||||
responseLevels: [
|
||||
'USER', 'KEY USER', 'Helpdesk', 'Support', 'Admin', 'FS/FE', '24/7',
|
||||
'TECH MGMT', 'Backoffice', 'BUSI MGMT', 'n/a'
|
||||
],
|
||||
serviceTypes: ['Remote', 'On Site', 'Off Site'],
|
||||
priorities: [
|
||||
{ value: 0, label: 'None' },
|
||||
{ value: 1, label: 'Low' },
|
||||
{ value: 2, label: 'Medium' },
|
||||
{ value: 3, label: 'High' },
|
||||
{ value: 4, label: 'Critical' }
|
||||
]
|
||||
}
|
||||
|
||||
export function useAdminConfig() {
|
||||
const [config, setConfig] = useState(DEFAULT_CONFIG)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const fetchConfig = useCallback(async () => {
|
||||
if (DEMO_MODE) {
|
||||
setConfig(DEFAULT_CONFIG)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Versuche Config-Dokument zu laden (ID: 'config')
|
||||
try {
|
||||
const doc = await databases.getDocument(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.CONFIG || 'config',
|
||||
'config'
|
||||
)
|
||||
setConfig({
|
||||
ticketTypes: doc.ticketTypes || DEFAULT_CONFIG.ticketTypes,
|
||||
systems: doc.systems || DEFAULT_CONFIG.systems,
|
||||
responseLevels: doc.responseLevels || DEFAULT_CONFIG.responseLevels,
|
||||
serviceTypes: doc.serviceTypes || DEFAULT_CONFIG.serviceTypes,
|
||||
priorities: doc.priorities || DEFAULT_CONFIG.priorities
|
||||
})
|
||||
} catch (e) {
|
||||
// Config existiert noch nicht (404) - das ist normal, verwende Defaults
|
||||
if (e.code === 404 || e.message?.includes('not found')) {
|
||||
setConfig(DEFAULT_CONFIG)
|
||||
setError(null) // Kein Fehler, Collection existiert einfach noch nicht
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error('Error fetching config:', err)
|
||||
// Nur echte Fehler als Error setzen, nicht 404
|
||||
if (err.code !== 404 && !err.message?.includes('not found')) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(null)
|
||||
}
|
||||
setConfig(DEFAULT_CONFIG) // Fallback zu Defaults
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig()
|
||||
}, [fetchConfig])
|
||||
|
||||
const updateConfig = async (newConfig) => {
|
||||
if (DEMO_MODE) {
|
||||
setConfig(newConfig)
|
||||
localStorage.setItem('admin_config', JSON.stringify(newConfig))
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
try {
|
||||
const configData = {
|
||||
ticketTypes: newConfig.ticketTypes,
|
||||
systems: newConfig.systems,
|
||||
responseLevels: newConfig.responseLevels,
|
||||
serviceTypes: newConfig.serviceTypes,
|
||||
priorities: newConfig.priorities
|
||||
}
|
||||
|
||||
try {
|
||||
// Versuche zu aktualisieren
|
||||
await databases.updateDocument(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.CONFIG || 'config',
|
||||
'config',
|
||||
configData
|
||||
)
|
||||
} catch (e) {
|
||||
// Dokument existiert nicht (404) oder Collection existiert nicht
|
||||
if (e.code === 404 || e.message?.includes('not found')) {
|
||||
// Versuche zu erstellen
|
||||
try {
|
||||
await databases.createDocument(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.CONFIG || 'config',
|
||||
'config',
|
||||
configData
|
||||
)
|
||||
} catch (createErr) {
|
||||
// Collection existiert nicht - zeige hilfreiche Fehlermeldung
|
||||
if (createErr.code === 404 || createErr.message?.includes('Collection')) {
|
||||
throw new Error('Die "config" Collection existiert noch nicht. Bitte erstelle sie zuerst in Appwrite.')
|
||||
}
|
||||
throw createErr
|
||||
}
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
setConfig(newConfig)
|
||||
return { success: true }
|
||||
} catch (err) {
|
||||
console.error('Error updating config:', err)
|
||||
return { success: false, error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
loading,
|
||||
error,
|
||||
updateConfig,
|
||||
refresh: fetchConfig
|
||||
}
|
||||
}
|
||||
|
||||
121
src/hooks/useCustomers.js
Normal file
121
src/hooks/useCustomers.js
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { databases, DATABASE_ID, COLLECTIONS, ID, Query } from '../lib/appwrite'
|
||||
|
||||
const DEMO_MODE = !import.meta.env.VITE_APPWRITE_PROJECT_ID
|
||||
|
||||
// Demo-Kunden für Testing
|
||||
const DEMO_CUSTOMERS = [
|
||||
{ $id: '1', code: 'C001', name: 'Kunde A', location: 'Berlin', email: 'kunde.a@example.com', phone: '030-123456' },
|
||||
{ $id: '2', code: 'C002', name: 'Kunde B', location: 'München', email: 'kunde.b@example.com', phone: '089-654321' }
|
||||
]
|
||||
|
||||
export function useCustomers() {
|
||||
const [customers, setCustomers] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const fetchCustomers = useCallback(async () => {
|
||||
if (DEMO_MODE) {
|
||||
setCustomers(DEMO_CUSTOMERS)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await databases.listDocuments(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.CUSTOMERS,
|
||||
[Query.orderAsc('name')]
|
||||
)
|
||||
setCustomers(response.documents)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error('Error fetching customers:', err)
|
||||
// Wenn Collection nicht existiert, setze leeres Array (kein Fehler)
|
||||
if (err.code === 404 || err.message?.includes('not found')) {
|
||||
setCustomers([])
|
||||
setError(null) // Kein Fehler, Collection existiert einfach noch nicht
|
||||
} else {
|
||||
setError(err.message)
|
||||
setCustomers([])
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchCustomers()
|
||||
}, [fetchCustomers])
|
||||
|
||||
const createCustomer = async (data) => {
|
||||
if (DEMO_MODE) {
|
||||
const newCustomer = { ...data, $id: Date.now().toString() }
|
||||
setCustomers(prev => [...prev, newCustomer])
|
||||
return { success: true, data: newCustomer }
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await databases.createDocument(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.CUSTOMERS,
|
||||
ID.unique(),
|
||||
data
|
||||
)
|
||||
setCustomers(prev => [...prev, response])
|
||||
return { success: true, data: response }
|
||||
} catch (err) {
|
||||
return { success: false, error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
const updateCustomer = async (id, data) => {
|
||||
if (DEMO_MODE) {
|
||||
setCustomers(prev => prev.map(c => c.$id === id ? { ...c, ...data } : c))
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await databases.updateDocument(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.CUSTOMERS,
|
||||
id,
|
||||
data
|
||||
)
|
||||
setCustomers(prev => prev.map(c => c.$id === id ? response : c))
|
||||
return { success: true, data: response }
|
||||
} catch (err) {
|
||||
return { success: false, error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
const deleteCustomer = async (id) => {
|
||||
if (DEMO_MODE) {
|
||||
setCustomers(prev => prev.filter(c => c.$id !== id))
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
try {
|
||||
await databases.deleteDocument(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.CUSTOMERS,
|
||||
id
|
||||
)
|
||||
setCustomers(prev => prev.filter(c => c.$id !== id))
|
||||
return { success: true }
|
||||
} catch (err) {
|
||||
return { success: false, error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
customers,
|
||||
loading,
|
||||
error,
|
||||
refresh: fetchCustomers,
|
||||
createCustomer,
|
||||
updateCustomer,
|
||||
deleteCustomer
|
||||
}
|
||||
}
|
||||
|
||||
207
src/hooks/useEmployees.js
Normal file
207
src/hooks/useEmployees.js
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { databases, account, DATABASE_ID, COLLECTIONS, ID, Query } from '../lib/appwrite'
|
||||
|
||||
const DEMO_MODE = !import.meta.env.VITE_APPWRITE_PROJECT_ID
|
||||
|
||||
// Demo-Mitarbeiter für Testing
|
||||
const DEMO_EMPLOYEES = [
|
||||
{ $id: '1', userId: 'user1', displayName: 'Kenso Grimm', email: 'kenso@example.com', shortcode: 'KNSO' },
|
||||
{ $id: '2', userId: 'user2', displayName: 'Christian Lehmann', email: 'christian@example.com', shortcode: 'CHLE' }
|
||||
]
|
||||
|
||||
export function useEmployees() {
|
||||
const [employees, setEmployees] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
|
||||
const fetchEmployees = useCallback(async () => {
|
||||
setLoading(true)
|
||||
|
||||
if (DEMO_MODE) {
|
||||
setEmployees(DEMO_EMPLOYEES)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await databases.listDocuments(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.EMPLOYEES,
|
||||
[Query.orderAsc('displayName')]
|
||||
)
|
||||
setEmployees(response.documents)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error('Error fetching employees:', err)
|
||||
// Wenn Collection nicht existiert, setze leeres Array (kein Fehler)
|
||||
if (err.code === 404 || err.message?.includes('not found')) {
|
||||
setEmployees([])
|
||||
setError(null) // Kein Fehler, Collection existiert einfach noch nicht
|
||||
} else {
|
||||
setError(err.message)
|
||||
setEmployees([])
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchEmployees()
|
||||
}, [fetchEmployees])
|
||||
|
||||
const createEmployee = async (data) => {
|
||||
if (DEMO_MODE) {
|
||||
const newEmployee = { ...data, $id: Date.now().toString() }
|
||||
setEmployees(prev => [...prev, newEmployee])
|
||||
return { success: true, data: newEmployee }
|
||||
}
|
||||
|
||||
try {
|
||||
// Validierung
|
||||
if (!data.userId || !data.displayName) {
|
||||
return { success: false, error: 'userId und displayName sind erforderlich' }
|
||||
}
|
||||
|
||||
const response = await databases.createDocument(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.EMPLOYEES,
|
||||
ID.unique(),
|
||||
{
|
||||
userId: data.userId,
|
||||
displayName: data.displayName,
|
||||
email: data.email || '',
|
||||
shortcode: data.shortcode || ''
|
||||
}
|
||||
)
|
||||
setEmployees(prev => [...prev, response])
|
||||
return { success: true, data: response }
|
||||
} catch (err) {
|
||||
console.error('Error creating employee:', err)
|
||||
return { success: false, error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
const updateEmployee = async (id, data) => {
|
||||
if (DEMO_MODE) {
|
||||
setEmployees(prev => prev.map(e => e.$id === id ? { ...e, ...data } : e))
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await databases.updateDocument(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.EMPLOYEES,
|
||||
id,
|
||||
data
|
||||
)
|
||||
setEmployees(prev => prev.map(e => e.$id === id ? response : e))
|
||||
return { success: true, data: response }
|
||||
} catch (err) {
|
||||
console.error('Error updating employee:', err)
|
||||
return { success: false, error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
const deleteEmployee = async (id) => {
|
||||
if (DEMO_MODE) {
|
||||
setEmployees(prev => prev.filter(e => e.$id !== id))
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
try {
|
||||
await databases.deleteDocument(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.EMPLOYEES,
|
||||
id
|
||||
)
|
||||
setEmployees(prev => prev.filter(e => e.$id !== id))
|
||||
return { success: true }
|
||||
} catch (err) {
|
||||
console.error('Error deleting employee:', err)
|
||||
return { success: false, error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronisiert Appwrite Auth Users mit der employees Collection
|
||||
* Erstellt fehlende Einträge für neue Users
|
||||
*/
|
||||
const syncWithAuthUsers = async () => {
|
||||
if (DEMO_MODE) {
|
||||
return { success: true, message: 'Demo-Modus: Keine Synchronisierung nötig' }
|
||||
}
|
||||
|
||||
setSyncing(true)
|
||||
|
||||
try {
|
||||
// 1. Lade alle Appwrite Auth Users
|
||||
// Hinweis: In Appwrite 1.5.7 gibt es möglicherweise keine direkte List-Users API
|
||||
// für normale User. Diese Funktion benötigt Server-Side Code oder Admin-API-Key.
|
||||
// Für jetzt implementieren wir einen Workaround: Wir bieten ein manuelles Add-Interface.
|
||||
|
||||
// Alternative: Wenn der User Appwrite Admin ist, können wir versuchen:
|
||||
// const users = await account.listUsers() // Funktioniert nur mit Admin-Rechten
|
||||
|
||||
// Da das nicht direkt möglich ist, geben wir eine Info zurück
|
||||
return {
|
||||
success: false,
|
||||
error: 'Automatische Synchronisierung erfordert Admin-API-Zugriff. Bitte füge Mitarbeiter manuell hinzu oder verwende die Appwrite Server API.'
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error syncing auth users:', err)
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt einen Employee-Eintrag für den aktuell eingeloggten User
|
||||
* Nützlich für Self-Service
|
||||
*/
|
||||
const createSelfEmployee = async (shortcode = '') => {
|
||||
if (DEMO_MODE) {
|
||||
return { success: true, message: 'Demo-Modus' }
|
||||
}
|
||||
|
||||
try {
|
||||
// Hole aktuellen User
|
||||
const currentUser = await account.get()
|
||||
|
||||
// Prüfe, ob Employee bereits existiert
|
||||
const existing = employees.find(e => e.userId === currentUser.$id)
|
||||
if (existing) {
|
||||
return { success: false, error: 'Mitarbeiter-Eintrag existiert bereits' }
|
||||
}
|
||||
|
||||
// Erstelle Employee-Eintrag
|
||||
const result = await createEmployee({
|
||||
userId: currentUser.$id,
|
||||
displayName: currentUser.name || currentUser.email,
|
||||
email: currentUser.email,
|
||||
shortcode: shortcode
|
||||
})
|
||||
|
||||
return result
|
||||
} catch (err) {
|
||||
console.error('Error creating self employee:', err)
|
||||
return { success: false, error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
employees,
|
||||
loading,
|
||||
error,
|
||||
syncing,
|
||||
refresh: fetchEmployees,
|
||||
createEmployee,
|
||||
updateEmployee,
|
||||
deleteEmployee,
|
||||
syncWithAuthUsers,
|
||||
createSelfEmployee
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,11 @@ const DEMO_MODE = !import.meta.env.VITE_APPWRITE_PROJECT_ID
|
||||
|
||||
// Demo data for testing without Appwrite
|
||||
const DEMO_WORKORDERS = [
|
||||
{ $id: '1', title: 'Server Wartung', description: 'Monatliche Wartung', status: 'Open', priority: 2, type: 'Maintenance', customerName: 'Kunde A', assignedName: 'Max M.', response: 'Onsite', $createdAt: new Date().toISOString() },
|
||||
{ $id: '2', title: 'Netzwerk Problem', description: 'WLAN funktioniert nicht', status: 'Occupied', priority: 3, type: 'Support', customerName: 'Kunde B', assignedName: 'Lisa S.', response: 'Remote', $createdAt: new Date().toISOString() },
|
||||
{ $id: '3', title: 'Software Installation', description: 'Office 365 Setup', status: 'Assigned', priority: 1, type: 'Installation', customerName: 'Kunde C', assignedName: 'Tom K.', response: 'Onsite', $createdAt: new Date().toISOString() },
|
||||
{ $id: '4', title: 'Drucker defekt', description: 'Papierstau', status: 'Awaiting', priority: 2, type: 'Hardware', customerName: 'Kunde D', assignedName: '', response: 'Pickup', $createdAt: new Date().toISOString() },
|
||||
{ $id: '5', title: 'Kritischer Serverausfall', description: 'Produktionsserver down', status: 'Open', priority: 4, type: 'Emergency', customerName: 'Kunde E', assignedName: 'Max M.', response: 'Onsite', $createdAt: new Date().toISOString() },
|
||||
{ $id: '1', woid: '10001', title: 'Server Wartung', description: 'Monatliche Wartung', status: 'Open', priority: 2, type: 'Maintenance', customerName: 'Kunde A', assignedName: 'Max M.', response: 'Onsite', $createdAt: new Date().toISOString() },
|
||||
{ $id: '2', woid: '10002', title: 'Netzwerk Problem', description: 'WLAN funktioniert nicht', status: 'Occupied', priority: 3, type: 'Support', customerName: 'Kunde B', assignedName: 'Lisa S.', response: 'Remote', $createdAt: new Date().toISOString() },
|
||||
{ $id: '3', woid: '10003', title: 'Software Installation', description: 'Office 365 Setup', status: 'Assigned', priority: 1, type: 'Installation', customerName: 'Kunde C', assignedName: 'Tom K.', response: 'Onsite', $createdAt: new Date().toISOString() },
|
||||
{ $id: '4', woid: '10004', title: 'Drucker defekt', description: 'Papierstau', status: 'Awaiting', priority: 2, type: 'Hardware', customerName: 'Kunde D', assignedName: '', response: 'Pickup', $createdAt: new Date().toISOString() },
|
||||
{ $id: '5', woid: '10005', title: 'Kritischer Serverausfall', description: 'Produktionsserver down', status: 'Open', priority: 4, type: 'Emergency', customerName: 'Kunde E', assignedName: 'Max M.', response: 'Onsite', $createdAt: new Date().toISOString() },
|
||||
]
|
||||
|
||||
export function useWorkorders(filters = {}) {
|
||||
@@ -44,16 +44,27 @@ export function useWorkorders(filters = {}) {
|
||||
queries.push(Query.limit(filters.limit))
|
||||
}
|
||||
|
||||
// Für Arrays: In Appwrite 1.5.7 gibt es kein Query.or()
|
||||
// Wir filtern clientseitig für mehrere Werte
|
||||
if (filters.status && filters.status.length > 0) {
|
||||
queries.push(Query.equal('status', filters.status))
|
||||
if (filters.status.length === 1) {
|
||||
queries.push(Query.equal('status', filters.status[0]))
|
||||
}
|
||||
// Für mehrere Werte: Clientseitig filtern (siehe unten)
|
||||
}
|
||||
|
||||
if (filters.type && filters.type.length > 0) {
|
||||
queries.push(Query.equal('type', filters.type))
|
||||
if (filters.type.length === 1) {
|
||||
queries.push(Query.equal('type', filters.type[0]))
|
||||
}
|
||||
// Für mehrere Werte: Clientseitig filtern
|
||||
}
|
||||
|
||||
if (filters.priority && filters.priority.length > 0) {
|
||||
queries.push(Query.equal('priority', filters.priority))
|
||||
if (filters.priority.length === 1) {
|
||||
queries.push(Query.equal('priority', filters.priority[0]))
|
||||
}
|
||||
// Für mehrere Werte: Clientseitig filtern
|
||||
}
|
||||
|
||||
if (filters.customerId) {
|
||||
@@ -64,16 +75,48 @@ export function useWorkorders(filters = {}) {
|
||||
queries.push(Query.equal('assignedTo', filters.assignedTo))
|
||||
}
|
||||
|
||||
// Debug: Zeige Collection ID
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('📋 Fetching workorders:')
|
||||
console.log(' Database ID:', DATABASE_ID)
|
||||
console.log(' Collection ID:', COLLECTIONS.WORKORDERS)
|
||||
console.log(' Queries:', queries.length)
|
||||
}
|
||||
|
||||
const response = await databases.listDocuments(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.WORKORDERS,
|
||||
queries
|
||||
)
|
||||
|
||||
setWorkorders(response.documents)
|
||||
// Clientseitige Filterung für Arrays (da Query.or() nicht verfügbar ist)
|
||||
let filteredDocs = response.documents
|
||||
|
||||
if (filters.status && filters.status.length > 1) {
|
||||
filteredDocs = filteredDocs.filter(doc => filters.status.includes(doc.status))
|
||||
}
|
||||
|
||||
if (filters.type && filters.type.length > 1) {
|
||||
filteredDocs = filteredDocs.filter(doc => filters.type.includes(doc.type))
|
||||
}
|
||||
|
||||
if (filters.priority && filters.priority.length > 1) {
|
||||
filteredDocs = filteredDocs.filter(doc => filters.priority.includes(doc.priority))
|
||||
}
|
||||
|
||||
setWorkorders(filteredDocs)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
let errorMessage = err.message || 'Fehler beim Laden der Tickets'
|
||||
|
||||
// Bessere Fehlermeldungen
|
||||
if (err.code === 401 || errorMessage.includes('not authorized') || errorMessage.includes('Unauthorized')) {
|
||||
errorMessage = 'Berechtigung fehlt: Bitte überprüfe die Read-Berechtigungen der Collection in Appwrite. Die Collection muss "Users" oder "Any" als Read-Berechtigung haben.'
|
||||
} else if (errorMessage.includes('Collection') && errorMessage.includes('not found')) {
|
||||
errorMessage = 'Collection nicht gefunden: Bitte überprüfe die Collection ID in der Konfiguration.'
|
||||
}
|
||||
|
||||
setError(errorMessage)
|
||||
console.error('Error fetching workorders:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@@ -86,26 +129,107 @@ export function useWorkorders(filters = {}) {
|
||||
|
||||
const createWorkorder = async (data) => {
|
||||
if (DEMO_MODE) {
|
||||
const newWo = { ...data, $id: Date.now().toString(), status: 'Open', $createdAt: new Date().toISOString() }
|
||||
// Finde höchste WOID und +1
|
||||
const maxWoid = workorders.length > 0
|
||||
? Math.max(...workorders.map(wo => parseInt(wo.woid)).filter(w => !isNaN(w)))
|
||||
: 9999;
|
||||
const nextWoid = maxWoid + 1;
|
||||
|
||||
const newWo = { ...data, $id: Date.now().toString(), woid: nextWoid.toString(), status: 'Open', $createdAt: new Date().toISOString() }
|
||||
setWorkorders(prev => [newWo, ...prev])
|
||||
return { success: true, data: newWo }
|
||||
}
|
||||
|
||||
try {
|
||||
// Validierung: Prüfe required fields
|
||||
if (!data.topic || data.topic.trim() === '') {
|
||||
return { success: false, error: 'Das Feld "Topic" ist erforderlich.' }
|
||||
}
|
||||
|
||||
// Status-Automatik: Wenn Mitarbeiter zugewiesen → Status = "Assigned", sonst "Open"
|
||||
const autoStatus = (data.assignedTo && data.assignedTo !== '') ? 'Assigned' : 'Open'
|
||||
|
||||
// Generiere sequentielle 5-stellige WOID (wie im Original-System)
|
||||
const generateWOID = () => {
|
||||
// Finde die höchste bestehende WOID
|
||||
if (workorders.length === 0) {
|
||||
return '10000'; // Starte bei 10000 wenn keine Tickets existieren
|
||||
}
|
||||
|
||||
const maxWoid = Math.max(
|
||||
...workorders
|
||||
.map(wo => parseInt(wo.woid))
|
||||
.filter(woid => !isNaN(woid) && woid > 0)
|
||||
);
|
||||
|
||||
// Wenn keine gültige WOID gefunden wurde, starte bei 10000
|
||||
if (maxWoid === -Infinity || isNaN(maxWoid)) {
|
||||
return '10000';
|
||||
}
|
||||
|
||||
// Gib die nächste Nummer zurück (sequentiell)
|
||||
return (maxWoid + 1).toString();
|
||||
}
|
||||
|
||||
// Bereite Daten für Appwrite vor
|
||||
const workorderData = {
|
||||
// Required fields
|
||||
topic: data.topic.trim(),
|
||||
status: data.status || autoStatus, // Verwende übergebenen Status oder automatischen Status
|
||||
priority: typeof data.priority === 'number' ? data.priority : parseInt(data.priority) || 1,
|
||||
woid: generateWOID(), // 5-stellige Zahl
|
||||
|
||||
// Optional fields - nur senden wenn vorhanden
|
||||
type: data.type || '',
|
||||
systemType: data.systemType || '',
|
||||
responseLevel: data.responseLevel || '',
|
||||
serviceType: data.serviceType || 'Remote',
|
||||
customerId: data.customerId || '',
|
||||
assignedTo: data.assignedTo || '', // Zugewiesener Mitarbeiter
|
||||
requestedBy: data.requestedBy || '',
|
||||
requestedFor: data.requestedFor || '',
|
||||
startDate: data.startDate || '',
|
||||
startTime: data.startTime || '',
|
||||
deadline: data.deadline || '',
|
||||
endTime: data.endTime || '',
|
||||
estimate: data.estimate || '',
|
||||
mailCopyTo: data.mailCopyTo || '',
|
||||
sendNotification: data.sendNotification || false,
|
||||
details: data.details || '',
|
||||
|
||||
// Datetime field
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
// Entferne leere Strings (außer für required fields)
|
||||
Object.keys(workorderData).forEach(key => {
|
||||
if (workorderData[key] === '' && key !== 'topic' && key !== 'status') {
|
||||
delete workorderData[key]
|
||||
}
|
||||
})
|
||||
|
||||
console.log('Creating workorder with data:', workorderData)
|
||||
|
||||
const response = await databases.createDocument(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.WORKORDERS,
|
||||
ID.unique(),
|
||||
{
|
||||
...data,
|
||||
status: 'Open',
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
workorderData
|
||||
)
|
||||
setWorkorders(prev => [response, ...prev])
|
||||
return { success: true, data: response }
|
||||
} catch (err) {
|
||||
return { success: false, error: err.message }
|
||||
console.error('Error creating workorder:', err)
|
||||
let errorMessage = err.message || 'Fehler beim Erstellen des Tickets'
|
||||
|
||||
// Bessere Fehlermeldungen
|
||||
if (err.code === 400 || errorMessage.includes('Bad Request')) {
|
||||
errorMessage = 'Ungültige Daten: Bitte überprüfe, ob alle Pflichtfelder ausgefüllt sind und die Daten korrekt sind. Details: ' + (err.message || 'Unbekannter Fehler')
|
||||
} else if (errorMessage.includes('required') || errorMessage.includes('missing')) {
|
||||
errorMessage = 'Pflichtfelder fehlen: Bitte fülle alle erforderlichen Felder aus.'
|
||||
}
|
||||
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,11 +240,27 @@ export function useWorkorders(filters = {}) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Status-Automatik beim Update:
|
||||
// Wenn assignedTo gesetzt wird → Status = "Assigned"
|
||||
// Wenn assignedTo entfernt wird UND Status = "Assigned" → Status = "Open"
|
||||
const updateData = { ...data }
|
||||
if ('assignedTo' in updateData) {
|
||||
if (updateData.assignedTo && updateData.assignedTo !== '') {
|
||||
// Mitarbeiter zugewiesen → Status auf "Assigned" setzen
|
||||
if (!updateData.status) {
|
||||
updateData.status = 'Assigned'
|
||||
}
|
||||
} else if (!updateData.status) {
|
||||
// Keine Zuweisung mehr → Status auf "Open" setzen (nur wenn nicht explizit anders gesetzt)
|
||||
updateData.status = 'Open'
|
||||
}
|
||||
}
|
||||
|
||||
const response = await databases.updateDocument(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.WORKORDERS,
|
||||
id,
|
||||
data
|
||||
updateData
|
||||
)
|
||||
setWorkorders(prev =>
|
||||
prev.map(wo => wo.$id === id ? response : wo)
|
||||
|
||||
362
src/hooks/useWorksheets.js
Normal file
362
src/hooks/useWorksheets.js
Normal file
@@ -0,0 +1,362 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { databases, DATABASE_ID, COLLECTIONS, Query, ID } from '../lib/appwrite'
|
||||
|
||||
const DEMO_MODE = !import.meta.env.VITE_APPWRITE_PROJECT_ID
|
||||
|
||||
// Demo data für Testing
|
||||
const DEMO_WORKSHEETS = [
|
||||
{
|
||||
$id: '1',
|
||||
wsid: '100001',
|
||||
woid: '10001',
|
||||
workorderId: '1',
|
||||
employeeId: 'emp1',
|
||||
employeeName: 'Max Müller',
|
||||
employeeShort: 'MAMU',
|
||||
serviceType: 'Remote',
|
||||
oldStatus: 'Open',
|
||||
newStatus: 'Occupied',
|
||||
totalTime: 30,
|
||||
startDate: '29.12.2025',
|
||||
startTime: '1000',
|
||||
endDate: '29.12.2025',
|
||||
endTime: '1030',
|
||||
details: 'Router neu gestartet',
|
||||
isComment: false,
|
||||
$createdAt: new Date().toISOString()
|
||||
},
|
||||
]
|
||||
|
||||
export function useWorksheets(woid = null) {
|
||||
const [worksheets, setWorksheets] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const fetchWorksheets = useCallback(async () => {
|
||||
setLoading(true)
|
||||
|
||||
if (DEMO_MODE) {
|
||||
// Filter demo data by WOID if provided
|
||||
const filtered = woid
|
||||
? DEMO_WORKSHEETS.filter(ws => ws.woid === woid)
|
||||
: DEMO_WORKSHEETS
|
||||
setWorksheets(filtered)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const queries = [Query.orderDesc('$createdAt')]
|
||||
|
||||
// Filter by WOID if provided
|
||||
if (woid) {
|
||||
queries.push(Query.equal('woid', woid))
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('📋 Fetching worksheets:')
|
||||
console.log(' Database ID:', DATABASE_ID)
|
||||
console.log(' Collection ID:', COLLECTIONS.WORKSHEETS)
|
||||
console.log(' WOID Filter:', woid || 'none')
|
||||
}
|
||||
|
||||
const response = await databases.listDocuments(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.WORKSHEETS,
|
||||
queries
|
||||
)
|
||||
|
||||
setWorksheets(response.documents)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
let errorMessage = err.message || 'Fehler beim Laden der Worksheets'
|
||||
|
||||
if (err.code === 401 || errorMessage.includes('not authorized')) {
|
||||
errorMessage = 'Berechtigung fehlt: Bitte überprüfe die Read-Berechtigungen der Worksheets Collection.'
|
||||
} else if (errorMessage.includes('Collection') && errorMessage.includes('not found')) {
|
||||
errorMessage = 'Worksheets Collection nicht gefunden. Bitte erstelle die Collection in Appwrite (siehe WORKSHEETS_COLLECTION_SETUP.md).'
|
||||
}
|
||||
|
||||
setError(errorMessage)
|
||||
console.error('Error fetching worksheets:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [woid])
|
||||
|
||||
useEffect(() => {
|
||||
fetchWorksheets()
|
||||
}, [fetchWorksheets])
|
||||
|
||||
/**
|
||||
* Generiert eine eindeutige 6-stellige WSID
|
||||
* Startet bei 100000 und zählt sequentiell hoch
|
||||
*/
|
||||
const generateWSID = useCallback(async () => {
|
||||
if (DEMO_MODE) {
|
||||
const maxWsid = worksheets.length > 0
|
||||
? Math.max(...worksheets.map(ws => parseInt(ws.wsid)).filter(w => !isNaN(w)))
|
||||
: 99999
|
||||
return (maxWsid + 1).toString()
|
||||
}
|
||||
|
||||
try {
|
||||
// Hole ALLE Worksheets (nicht gefiltert) um höchste WSID zu finden
|
||||
const response = await databases.listDocuments(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.WORKSHEETS,
|
||||
[Query.orderDesc('wsid'), Query.limit(1)]
|
||||
)
|
||||
|
||||
if (response.documents.length === 0) {
|
||||
return '100000' // Erste WSID
|
||||
}
|
||||
|
||||
const highestWsid = parseInt(response.documents[0].wsid)
|
||||
|
||||
if (isNaN(highestWsid)) {
|
||||
console.warn('Ungültige WSID gefunden, starte bei 100000')
|
||||
return '100000'
|
||||
}
|
||||
|
||||
return (highestWsid + 1).toString()
|
||||
} catch (err) {
|
||||
console.error('Error generating WSID:', err)
|
||||
// Fallback: Verwende lokale Worksheets
|
||||
const maxWsid = worksheets.length > 0
|
||||
? Math.max(...worksheets.map(ws => parseInt(ws.wsid)).filter(w => !isNaN(w)))
|
||||
: 99999
|
||||
return (maxWsid + 1).toString()
|
||||
}
|
||||
}, [worksheets])
|
||||
|
||||
/**
|
||||
* Berechnet Arbeitszeit aus Start- und Endzeit
|
||||
* Format: "1000" = 10:00, "1430" = 14:30
|
||||
* @returns Minuten oder null wenn ungültig
|
||||
*/
|
||||
const calculateTime = (startTime, endTime) => {
|
||||
if (!startTime || !endTime) return null
|
||||
|
||||
try {
|
||||
const startHour = parseInt(startTime.substring(0, 2))
|
||||
const startMin = parseInt(startTime.substring(2, 4))
|
||||
const endHour = parseInt(endTime.substring(0, 2))
|
||||
const endMin = parseInt(endTime.substring(2, 4))
|
||||
|
||||
if (isNaN(startHour) || isNaN(startMin) || isNaN(endHour) || isNaN(endMin)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const startTotal = startHour * 60 + startMin
|
||||
const endTotal = endHour * 60 + endMin
|
||||
|
||||
let diff = endTotal - startTotal
|
||||
|
||||
// Handle overnight (z.B. 23:00 - 01:00)
|
||||
if (diff < 0) {
|
||||
diff += 24 * 60
|
||||
}
|
||||
|
||||
return diff
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt ein neues Worksheet
|
||||
*/
|
||||
const createWorksheet = async (data, currentUser) => {
|
||||
if (DEMO_MODE) {
|
||||
const wsid = await generateWSID()
|
||||
const newWs = {
|
||||
...data,
|
||||
$id: Date.now().toString(),
|
||||
wsid,
|
||||
$createdAt: new Date().toISOString()
|
||||
}
|
||||
setWorksheets(prev => [newWs, ...prev])
|
||||
return { success: true, data: newWs }
|
||||
}
|
||||
|
||||
try {
|
||||
// Validierung
|
||||
if (!data.woid || data.woid.trim() === '') {
|
||||
return { success: false, error: 'WOID ist erforderlich' }
|
||||
}
|
||||
if (!data.workorderId || data.workorderId.trim() === '') {
|
||||
return { success: false, error: 'Work Order ID ist erforderlich' }
|
||||
}
|
||||
if (!data.details || data.details.trim() === '') {
|
||||
return { success: false, error: 'Details sind erforderlich' }
|
||||
}
|
||||
|
||||
// WSID generieren
|
||||
const wsid = await generateWSID()
|
||||
|
||||
// Automatische Zeitberechnung (wenn nicht manuell angegeben)
|
||||
let totalTime = data.totalTime || 0
|
||||
if (!data.isComment && data.startTime && data.endTime && !data.totalTime) {
|
||||
const calculatedTime = calculateTime(data.startTime, data.endTime)
|
||||
if (calculatedTime !== null) {
|
||||
totalTime = calculatedTime
|
||||
}
|
||||
}
|
||||
|
||||
// Worksheet-Daten vorbereiten
|
||||
const worksheetData = {
|
||||
wsid,
|
||||
woid: data.woid.trim(),
|
||||
workorderId: data.workorderId.trim(),
|
||||
employeeId: currentUser.$id,
|
||||
employeeName: currentUser.name || currentUser.email,
|
||||
employeeShort: data.employeeShort || '',
|
||||
serviceType: data.serviceType || 'Remote',
|
||||
oldStatus: data.oldStatus || '',
|
||||
newStatus: data.newStatus || data.oldStatus || '',
|
||||
oldResponseLevel: data.oldResponseLevel || '',
|
||||
newResponseLevel: data.newResponseLevel || data.oldResponseLevel || '',
|
||||
totalTime: parseInt(totalTime) || 0,
|
||||
startDate: data.startDate || '',
|
||||
startTime: data.startTime || '',
|
||||
endDate: data.endDate || data.startDate || '',
|
||||
endTime: data.endTime || '',
|
||||
details: data.details.trim(),
|
||||
isComment: data.isComment || false,
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
console.log('Creating worksheet with data:', worksheetData)
|
||||
|
||||
const response = await databases.createDocument(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.WORKSHEETS,
|
||||
ID.unique(),
|
||||
worksheetData
|
||||
)
|
||||
|
||||
setWorksheets(prev => [response, ...prev])
|
||||
return { success: true, data: response }
|
||||
} catch (err) {
|
||||
console.error('Error creating worksheet:', err)
|
||||
return {
|
||||
success: false,
|
||||
error: err.message || 'Fehler beim Erstellen des Worksheets'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert ein Worksheet
|
||||
*/
|
||||
const updateWorksheet = async (id, data) => {
|
||||
if (DEMO_MODE) {
|
||||
setWorksheets(prev => prev.map(ws => ws.$id === id ? { ...ws, ...data } : ws))
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await databases.updateDocument(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.WORKSHEETS,
|
||||
id,
|
||||
data
|
||||
)
|
||||
setWorksheets(prev => prev.map(ws => ws.$id === id ? response : ws))
|
||||
return { success: true, data: response }
|
||||
} catch (err) {
|
||||
console.error('Error updating worksheet:', err)
|
||||
return { success: false, error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht ein Worksheet (sollte normalerweise nicht erlaubt sein - Audit Trail!)
|
||||
*/
|
||||
const deleteWorksheet = async (id) => {
|
||||
if (DEMO_MODE) {
|
||||
setWorksheets(prev => prev.filter(ws => ws.$id !== id))
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
try {
|
||||
await databases.deleteDocument(
|
||||
DATABASE_ID,
|
||||
COLLECTIONS.WORKSHEETS,
|
||||
id
|
||||
)
|
||||
setWorksheets(prev => prev.filter(ws => ws.$id !== id))
|
||||
return { success: true }
|
||||
} catch (err) {
|
||||
console.error('Error deleting worksheet:', err)
|
||||
return { success: false, error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Berechnet die Gesamtarbeitszeit für alle Worksheets
|
||||
* @returns Minuten
|
||||
*/
|
||||
const getTotalTime = useCallback(() => {
|
||||
return worksheets
|
||||
.filter(ws => !ws.isComment)
|
||||
.reduce((sum, ws) => sum + (ws.totalTime || 0), 0)
|
||||
}, [worksheets])
|
||||
|
||||
/**
|
||||
* Gruppiert Worksheets nach Mitarbeiter
|
||||
* @returns Object mit employeeId als Key
|
||||
*/
|
||||
const getWorksheetsByEmployee = useCallback(() => {
|
||||
return worksheets.reduce((acc, ws) => {
|
||||
const empId = ws.employeeId
|
||||
if (!acc[empId]) {
|
||||
acc[empId] = {
|
||||
employeeName: ws.employeeName,
|
||||
employeeShort: ws.employeeShort,
|
||||
worksheets: [],
|
||||
totalTime: 0
|
||||
}
|
||||
}
|
||||
acc[empId].worksheets.push(ws)
|
||||
if (!ws.isComment) {
|
||||
acc[empId].totalTime += ws.totalTime || 0
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
}, [worksheets])
|
||||
|
||||
/**
|
||||
* Gibt die Status-Historie zurück (chronologisch)
|
||||
*/
|
||||
const getStatusHistory = useCallback(() => {
|
||||
return worksheets
|
||||
.filter(ws => ws.oldStatus && ws.newStatus)
|
||||
.map(ws => ({
|
||||
wsid: ws.wsid,
|
||||
date: ws.startDate,
|
||||
time: ws.startTime,
|
||||
employee: ws.employeeName,
|
||||
from: ws.oldStatus,
|
||||
to: ws.newStatus,
|
||||
details: ws.details
|
||||
}))
|
||||
.reverse() // Älteste zuerst
|
||||
}, [worksheets])
|
||||
|
||||
return {
|
||||
worksheets,
|
||||
loading,
|
||||
error,
|
||||
createWorksheet,
|
||||
updateWorksheet,
|
||||
deleteWorksheet,
|
||||
refresh: fetchWorksheets,
|
||||
getTotalTime,
|
||||
getWorksheetsByEmployee,
|
||||
getStatusHistory,
|
||||
calculateTime
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
import { Client, Account, Databases, Storage, ID, Query } from 'appwrite'
|
||||
|
||||
// Debug: Zeige geladene Umgebungsvariablen (nur in Development)
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🔧 Appwrite Konfiguration:')
|
||||
console.log('Endpoint:', import.meta.env.VITE_APPWRITE_ENDPOINT || 'NICHT GESETZT')
|
||||
console.log('Project ID:', import.meta.env.VITE_APPWRITE_PROJECT_ID || 'NICHT GESETZT')
|
||||
console.log('Database ID:', import.meta.env.VITE_APPWRITE_DATABASE_ID || 'NICHT GESETZT')
|
||||
}
|
||||
|
||||
const endpoint = import.meta.env.VITE_APPWRITE_ENDPOINT || 'https://appwrite.webklar.com/v1'
|
||||
const projectId = import.meta.env.VITE_APPWRITE_PROJECT_ID || ''
|
||||
|
||||
if (!projectId) {
|
||||
console.error('❌ FEHLER: VITE_APPWRITE_PROJECT_ID ist nicht gesetzt!')
|
||||
console.error('Bitte überprüfe deine .env Datei im Root-Verzeichnis.')
|
||||
}
|
||||
|
||||
const client = new Client()
|
||||
.setEndpoint(import.meta.env.VITE_APPWRITE_ENDPOINT || 'https://cloud.appwrite.io/v1')
|
||||
.setProject(import.meta.env.VITE_APPWRITE_PROJECT_ID || '')
|
||||
.setEndpoint(endpoint)
|
||||
.setProject(projectId)
|
||||
|
||||
export const account = new Account(client)
|
||||
export const databases = new Databases(client)
|
||||
@@ -10,11 +26,13 @@ export const storage = new Storage(client)
|
||||
|
||||
export const DATABASE_ID = import.meta.env.VITE_APPWRITE_DATABASE_ID || 'woms-database'
|
||||
|
||||
// Collection IDs
|
||||
// Collection IDs - Verwende die tatsächlichen Collection IDs aus Appwrite!
|
||||
export const COLLECTIONS = {
|
||||
WORKORDERS: 'workorders',
|
||||
WORKSHEETS: 'worksheets',
|
||||
CUSTOMERS: 'customers',
|
||||
WORKORDERS: '6943bf7d001901baa60c', // Collection ID für workorders
|
||||
CONFIG: 'config', // Collection ID für Admin-Konfiguration (wird erstellt)
|
||||
CUSTOMERS: '694bd1fb002b2e583d13', // Collection ID für customers
|
||||
EMPLOYEES: '695280510031c6c6153b', // Collection ID für employees
|
||||
WORKSHEETS: '6952dbcf0032a92e1168', // Collection ID für worksheets ✅
|
||||
USERS: 'users',
|
||||
ATTACHMENTS: 'attachments'
|
||||
}
|
||||
|
||||
7
src/lib/utils.js
Normal file
7
src/lib/utils.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
698
src/pages/AdminPage.jsx
Normal file
698
src/pages/AdminPage.jsx
Normal file
@@ -0,0 +1,698 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAdminConfig } from '../hooks/useAdminConfig'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useCustomers } from '../hooks/useCustomers'
|
||||
import { useEmployees } from '../hooks/useEmployees'
|
||||
import { FaPlus, FaTrash, FaFloppyDisk, FaSpinner } from 'react-icons/fa6'
|
||||
import { FaEdit } from 'react-icons/fa'
|
||||
|
||||
export default function AdminPage() {
|
||||
const { user, isAdmin } = useAuth()
|
||||
const { config, loading, error, updateConfig } = useAdminConfig()
|
||||
const { customers, loading: customersLoading, createCustomer, updateCustomer, deleteCustomer, refresh: refreshCustomers } = useCustomers()
|
||||
const { employees, loading: employeesLoading, createEmployee, updateEmployee, deleteEmployee, refresh: refreshEmployees } = useEmployees()
|
||||
const [localConfig, setLocalConfig] = useState(() => {
|
||||
// Initialisiere mit Default-Werten falls config noch nicht geladen
|
||||
if (config && Object.keys(config).length > 0) {
|
||||
return config
|
||||
}
|
||||
return {
|
||||
ticketTypes: [],
|
||||
systems: [],
|
||||
responseLevels: [],
|
||||
serviceTypes: [],
|
||||
priorities: []
|
||||
}
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveMessage, setSaveMessage] = useState('')
|
||||
const [editingCustomer, setEditingCustomer] = useState(null)
|
||||
const [customerForm, setCustomerForm] = useState({ code: '', name: '', location: '', email: '', phone: '' })
|
||||
const [editingEmployee, setEditingEmployee] = useState(null)
|
||||
const [employeeForm, setEmployeeForm] = useState({ userId: '', displayName: '', email: '', shortcode: '' })
|
||||
|
||||
// Update localConfig when config loads
|
||||
useEffect(() => {
|
||||
if (config && Object.keys(config).length > 0) {
|
||||
setLocalConfig(config)
|
||||
}
|
||||
}, [config])
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="main-content">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2>Zugriff verweigert</h2>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<p>Du hast keine Berechtigung, auf diese Seite zuzugreifen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleAddItem = (field) => {
|
||||
setLocalConfig(prev => ({
|
||||
...prev,
|
||||
[field]: [...prev[field], field === 'priorities' ? { value: prev[field].length, label: 'New' } : 'New Item']
|
||||
}))
|
||||
}
|
||||
|
||||
const handleRemoveItem = (field, index) => {
|
||||
setLocalConfig(prev => ({
|
||||
...prev,
|
||||
[field]: prev[field].filter((_, i) => i !== index)
|
||||
}))
|
||||
}
|
||||
|
||||
const handleUpdateItem = (field, index, value) => {
|
||||
setLocalConfig(prev => {
|
||||
const newArray = [...prev[field]]
|
||||
if (field === 'priorities') {
|
||||
newArray[index] = { ...newArray[index], ...value }
|
||||
} else {
|
||||
newArray[index] = value
|
||||
}
|
||||
return { ...prev, [field]: newArray }
|
||||
})
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setSaveMessage('')
|
||||
|
||||
const result = await updateConfig(localConfig)
|
||||
|
||||
if (result.success) {
|
||||
setSaveMessage('Konfiguration erfolgreich gespeichert!')
|
||||
setTimeout(() => setSaveMessage(''), 3000)
|
||||
} else {
|
||||
setSaveMessage('Fehler beim Speichern: ' + (result.error || 'Unbekannter Fehler'))
|
||||
}
|
||||
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
if (loading && !config) {
|
||||
return (
|
||||
<div className="main-content text-center p-4">
|
||||
<FaSpinner className="spinner" size={32} />
|
||||
<p>Lade Konfiguration...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<header className="text-center mb-2">
|
||||
<h2>Admin Panel - Dropdown Konfiguration</h2>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
|
||||
Fehler: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveMessage && (
|
||||
<div className={`p-2 mb-2 ${saveMessage.includes('erfolgreich') ? 'bg-green text-white' : 'bg-red text-white'}`} style={{ borderRadius: '4px' }}>
|
||||
{saveMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="row">
|
||||
{/* Ticket Types */}
|
||||
<div className="col col-6">
|
||||
<div className="card mb-2">
|
||||
<div className="card-header">
|
||||
<h3>Work Order Types</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{localConfig.ticketTypes?.map((type, index) => (
|
||||
<div key={index} className="form-group" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={type}
|
||||
onChange={(e) => handleUpdateItem('ticketTypes', index, e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-red"
|
||||
onClick={() => handleRemoveItem('ticketTypes', index)}
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
className="btn btn-green"
|
||||
onClick={() => handleAddItem('ticketTypes')}
|
||||
style={{ width: '100%', marginTop: '8px' }}
|
||||
>
|
||||
<FaPlus /> Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Systems */}
|
||||
<div className="col col-6">
|
||||
<div className="card mb-2">
|
||||
<div className="card-header">
|
||||
<h3>Affected Systems</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{localConfig.systems?.map((system, index) => (
|
||||
<div key={index} className="form-group" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={system}
|
||||
onChange={(e) => handleUpdateItem('systems', index, e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-red"
|
||||
onClick={() => handleRemoveItem('systems', index)}
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
className="btn btn-green"
|
||||
onClick={() => handleAddItem('systems')}
|
||||
style={{ width: '100%', marginTop: '8px' }}
|
||||
>
|
||||
<FaPlus /> Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row">
|
||||
{/* Response Levels */}
|
||||
<div className="col col-6">
|
||||
<div className="card mb-2">
|
||||
<div className="card-header">
|
||||
<h3>Response Levels</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{localConfig.responseLevels?.map((level, index) => (
|
||||
<div key={index} className="form-group" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={level}
|
||||
onChange={(e) => handleUpdateItem('responseLevels', index, e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-red"
|
||||
onClick={() => handleRemoveItem('responseLevels', index)}
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
className="btn btn-green"
|
||||
onClick={() => handleAddItem('responseLevels')}
|
||||
style={{ width: '100%', marginTop: '8px' }}
|
||||
>
|
||||
<FaPlus /> Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service Types */}
|
||||
<div className="col col-6">
|
||||
<div className="card mb-2">
|
||||
<div className="card-header">
|
||||
<h3>Service Types</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{localConfig.serviceTypes?.map((type, index) => (
|
||||
<div key={index} className="form-group" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={type}
|
||||
onChange={(e) => handleUpdateItem('serviceTypes', index, e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-red"
|
||||
onClick={() => handleRemoveItem('serviceTypes', index)}
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
className="btn btn-green"
|
||||
onClick={() => handleAddItem('serviceTypes')}
|
||||
style={{ width: '100%', marginTop: '8px' }}
|
||||
>
|
||||
<FaPlus /> Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priorities */}
|
||||
<div className="card mb-2">
|
||||
<div className="card-header">
|
||||
<h3>Priorities</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{localConfig.priorities?.map((priority, index) => (
|
||||
<div key={index} className="form-group" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
value={priority.value}
|
||||
onChange={(e) => handleUpdateItem('priorities', index, { ...priority, value: parseInt(e.target.value) })}
|
||||
style={{ width: '100px' }}
|
||||
placeholder="Value"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={priority.label}
|
||||
onChange={(e) => handleUpdateItem('priorities', index, { ...priority, label: e.target.value })}
|
||||
style={{ flex: 1 }}
|
||||
placeholder="Label"
|
||||
/>
|
||||
<button
|
||||
className="btn btn-red"
|
||||
onClick={() => handleRemoveItem('priorities', index)}
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
className="btn btn-green"
|
||||
onClick={() => handleAddItem('priorities')}
|
||||
style={{ width: '100%', marginTop: '8px' }}
|
||||
>
|
||||
<FaPlus /> Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Customers */}
|
||||
<div className="card mb-2">
|
||||
<div className="card-header">
|
||||
<h3>Customers</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{customersLoading ? (
|
||||
<div className="text-center p-2">
|
||||
<FaSpinner className="spinner" /> Lade Kunden...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="table" style={{ marginBottom: '16px' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Code</th>
|
||||
<th>Name</th>
|
||||
<th>Location</th>
|
||||
<th>Email</th>
|
||||
<th>Phone</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{customers.map((customer) => (
|
||||
<tr key={customer.$id}>
|
||||
{editingCustomer === customer.$id ? (
|
||||
<>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={customerForm.code}
|
||||
onChange={(e) => setCustomerForm(prev => ({ ...prev, code: e.target.value }))}
|
||||
style={{ width: '100px' }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={customerForm.name}
|
||||
onChange={(e) => setCustomerForm(prev => ({ ...prev, name: e.target.value }))}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={customerForm.location}
|
||||
onChange={(e) => setCustomerForm(prev => ({ ...prev, location: e.target.value }))}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="email"
|
||||
className="form-control"
|
||||
value={customerForm.email}
|
||||
onChange={(e) => setCustomerForm(prev => ({ ...prev, email: e.target.value }))}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={customerForm.phone}
|
||||
onChange={(e) => setCustomerForm(prev => ({ ...prev, phone: e.target.value }))}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-green"
|
||||
onClick={async () => {
|
||||
const result = await updateCustomer(customer.$id, customerForm)
|
||||
if (result.success) {
|
||||
setEditingCustomer(null)
|
||||
setCustomerForm({ code: '', name: '', location: '', email: '', phone: '' })
|
||||
}
|
||||
}}
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
setEditingCustomer(null)
|
||||
setCustomerForm({ code: '', name: '', location: '', email: '', phone: '' })
|
||||
}}
|
||||
style={{ marginLeft: '4px' }}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td>{customer.code || '-'}</td>
|
||||
<td>{customer.name || '-'}</td>
|
||||
<td>{customer.location || '-'}</td>
|
||||
<td>{customer.email || '-'}</td>
|
||||
<td>{customer.phone || '-'}</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
setEditingCustomer(customer.$id)
|
||||
setCustomerForm({
|
||||
code: customer.code || '',
|
||||
name: customer.name || '',
|
||||
location: customer.location || '',
|
||||
email: customer.email || '',
|
||||
phone: customer.phone || ''
|
||||
})
|
||||
}}
|
||||
>
|
||||
<FaEdit />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-red"
|
||||
onClick={async () => {
|
||||
if (confirm(`Möchtest du ${customer.name || customer.code} wirklich löschen?`)) {
|
||||
await deleteCustomer(customer.$id)
|
||||
}
|
||||
}}
|
||||
style={{ marginLeft: '4px' }}
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="card" style={{ background: '#f5f5f5', padding: '16px' }}>
|
||||
<h4 style={{ marginTop: 0 }}>Neuen Kunden hinzufügen</h4>
|
||||
<div className="row">
|
||||
<div className="col col-3">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Code</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={customerForm.code}
|
||||
onChange={(e) => setCustomerForm(prev => ({ ...prev, code: e.target.value }))}
|
||||
placeholder="C001"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col col-3">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={customerForm.name}
|
||||
onChange={(e) => setCustomerForm(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="Kundenname"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col col-3">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Location</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={customerForm.location}
|
||||
onChange={(e) => setCustomerForm(prev => ({ ...prev, location: e.target.value }))}
|
||||
placeholder="Stadt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col col-3">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
className="form-control"
|
||||
value={customerForm.email}
|
||||
onChange={(e) => setCustomerForm(prev => ({ ...prev, email: e.target.value }))}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col col-3">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Phone</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={customerForm.phone}
|
||||
onChange={(e) => setCustomerForm(prev => ({ ...prev, phone: e.target.value }))}
|
||||
placeholder="030-123456"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col col-9">
|
||||
<div className="form-group" style={{ marginTop: '24px' }}>
|
||||
<button
|
||||
className="btn btn-green"
|
||||
onClick={async () => {
|
||||
if (!customerForm.name) {
|
||||
alert('Bitte gib mindestens einen Namen ein.')
|
||||
return
|
||||
}
|
||||
const result = await createCustomer(customerForm)
|
||||
if (result.success) {
|
||||
setCustomerForm({ code: '', name: '', location: '', email: '', phone: '' })
|
||||
} else {
|
||||
alert('Fehler beim Erstellen: ' + (result.error || 'Unbekannter Fehler'))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FaPlus /> Kunden hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Employees */}
|
||||
<div className="card mb-2">
|
||||
<div className="card-header">
|
||||
<h3>Mitarbeiter & Kürzel</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{employeesLoading ? (
|
||||
<div className="text-center p-2">
|
||||
<FaSpinner className="spinner" /> Lade Mitarbeiter...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{employees.length === 0 ? (
|
||||
<div style={{ padding: '20px', textAlign: 'center', background: '#f5f5f5', borderRadius: '4px' }}>
|
||||
<p style={{ margin: '0 0 8px 0', fontSize: '16px', fontWeight: 'bold' }}>
|
||||
Noch keine Mitarbeiter in der Liste
|
||||
</p>
|
||||
<p style={{ margin: 0, fontSize: '14px', color: '#666' }}>
|
||||
Sobald sich Benutzer einloggen, werden sie automatisch hier angezeigt.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="table" style={{ marginBottom: '16px' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Kürzel</th>
|
||||
<th>User ID</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{employees.map((employee) => (
|
||||
<tr key={employee.$id}>
|
||||
{editingEmployee === employee.$id ? (
|
||||
<>
|
||||
<td>{employee.displayName || '-'}</td>
|
||||
<td>{employee.email || '-'}</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={employeeForm.shortcode}
|
||||
onChange={(e) => setEmployeeForm(prev => ({ ...prev, shortcode: e.target.value.toUpperCase() }))}
|
||||
placeholder="KNSO"
|
||||
style={{ width: '100px' }}
|
||||
maxLength={10}
|
||||
autoFocus
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<small style={{ color: '#666' }}>{employee.userId.substring(0, 12)}...</small>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-green"
|
||||
onClick={async () => {
|
||||
const result = await updateEmployee(employee.$id, {
|
||||
shortcode: employeeForm.shortcode
|
||||
})
|
||||
if (result.success) {
|
||||
setEditingEmployee(null)
|
||||
setEmployeeForm({ userId: '', displayName: '', email: '', shortcode: '' })
|
||||
} else {
|
||||
alert('Fehler: ' + (result.error || 'Unbekannter Fehler'))
|
||||
}
|
||||
}}
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
setEditingEmployee(null)
|
||||
setEmployeeForm({ userId: '', displayName: '', email: '', shortcode: '' })
|
||||
}}
|
||||
style={{ marginLeft: '4px' }}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td>{employee.displayName || '-'}</td>
|
||||
<td>{employee.email || '-'}</td>
|
||||
<td>
|
||||
<strong style={{ color: employee.shortcode ? '#007bff' : '#999' }}>
|
||||
{employee.shortcode || '(kein Kürzel)'}
|
||||
</strong>
|
||||
</td>
|
||||
<td>
|
||||
<small style={{ color: '#666' }}>{employee.userId.substring(0, 12)}...</small>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-blue"
|
||||
onClick={() => {
|
||||
setEditingEmployee(employee.$id)
|
||||
setEmployeeForm({
|
||||
userId: employee.userId,
|
||||
displayName: employee.displayName || '',
|
||||
email: employee.email || '',
|
||||
shortcode: employee.shortcode || ''
|
||||
})
|
||||
}}
|
||||
title="Kürzel bearbeiten"
|
||||
>
|
||||
<FaEdit /> Kürzel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-red"
|
||||
onClick={async () => {
|
||||
if (confirm(`Möchtest du ${employee.displayName} wirklich aus der Mitarbeiter-Liste entfernen?`)) {
|
||||
await deleteEmployee(employee.$id)
|
||||
}
|
||||
}}
|
||||
style={{ marginLeft: '4px' }}
|
||||
title="Aus Mitarbeiter-Liste entfernen"
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-2">
|
||||
<button
|
||||
className="btn btn-dark"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
style={{ minWidth: '200px' }}
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<FaSpinner className="spinner" /> Speichere...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FaFloppyDisk /> Konfiguration speichern
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ import { useAuth } from '../context/AuthContext'
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [isRegistering, setIsRegistering] = useState(false)
|
||||
|
||||
const { login } = useAuth()
|
||||
const { login, register } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
@@ -16,15 +18,36 @@ export default function LoginPage() {
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
const result = await login(email, password)
|
||||
|
||||
if (result.success) {
|
||||
navigate('/tickets')
|
||||
} else {
|
||||
setError(result.error || 'Login failed')
|
||||
try {
|
||||
const result = isRegistering
|
||||
? await register(email, password, name || email.split('@')[0])
|
||||
: await login(email, password)
|
||||
|
||||
if (result.success) {
|
||||
navigate('/tickets')
|
||||
} else {
|
||||
// Bessere Fehlermeldungen
|
||||
let errorMessage = result.error || 'Login fehlgeschlagen'
|
||||
|
||||
if (errorMessage.includes('Invalid credentials') || errorMessage.includes('401')) {
|
||||
errorMessage = 'Ungültige Email oder Passwort. Bitte überprüfe deine Eingaben.'
|
||||
} else if (errorMessage.includes('User already exists')) {
|
||||
errorMessage = 'Ein Benutzer mit dieser Email existiert bereits. Bitte logge dich ein.'
|
||||
setIsRegistering(false)
|
||||
} else if (errorMessage.includes('User with the same email already exists')) {
|
||||
errorMessage = 'Diese Email ist bereits registriert. Bitte logge dich ein.'
|
||||
setIsRegistering(false)
|
||||
} else if (errorMessage.includes('Email/Password') || errorMessage.includes('auth')) {
|
||||
errorMessage = 'Email/Password Authentifizierung ist möglicherweise nicht aktiviert. Bitte überprüfe deine Appwrite-Konfiguration.'
|
||||
}
|
||||
|
||||
setError(errorMessage)
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Ein unerwarteter Fehler ist aufgetreten: ' + (err.message || 'Unbekannter Fehler'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -37,7 +60,7 @@ export default function LoginPage() {
|
||||
}}>
|
||||
<div className="card" style={{ width: '400px' }}>
|
||||
<div className="card-header text-center">
|
||||
<h2>NetWEB Systems WOMS 2.0</h2>
|
||||
<h2>Webklar WOMS 2.0</h2>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<form onSubmit={handleSubmit}>
|
||||
@@ -47,6 +70,19 @@ export default function LoginPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRegistering && (
|
||||
<div className="form-group">
|
||||
<label className="form-label">Name (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Dein Name"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Email</label>
|
||||
<input
|
||||
@@ -55,6 +91,7 @@ export default function LoginPage() {
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="deine@email.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -66,16 +103,42 @@ export default function LoginPage() {
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder="••••••••"
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-green"
|
||||
style={{ width: '100%' }}
|
||||
style={{ width: '100%', marginBottom: '10px' }}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Logging in...' : 'Login'}
|
||||
{loading
|
||||
? (isRegistering ? 'Registrierung läuft...' : 'Login läuft...')
|
||||
: (isRegistering ? 'Registrieren' : 'Login')
|
||||
}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{
|
||||
width: '100%',
|
||||
background: 'transparent',
|
||||
border: '1px solid #ccc',
|
||||
color: '#333'
|
||||
}}
|
||||
onClick={() => {
|
||||
setIsRegistering(!isRegistering)
|
||||
setError('')
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
{isRegistering
|
||||
? 'Bereits registriert? Hier einloggen'
|
||||
: 'Noch kein Account? Hier registrieren'
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useState } from 'react'
|
||||
import { format } from 'date-fns'
|
||||
import { FaAngleDown, FaSpinner } from 'react-icons/fa6'
|
||||
import { useWorkorders } from '../hooks/useWorkorders'
|
||||
import { useCustomers } from '../hooks/useCustomers'
|
||||
import TicketRow from '../components/TicketRow'
|
||||
import TicketFilters from '../components/TicketFilters'
|
||||
import CreateTicketModal from '../components/CreateTicketModal'
|
||||
import QuickOverviewModal from '../components/QuickOverviewModal'
|
||||
|
||||
@@ -17,6 +17,7 @@ export default function TicketsPage() {
|
||||
})
|
||||
|
||||
const { workorders, loading, error, refresh, updateWorkorder, createWorkorder } = useWorkorders(filters)
|
||||
const { customers } = useCustomers()
|
||||
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [showOverviewModal, setShowOverviewModal] = useState(false)
|
||||
@@ -54,104 +55,230 @@ export default function TicketsPage() {
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<header className="text-center mb-2">
|
||||
<h2>Active Tickets Overview</h2>
|
||||
</header>
|
||||
<div style={{
|
||||
background: 'rgba(45, 55, 72, 0.95)',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||
padding: '24px',
|
||||
marginBottom: '24px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<h2 style={{
|
||||
color: 'var(--dark-text)',
|
||||
marginBottom: '12px',
|
||||
fontSize: '28px',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
Active Tickets Overview
|
||||
</h2>
|
||||
<p style={{ color: '#a0aec0', marginBottom: '8px' }}>
|
||||
Work Order loading limit is set to <span style={{
|
||||
fontSize: '24px',
|
||||
color: 'var(--green-primary)',
|
||||
fontWeight: 'bold'
|
||||
}}>{limit}</span>.
|
||||
Reduce value to increase reload speed.
|
||||
</p>
|
||||
<p style={{ color: '#718096', fontSize: '12px' }}>
|
||||
Last page reload: {format(new Date(), 'dd.MM.yyyy, HH:mm:ss')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-grey">
|
||||
Work Order loading limit is set to <span className="text-xlarge">{limit}</span>.
|
||||
Reduce value to increase reload speed.
|
||||
</p>
|
||||
<p className="text-center text-grey text-small">
|
||||
Last page reload: {format(new Date(), 'dd.MM.yyyy, HH:mm:ss')}
|
||||
</p>
|
||||
{/* Unified Control Panel - All in One */}
|
||||
<div style={{
|
||||
background: 'rgba(45, 55, 72, 0.95)',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||
overflow: 'hidden',
|
||||
marginBottom: '24px'
|
||||
}}>
|
||||
{/* Extended Filters + Quick Selection - TOP */}
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
borderBottom: '1px solid rgba(16, 185, 129, 0.2)'
|
||||
}}>
|
||||
{/* Main Filter Row */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
|
||||
gap: '12px',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px'
|
||||
}}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="WOID"
|
||||
className="form-control"
|
||||
style={{ margin: 0 }}
|
||||
value={filters.woid || ''}
|
||||
onChange={(e) => setFilters({ ...filters, woid: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Created Date"
|
||||
className="form-control"
|
||||
style={{ margin: 0 }}
|
||||
value={filters.createdDate || ''}
|
||||
onChange={(e) => setFilters({ ...filters, createdDate: e.target.value })}
|
||||
/>
|
||||
<select
|
||||
className="form-control"
|
||||
style={{ margin: 0 }}
|
||||
value={filters.type?.[0] || ''}
|
||||
onChange={(e) => setFilters({ ...filters, type: e.target.value ? [e.target.value] : [] })}
|
||||
>
|
||||
<option value="">Type / Location</option>
|
||||
<option>Home Office</option>
|
||||
<option>Holidays</option>
|
||||
<option>Trip</option>
|
||||
<option>Supportrequest</option>
|
||||
<option>Change Request</option>
|
||||
<option>Maintenance</option>
|
||||
<option>Project</option>
|
||||
<option>Procurement</option>
|
||||
<option>Emergency Call</option>
|
||||
</select>
|
||||
<select
|
||||
className="form-control"
|
||||
style={{ margin: 0 }}
|
||||
value={filters.system?.[0] || ''}
|
||||
onChange={(e) => setFilters({ ...filters, system: e.target.value ? [e.target.value] : [] })}
|
||||
>
|
||||
<option value="">System</option>
|
||||
<option>Client</option>
|
||||
<option>Server</option>
|
||||
<option>Network</option>
|
||||
<option>EDI</option>
|
||||
<option>TOS</option>
|
||||
<option>Reports</option>
|
||||
<option>n/a</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Customer"
|
||||
className="form-control"
|
||||
style={{ margin: 0 }}
|
||||
value={filters.customer || ''}
|
||||
onChange={(e) => setFilters({ ...filters, customer: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Topic / User"
|
||||
className="form-control"
|
||||
style={{ margin: 0 }}
|
||||
value={filters.userTopic || ''}
|
||||
onChange={(e) => setFilters({ ...filters, userTopic: e.target.value })}
|
||||
/>
|
||||
<select
|
||||
className="form-control"
|
||||
style={{ margin: 0 }}
|
||||
value={filters.priority?.[0] ?? ''}
|
||||
onChange={(e) => setFilters({ ...filters, priority: e.target.value ? [parseInt(e.target.value)] : [] })}
|
||||
>
|
||||
<option value="">Priority</option>
|
||||
<option value="0">None</option>
|
||||
<option value="1">Low</option>
|
||||
<option value="2">Medium</option>
|
||||
<option value="3">High</option>
|
||||
<option value="4">Critical</option>
|
||||
</select>
|
||||
<button
|
||||
className="btn btn-green"
|
||||
onClick={handleApplyFilters}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
Apply!
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr className="mb-2" />
|
||||
{/* Quick Selection Buttons */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
paddingTop: '16px',
|
||||
borderTop: '1px solid rgba(16, 185, 129, 0.1)'
|
||||
}}>
|
||||
<button
|
||||
className="btn btn-green"
|
||||
onClick={() => { setFilters(prev => ({ ...prev, type: ['Procurement'] })); handleApplyFilters(); }}
|
||||
>
|
||||
Procurements
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-green"
|
||||
onClick={() => { setFilters(prev => ({ ...prev, priority: [4] })); handleApplyFilters(); }}
|
||||
>
|
||||
Criticals
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-green"
|
||||
onClick={() => { setFilters(prev => ({ ...prev, priority: [3] })); handleApplyFilters(); }}
|
||||
>
|
||||
Highs
|
||||
</button>
|
||||
<div style={{
|
||||
width: '1px',
|
||||
height: '32px',
|
||||
background: 'rgba(16, 185, 129, 0.3)',
|
||||
margin: '0 8px'
|
||||
}}></div>
|
||||
<button
|
||||
className="btn btn-green"
|
||||
onClick={() => { setLimit(10); setFilters(prev => ({ ...prev, limit: 10 })) }}
|
||||
>
|
||||
10
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-green"
|
||||
onClick={() => { setLimit(25); setFilters(prev => ({ ...prev, limit: 25 })) }}
|
||||
>
|
||||
25
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slider + Action Buttons - BOTTOM */}
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<div style={{ flex: '1', minWidth: '200px' }}>
|
||||
<input
|
||||
type="range"
|
||||
min="5"
|
||||
max="50"
|
||||
value={limit}
|
||||
className="slider"
|
||||
onChange={handleLimitChange}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'nowrap' }}>
|
||||
<button
|
||||
className="btn btn-dark"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
CREATE NEW TICKET
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-dark"
|
||||
onClick={() => setShowOverviewModal(true)}
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
QUICK OVERVIEW
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table className="table table-hover">
|
||||
<tbody>
|
||||
<TicketFilters
|
||||
filters={filters}
|
||||
onChange={handleFilterChange}
|
||||
onApply={handleApplyFilters}
|
||||
/>
|
||||
|
||||
<tr>
|
||||
<td colSpan={10} className="bg-dark-grey">
|
||||
<div className="slider-container text-center">
|
||||
<input
|
||||
type="range"
|
||||
min="5"
|
||||
max="50"
|
||||
value={limit}
|
||||
className="slider"
|
||||
onChange={handleLimitChange}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td colSpan={10} className="text-center p-1">
|
||||
<button
|
||||
className="btn btn-teal"
|
||||
onClick={() => setFilters(prev => ({ ...prev, type: ['Procurement'] }))}
|
||||
>
|
||||
Procurements
|
||||
</button>
|
||||
{' '}
|
||||
<button
|
||||
className="btn btn-teal"
|
||||
onClick={() => setFilters(prev => ({ ...prev, priority: [4] }))}
|
||||
>
|
||||
Criticals
|
||||
</button>
|
||||
{' '}
|
||||
<button
|
||||
className="btn btn-teal"
|
||||
onClick={() => setFilters(prev => ({ ...prev, priority: [3] }))}
|
||||
>
|
||||
Highs
|
||||
</button>
|
||||
{' '}
|
||||
<button
|
||||
className="btn btn-teal"
|
||||
onClick={() => { setLimit(10); setFilters(prev => ({ ...prev, limit: 10 })) }}
|
||||
>
|
||||
10
|
||||
</button>
|
||||
{' '}
|
||||
<button
|
||||
className="btn btn-teal"
|
||||
onClick={() => { setLimit(25); setFilters(prev => ({ ...prev, limit: 25 })) }}
|
||||
>
|
||||
25
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td colSpan={10} className="text-center p-1">
|
||||
<button
|
||||
className="btn btn-dark"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
>
|
||||
CREATE NEW TICKET
|
||||
</button>
|
||||
{' '}
|
||||
<button
|
||||
className="btn btn-dark"
|
||||
onClick={() => setShowOverviewModal(true)}
|
||||
>
|
||||
QUICK OVERVIEW
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr className="spacer">
|
||||
<td colSpan={10} style={{ height: '16px' }}></td>
|
||||
</tr>
|
||||
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={10} className="text-center p-2">
|
||||
@@ -184,26 +311,47 @@ export default function TicketsPage() {
|
||||
</table>
|
||||
|
||||
{workorders.length > 0 && workorders.length >= limit && (
|
||||
<div className="text-center mt-2">
|
||||
<div style={{ textAlign: 'center', marginTop: '24px' }}>
|
||||
<button
|
||||
className="btn"
|
||||
style={{ background: 'none', border: 'none' }}
|
||||
className="btn btn-green"
|
||||
style={{
|
||||
padding: '16px 32px',
|
||||
fontSize: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
margin: '0 auto'
|
||||
}}
|
||||
onClick={handleLoadMore}
|
||||
>
|
||||
<FaAngleDown size={48} />
|
||||
Load More <FaAngleDown size={20} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-center text-grey mt-2">
|
||||
Summary: Listed a total of {workorders.length} Workorders.
|
||||
<br />EOL =)
|
||||
</p>
|
||||
<div style={{
|
||||
background: 'rgba(45, 55, 72, 0.95)',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid rgba(16, 185, 129, 0.2)',
|
||||
padding: '16px',
|
||||
marginTop: '24px',
|
||||
textAlign: 'center',
|
||||
color: '#a0aec0'
|
||||
}}>
|
||||
<p style={{ margin: 0 }}>
|
||||
Summary: Listed a total of <span style={{
|
||||
color: 'var(--green-primary)',
|
||||
fontWeight: 'bold'
|
||||
}}>{workorders.length}</span> Workorders.
|
||||
<br />EOL =)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CreateTicketModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onCreate={handleCreate}
|
||||
customers={customers}
|
||||
/>
|
||||
|
||||
<QuickOverviewModal
|
||||
|
||||
@@ -4,31 +4,67 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--dark-bg: #1a202c;
|
||||
--dark-card-bg: rgba(45, 55, 72, 0.95);
|
||||
--dark-text: #e2e8f0;
|
||||
--green-primary: #10b981;
|
||||
--green-secondary: #059669;
|
||||
--gray-700: #374151;
|
||||
--gray-800: #1f2937;
|
||||
--gray-900: #111827;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Lato', sans-serif;
|
||||
background-color: #fff;
|
||||
color: #000;
|
||||
background-color: var(--dark-bg);
|
||||
color: var(--dark-text);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(26, 32, 44, 0.5);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--green-primary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--green-secondary);
|
||||
}
|
||||
|
||||
/* Text Selection */
|
||||
::selection {
|
||||
background: rgba(16, 185, 129, 0.3);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Color Classes */
|
||||
.bg-dark-grey { background-color: #616161; }
|
||||
.bg-light-grey { background-color: #f1f1f1; }
|
||||
.bg-white { background-color: #fff; }
|
||||
.bg-black { background-color: #000; }
|
||||
.bg-green { background-color: #4CAF50; }
|
||||
.bg-teal { background-color: #009688; }
|
||||
.bg-blue { background-color: #2196F3; }
|
||||
.bg-blue-grey { background-color: #607D8B; }
|
||||
.bg-red { background-color: #f44336; }
|
||||
.bg-yellow { background-color: #ffeb3b; }
|
||||
.bg-amber { background-color: #ffc107; }
|
||||
.bg-orange { background-color: #ff9800; }
|
||||
.bg-dark-grey { background-color: #616161 !important; }
|
||||
.bg-light-grey { background-color: #f1f1f1 !important; }
|
||||
.bg-white { background-color: #fff !important; }
|
||||
.bg-black { background-color: #000 !important; }
|
||||
.bg-green { background-color: #4CAF50 !important; }
|
||||
.bg-teal { background-color: #009688 !important; }
|
||||
.bg-blue { background-color: #2196F3 !important; }
|
||||
.bg-blue-grey { background-color: #607D8B !important; }
|
||||
.bg-red { background-color: #f44336 !important; }
|
||||
.bg-yellow { background-color: #ffeb3b !important; }
|
||||
.bg-amber { background-color: #ffc107 !important; }
|
||||
.bg-orange { background-color: #ff9800 !important; }
|
||||
|
||||
.text-white { color: #fff; }
|
||||
.text-black { color: #000; }
|
||||
.text-grey { color: #9e9e9e; }
|
||||
.text-green { color: #4CAF50; }
|
||||
.text-red { color: #f44336; }
|
||||
.text-white { color: #fff !important; }
|
||||
.text-black { color: #000 !important; }
|
||||
.text-grey { color: #9e9e9e !important; }
|
||||
.text-green { color: #4CAF50 !important; }
|
||||
.text-red { color: #f44336 !important; }
|
||||
|
||||
/* Layout */
|
||||
.container {
|
||||
@@ -48,7 +84,8 @@ body {
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #000;
|
||||
background: linear-gradient(135deg, rgba(26, 32, 44, 0.98) 0%, rgba(17, 24, 39, 0.98) 100%);
|
||||
border-bottom: 1px solid rgba(16, 185, 129, 0.2);
|
||||
z-index: 199;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -61,6 +98,7 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.navbar-nav {
|
||||
@@ -70,13 +108,15 @@ body {
|
||||
|
||||
.nav-link {
|
||||
padding: 16px 24px;
|
||||
color: #fff;
|
||||
color: var(--dark-text);
|
||||
text-decoration: none;
|
||||
transition: background 0.3s;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: #4CAF50;
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: var(--green-primary);
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
@@ -85,80 +125,147 @@ body {
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.btn-dark {
|
||||
background: #616161;
|
||||
background: linear-gradient(135deg, #4a5568 0%, #2d3748 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-dark:hover {
|
||||
background: #4CAF50;
|
||||
background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(74, 85, 104, 0.5);
|
||||
}
|
||||
|
||||
.btn-green {
|
||||
background: #4CAF50;
|
||||
color: #fff;
|
||||
.btn-green,
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%) !important;
|
||||
color: #fff !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.btn-green:hover,
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, #059669 0%, #047857 100%) !important;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(16, 185, 129, 0.5);
|
||||
}
|
||||
|
||||
.btn-teal {
|
||||
background: #009688;
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: linear-gradient(135deg, #4a5568 0%, #2d3748 100%) !important;
|
||||
color: #fff !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
color: var(--dark-text) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
border: 1px solid #ddd;
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Remove borders between rowspan cells */
|
||||
.table td[rowspan] {
|
||||
border-top: 1px solid rgba(16, 185, 129, 0.2);
|
||||
border-bottom: 1px solid rgba(16, 185, 129, 0.2);
|
||||
border-left: 1px solid rgba(16, 185, 129, 0.2);
|
||||
border-right: 1px solid rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.table th {
|
||||
background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%);
|
||||
color: var(--dark-text);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.table tbody {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.table-hover tbody tr {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.table-hover tbody tr:hover {
|
||||
background: #f5f5f5;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Spacer rows between tickets */
|
||||
.spacer,
|
||||
.spacer td {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background: rgba(45, 55, 72, 0.95) !important;
|
||||
border: 1px solid rgba(16, 185, 129, 0.2) !important;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--dark-text) !important;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 12px 16px;
|
||||
background: #616161;
|
||||
background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%);
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 16px;
|
||||
color: var(--dark-text);
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-control {
|
||||
.form-control,
|
||||
.form-select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
background: rgba(31, 41, 55, 0.6) !important;
|
||||
color: var(--dark-text) !important;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
.form-control::placeholder {
|
||||
color: rgba(226, 232, 240, 0.5);
|
||||
}
|
||||
|
||||
.form-control:focus,
|
||||
.form-select:focus {
|
||||
outline: none;
|
||||
border-color: #4CAF50;
|
||||
border-color: var(--green-primary) !important;
|
||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15) !important;
|
||||
background: rgba(31, 41, 55, 0.8) !important;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
@@ -169,11 +276,17 @@ body {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
color: var(--dark-text);
|
||||
}
|
||||
|
||||
select.form-control {
|
||||
background: #616161;
|
||||
color: #fff;
|
||||
background: rgba(31, 41, 55, 0.6) !important;
|
||||
color: var(--dark-text) !important;
|
||||
}
|
||||
|
||||
textarea.form-control {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
@@ -209,42 +322,64 @@ select.form-control {
|
||||
|
||||
/* Ticket Row */
|
||||
.ticket-row {
|
||||
border: 1px solid #ddd;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.ticket-row td {
|
||||
background: rgba(45, 55, 72, 0.95);
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
/* Second row of ticket (no top border to avoid line in rowspan cells) */
|
||||
.ticket-row + .ticket-row td {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.ticket-row:hover td {
|
||||
background: rgba(45, 55, 72, 0.95);
|
||||
}
|
||||
|
||||
.ticket-row:hover {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.ticket-id {
|
||||
background: #616161;
|
||||
background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%) !important;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(16, 185, 129, 0.2) !important;
|
||||
}
|
||||
|
||||
.ticket-time {
|
||||
font-size: 11px;
|
||||
color: #ccc;
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
.ticket-info {
|
||||
background: #f1f1f1;
|
||||
background: rgba(31, 41, 55, 0.6) !important;
|
||||
font-size: 13px;
|
||||
color: var(--dark-text);
|
||||
}
|
||||
|
||||
/* Status Colors */
|
||||
.status-open { background: #4CAF50; color: #fff; }
|
||||
.status-occupied { background: #607D8B; color: #fff; }
|
||||
.status-assigned { background: #009688; color: #fff; }
|
||||
.status-awaiting { background: #ff9800; color: #fff; }
|
||||
.status-closed { background: #9e9e9e; color: #fff; }
|
||||
.status-open { background: #4CAF50 !important; color: #fff !important; }
|
||||
.status-occupied { background: #607D8B !important; color: #fff !important; }
|
||||
.status-assigned { background: #009688 !important; color: #fff !important; }
|
||||
.status-awaiting { background: #ff9800 !important; color: #fff !important; }
|
||||
.status-closed { background: #9e9e9e !important; color: #fff !important; }
|
||||
|
||||
/* Priority Colors */
|
||||
.priority-none { background: #2196F3; color: #fff; }
|
||||
.priority-low { background: #4CAF50; color: #fff; }
|
||||
.priority-medium { background: #ffc107; color: #000; }
|
||||
.priority-high { background: #ff9800; color: #fff; }
|
||||
.priority-critical { background: #f44336; color: #fff; }
|
||||
.priority-none { background: #2196F3 !important; color: #fff !important; }
|
||||
.priority-low { background: #4CAF50 !important; color: #fff !important; }
|
||||
.priority-medium { background: #ffc107 !important; color: #000 !important; }
|
||||
.priority-high { background: #ff9800 !important; color: #fff !important; }
|
||||
.priority-critical { background: #f44336 !important; color: #fff !important; }
|
||||
|
||||
/* Dropdown */
|
||||
.dropdown {
|
||||
@@ -255,9 +390,11 @@ select.form-control {
|
||||
.dropdown-content {
|
||||
display: none;
|
||||
position: absolute;
|
||||
background: #616161;
|
||||
background: rgba(45, 55, 72, 0.98);
|
||||
min-width: 160px;
|
||||
box-shadow: 0 8px 16px rgba(0,0,0,0.2);
|
||||
box-shadow: 0 8px 16px rgba(0,0,0,0.4);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
border-radius: 6px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@@ -267,13 +404,15 @@ select.form-control {
|
||||
|
||||
.dropdown-item {
|
||||
padding: 8px 16px;
|
||||
color: #fff;
|
||||
color: var(--dark-text);
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: #4CAF50;
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: var(--green-primary);
|
||||
}
|
||||
|
||||
/* Modal/Overlay */
|
||||
@@ -283,7 +422,7 @@ select.form-control {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.9);
|
||||
background: rgba(0,0,0,0.95);
|
||||
z-index: 200;
|
||||
overflow-y: auto;
|
||||
padding: 60px 20px;
|
||||
@@ -296,6 +435,12 @@ select.form-control {
|
||||
font-size: 48px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.overlay-close:hover {
|
||||
color: var(--green-primary);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.overlay-content {
|
||||
@@ -304,32 +449,62 @@ select.form-control {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: rgba(45, 55, 72, 0.98) !important;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3) !important;
|
||||
color: var(--dark-text) !important;
|
||||
}
|
||||
|
||||
/* Slider */
|
||||
.slider-container {
|
||||
width: 100%;
|
||||
padding: 16px 0;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
background: #d3d3d3;
|
||||
height: 8px;
|
||||
background: rgba(31, 41, 55, 0.6);
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
transition: all 0.2s;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.slider:hover {
|
||||
opacity: 1;
|
||||
background: rgba(31, 41, 55, 0.8);
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 10px;
|
||||
height: 25px;
|
||||
background: #4CAF50;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.4);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.6);
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.4);
|
||||
border: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.6);
|
||||
}
|
||||
|
||||
/* File Upload */
|
||||
@@ -348,7 +523,13 @@ select.form-control {
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
border: 4px solid rgba(16, 185, 129, 0.2);
|
||||
border-top: 4px solid var(--green-primary);
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
@@ -356,10 +537,68 @@ select.form-control {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Modern Card Style */
|
||||
.modern-card {
|
||||
background: rgba(45, 55, 72, 0.95);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.modern-card:hover {
|
||||
border-color: rgba(16, 185, 129, 0.4);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Unified Control Panel - No internal rounded corners */
|
||||
.control-panel-section {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.control-panel-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.slide-in {
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
background: #000;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, rgba(26, 32, 44, 0.98) 0%, rgba(17, 24, 39, 0.98) 100%);
|
||||
border-top: 1px solid rgba(16, 185, 129, 0.2);
|
||||
color: var(--dark-text);
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
margin-top: 32px;
|
||||
|
||||
Reference in New Issue
Block a user