feat(frontend): Tailwind UI + Whitelabel + Multi-Tenant Components

Tech:
- Tailwind 3.4 + PostCSS + Autoprefixer
- Inter via rsms.me, Slate + Indigo (brand-* Farbpalette)
- Komponenten-Klassen .btn-{primary|secondary|danger|ghost}, .input, .label, .card, .badge-*

Visuelles Refresh:
- Logo entfernt - Wortmarke Pfandsystem mit Mono-Box
- Login: zentriertes Card-Layout mit Tenant-Slug-Toggle
- TopNav: cleanes Header mit Tabs + User-Menue, Demo-Badge
- Konsistente Card/Table/Badge-Patterns ueber alle Views
- Mobile-First (Tabellen scrollen, Filter wrap)

Komponenten neu/umgebaut:
- Auth/Login.jsx        - Tenant-Slug aus URL-Param ?tenant=...
- Auth/PasswordChange.jsx
- Dashboard/Dashboard.jsx
- Dashboard/TopNav.jsx  - inkl. Mobile-Hamburger
- Dashboard/Account.jsx (neu)
- Dashboard/AdminDashboard.jsx - Soll-Ist mit CSV-Export
- Dashboard/VorgangsListe.jsx
- Dashboard/BildModal.jsx
- Eingabe/VorgangForm.jsx - Kunden-Info-Popup mit Hinweis+Bildern+Maps
- KundenVerwaltung.jsx - inkl. Anlieferungshinweis, Lieferzeit, Bilder-Modal
- MitarbeiterVerwaltung.jsx - Aktiv-Toggle, Fahrer-Rolle
- Geraete.jsx (neu) - CRUD mit Status-Badges, Filter
- TourPlanung.jsx (neu) - Sortiert nach Lieferzeit, Navi-Button, Done-Persistenz
- SuperAdmin.jsx (neu) - Tenant-Liste + Demo-Anlage-Modal + Verlaengern

API Client:
- relative URL /api (Traefik-Proxy)
- Tenant-Slug im Login
- Endpoints fuer tenants, geraete, kunden-bilder

Sonstiges:
- nginx.conf: SSL raus (Traefik macht TLS), Host-Lock raus, Gzip, immutable Cache
- docker-compose: Frontend baut via Dockerfile (multi-stage)
- Manifest/Theme: #4338ca (Indigo)
- Aufgeraeumt: alte Supabase-Reste, Logout-Komponente (jetzt im Menu),
               nicht mehr noetige Deploy-Skripte
This commit is contained in:
christian
2026-05-26 13:18:13 +00:00
parent 43c75e5dd3
commit 51aa85df6f
93 changed files with 1497 additions and 5464 deletions

BIN
._index.html Normal file

Binary file not shown.

BIN
._package.json Normal file

Binary file not shown.

BIN
._postcss.config.js Normal file

Binary file not shown.

BIN
._src Executable file

Binary file not shown.

BIN
._tailwind.config.js Normal file

Binary file not shown.

View File

@@ -1,44 +0,0 @@
#!/bin/bash
# Backup-Script für Pfandsystem MySQL Datenbank
set -e
# Konfiguration
BACKUP_DIR="/root/backups"
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="$BACKUP_DIR/pfandsystem_backup_$DATE.sql"
RETENTION_DAYS=30
# Backup-Verzeichnis erstellen
mkdir -p "$BACKUP_DIR"
echo "🗄️ Starte Datenbank-Backup..."
# MySQL Backup erstellen
docker exec pfandsystem-mysql mysqldump \
-u pfandsystem \
-ppfandsystem_secure_password \
--single-transaction \
--routines \
--triggers \
pfandsystem > "$BACKUP_FILE"
# Komprimieren
gzip "$BACKUP_FILE"
BACKUP_FILE="$BACKUP_FILE.gz"
echo "✅ Backup erstellt: $BACKUP_FILE"
# Backup-Größe anzeigen
SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
echo "📊 Backup-Größe: $SIZE"
# Alte Backups löschen (älter als RETENTION_DAYS)
echo "🧹 Lösche alte Backups (älter als $RETENTION_DAYS Tage)..."
find "$BACKUP_DIR" -name "pfandsystem_backup_*.sql.gz" -mtime +$RETENTION_DAYS -delete
# Anzahl verbleibender Backups
BACKUP_COUNT=$(ls -1 "$BACKUP_DIR"/pfandsystem_backup_*.sql.gz 2>/dev/null | wc -l)
echo "📁 Anzahl Backups: $BACKUP_COUNT"
echo "✅ Backup abgeschlossen!"

View File

@@ -1,18 +0,0 @@
#!/bin/sh
# Komplettes Build- und Docker-Deploy-Skript für PWA mit nginx
set -e
echo "[1/4] → npm run build..."
npm run build
echo "[2/4] → Manifest-Link fixen..."
node fix-manifest-link.cjs
echo "[3/4] → Docker-Image bauen..."
docker build -t pfandsystem-pwa .
echo "[4/4] → Container starten (Port 8080 auf 80)..."
docker run -d --rm -p 8080:80 --name pfandsystem-pwa pfandsystem-pwa
echo "Deployment erfolgreich! App läuft auf http://localhost:8080"

View File

@@ -1,11 +0,0 @@
#!/bin/bash
# Erstzertifikat holen (nur einmal nötig)
docker run --rm -it \
-v $(pwd)/certbot/conf:/etc/letsencrypt \
-v $(pwd)/certbot/www:/var/www/certbot \
certbot/certbot certonly --webroot \
--webroot-path=/var/www/certbot \
--email deine-email@domain.de \
--agree-tos \
--no-eff-email \
-d pfandsystem.backdigital.de

View File

@@ -1,20 +0,0 @@
#!/bin/bash
set -e
# Build das Projekt
npm run build
# Zielverzeichnis für nginx
TARGET=/usr/share/nginx/html
# Lösche alten Inhalt (optional, vorsichtig!)
sudo rm -rf $TARGET/*
# Kopiere neuen Build nach nginx-root
sudo cp -r dist/* $TARGET/
# Setze Dateirechte
sudo chown -R www-data:www-data $TARGET
sudo chmod -R 755 $TARGET
echo "Deployment abgeschlossen. Die App ist jetzt unter nginx erreichbar."

View File

@@ -1,50 +0,0 @@
#!/bin/bash
# Deployment-Skript für Pfandsystem
set -e
echo "🚀 Pfandsystem Deployment gestartet..."
# 1. Frontend bauen
echo "[1/6] Frontend wird gebaut..."
npm install
npm run build
node fix-manifest-link.cjs
# 2. Backend Dependencies installieren
echo "[2/6] Backend Dependencies werden installiert..."
cd backend
npm install --production
cd ..
# 3. Docker Images bauen
echo "[3/6] Docker Images werden gebaut..."
docker-compose build
# 4. Container stoppen
echo "[4/6] Alte Container werden gestoppt..."
docker-compose down
# 5. Container starten
echo "[5/6] Neue Container werden gestartet..."
docker-compose up -d
# 6. Warten und Status prüfen
echo "[6/6] Warte auf Container-Start..."
sleep 10
echo ""
echo "✅ Deployment abgeschlossen!"
echo ""
echo "📊 Container-Status:"
docker-compose ps
echo ""
echo "🌐 Zugriff:"
echo " Frontend: https://pfandsystem.backdigital.de"
echo " phpMyAdmin: http://localhost:8081"
echo " Backend API: http://localhost:3001/api"
echo ""
echo "📝 Logs anzeigen:"
echo " docker-compose logs -f"
echo ""

View File

@@ -1,14 +0,0 @@
version: '3.8'
services:
nginx:
image: nginx:1.25-alpine
container_name: pfandsystem-nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./dist:/usr/share/nginx/html:ro
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./certbot/www:/var/www/certbot:ro
- ./certbot/conf:/etc/letsencrypt

View File

@@ -56,6 +56,7 @@ services:
PORT: 3001
NODE_ENV: production
RESEND_API_KEY: ${RESEND_API_KEY}
MAIL_FROM: ${MAIL_FROM:-Pfandsystem Demo <demo@dockly.de>}
UPLOAD_DIR: /app/uploads
MAX_FILE_SIZE: 10485760
SUPERADMIN_EMAIL: ${SUPERADMIN_EMAIL}
@@ -71,11 +72,11 @@ services:
condition: service_healthy
frontend:
image: nginx:1.25-alpine
build:
context: .
dockerfile: Dockerfile
container_name: pfandsystem-demo-frontend
restart: unless-stopped
volumes:
- ./dist:/usr/share/nginx/html:ro
networks:
- pfandsystem-demo-net
labels:
@@ -86,6 +87,8 @@ services:
- "traefik.http.routers.pfandsystem-demo.tls=true"
- "traefik.http.routers.pfandsystem-demo.tls.certresolver=letsencrypt"
- "traefik.http.services.pfandsystem-demo.loadbalancer.server.port=80"
depends_on:
- backend
volumes:
mysql_data:

View File

@@ -1,121 +0,0 @@
# 🔧 Backend Services (systemd)
## Problem gelöst:
Backends sind nach Server-Neustart oder Crash abgestürzt.
## Lösung:
Systemd Services erstellt - Backends starten automatisch!
---
## 📋 Services:
### **Dev-Backend:**
- **Service:** `pfandsystem-dev-backend.service`
- **Port:** 3001
- **Verzeichnis:** `/root/entwicklung/pfandsystem/backend`
- **Log:** `/var/log/pfandsystem-dev-backend.log`
### **Live-Backend (BorBäcker):**
- **Service:** `borbaecker-backend.service`
- **Port:** 3002
- **Verzeichnis:** `/root/livesysteme/borbaecker/backend`
- **Log:** `/var/log/borbaecker-backend.log`
---
## 🎯 Befehle:
### **Status prüfen:**
```bash
systemctl status pfandsystem-dev-backend
systemctl status borbaecker-backend
```
### **Starten:**
```bash
systemctl start pfandsystem-dev-backend
systemctl start borbaecker-backend
```
### **Stoppen:**
```bash
systemctl stop pfandsystem-dev-backend
systemctl stop borbaecker-backend
```
### **Neu starten:**
```bash
systemctl restart pfandsystem-dev-backend
systemctl restart borbaecker-backend
```
### **Logs ansehen:**
```bash
tail -f /var/log/pfandsystem-dev-backend.log
tail -f /var/log/borbaecker-backend.log
# Oder mit journalctl:
journalctl -u pfandsystem-dev-backend -f
journalctl -u borbaecker-backend -f
```
### **Autostart deaktivieren:**
```bash
systemctl disable pfandsystem-dev-backend
systemctl disable borbaecker-backend
```
---
## ✅ Features:
- **Automatischer Start** beim Server-Neustart
- **Automatischer Neustart** bei Crash (nach 10 Sekunden)
- **Logging** in separate Dateien
- **Systemd-Integration** - Standard Linux Service Management
---
## 🔄 Nach Code-Änderungen:
```bash
# Backend neu starten
systemctl restart borbaecker-backend
# Oder manuell (wenn Service gestoppt werden soll):
systemctl stop borbaecker-backend
cd /root/livesysteme/borbaecker/backend
npm start
```
---
## 🧪 Test:
```bash
# Backends testen
curl http://localhost:3001/health
curl http://localhost:3002/health
# Login testen
curl -X POST http://localhost:3002/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@pfandsystem.de","password":"admin123"}'
```
---
## 📊 Monitoring:
```bash
# Beide Services überwachen
watch -n 2 'systemctl status pfandsystem-dev-backend borbaecker-backend'
# Logs live verfolgen
tail -f /var/log/pfandsystem-dev-backend.log /var/log/borbaecker-backend.log
```
---
**Backends laufen jetzt als systemd Services und starten automatisch!** 🚀

View File

@@ -1,415 +0,0 @@
# 🚀 Deployment-Anleitung: Live-Version erstellen
## 📋 Übersicht
**Dev-Version:** `/root` (pfandsystem.backdigital.de)
**Live-Version:** `/var/www/pfandsystem-live` (neue-domain.de)
---
## 🔧 Schritt-für-Schritt Anleitung
### 1. **Neues Verzeichnis erstellen**
```bash
# Live-Version Verzeichnis
sudo mkdir -p /var/www/pfandsystem-live
cd /var/www/pfandsystem-live
# Git Repository klonen
git clone ssh://git@49.13.154.75:2244/christian/pfandsystem.git .
# Auf main branch wechseln
git checkout main
```
---
### 2. **Umgebungsvariablen anpassen**
#### Frontend `.env`:
```bash
cat > .env << 'EOF'
VITE_API_URL=https://DEINE-NEUE-DOMAIN.de/api
EOF
```
#### Backend `.env`:
```bash
cat > backend/.env << 'EOF'
# Database
DB_HOST=localhost
DB_PORT=3307
DB_USER=pfandsystem_live
DB_PASSWORD=NEUES_SICHERES_PASSWORT
DB_NAME=pfandsystem_live
# JWT
JWT_SECRET=NEUER_JWT_SECRET_KEY_HIER
# Server
PORT=3002
NODE_ENV=production
# Resend API
RESEND_API_KEY=re_6BE6Hv6U_H7ggkAT4ApbiSZ5SCo2Cf1qA
# File Upload
UPLOAD_DIR=/var/www/pfandsystem-live/backend/uploads
MAX_FILE_SIZE=10485760
EOF
```
---
### 3. **Docker-Compose anpassen**
```bash
cat > docker-compose.yml << 'EOF'
version: '3.8'
services:
# MySQL Datenbank (Live)
mysql:
image: mysql:8.0
container_name: pfandsystem-live-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: NEUES_ROOT_PASSWORT
MYSQL_DATABASE: pfandsystem_live
MYSQL_USER: pfandsystem_live
MYSQL_PASSWORD: NEUES_SICHERES_PASSWORT
volumes:
- mysql_data:/var/lib/mysql
ports:
- "3307:3306"
networks:
- pfandsystem-live-network
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
# phpMyAdmin (Live)
phpmyadmin:
image: phpmyadmin:latest
container_name: pfandsystem-live-phpmyadmin
restart: unless-stopped
environment:
PMA_HOST: mysql
PMA_PORT: 3306
ports:
- "8082:80"
networks:
- pfandsystem-live-network
depends_on:
- mysql
# Frontend (Live)
frontend:
image: nginx:1.25-alpine
container_name: pfandsystem-live-frontend
restart: unless-stopped
volumes:
- ./dist:/usr/share/nginx/html:ro
networks:
- pfandsystem-live-network
labels:
- "traefik.enable=true"
- "traefik.http.routers.pfandsystem-live.rule=Host(`DEINE-NEUE-DOMAIN.de`)"
- "traefik.http.routers.pfandsystem-live.entrypoints=web,websecure"
- "traefik.http.routers.pfandsystem-live.tls=true"
- "traefik.http.routers.pfandsystem-live.tls.certresolver=letsencrypt"
- "traefik.http.services.pfandsystem-live.loadbalancer.server.port=80"
volumes:
mysql_data:
driver: local
networks:
pfandsystem-live-network:
driver: bridge
EOF
```
---
### 4. **Traefik Dynamic Config für Backend**
```bash
mkdir -p /var/www/pfandsystem-live/traefik-config
cat > /var/www/pfandsystem-live/traefik-config/backend.yml << 'EOF'
http:
routers:
# Backend API Router (Live)
pfandsystem-live-api:
rule: "Host(`DEINE-NEUE-DOMAIN.de`) && PathPrefix(`/api`)"
service: pfandsystem-live-api
entryPoints:
- web
- websecure
tls:
certResolver: letsencrypt
priority: 100
# Backend Uploads Router (Live)
pfandsystem-live-uploads:
rule: "Host(`DEINE-NEUE-DOMAIN.de`) && PathPrefix(`/uploads`)"
service: pfandsystem-live-api
entryPoints:
- web
- websecure
tls:
certResolver: letsencrypt
priority: 100
services:
# Backend Service (Live)
pfandsystem-live-api:
loadBalancer:
servers:
- url: "http://172.17.0.1:3002"
EOF
```
---
### 5. **Traefik-Config in bestehenden Traefik einbinden**
```bash
# Kopiere Config in Traefik dynamic Ordner
sudo cp /var/www/pfandsystem-live/traefik-config/backend.yml /root/traefik/dynamic/pfandsystem-live.yml
# Traefik neu laden (erkennt automatisch neue Configs)
docker restart pfandsystem-traefik
```
---
### 6. **Datenbank initialisieren**
```bash
# Docker Container starten
cd /var/www/pfandsystem-live
docker compose up -d mysql phpmyadmin
# Warte bis MySQL bereit ist
sleep 30
# SQL Schema importieren
docker exec -i pfandsystem-live-mysql mysql -upfandsystem_live -pNEUES_SICHERES_PASSWORT pfandsystem_live < backend/schema.sql
# Admin-User erstellen
cd backend
npm install
node scripts/create-admin.js
```
---
### 7. **Frontend bauen**
```bash
cd /var/www/pfandsystem-live
# Dependencies installieren
npm install
# Frontend bauen
npm run build
# Manifest-Links fixen
node fix-manifest-link.cjs
```
---
### 8. **Backend starten**
```bash
cd /var/www/pfandsystem-live/backend
# Dependencies installieren
npm install
# Backend als Service starten (mit PM2)
npm install -g pm2
pm2 start server.js --name pfandsystem-live
pm2 save
pm2 startup
```
---
### 9. **Frontend-Container starten**
```bash
cd /var/www/pfandsystem-live
docker compose up -d frontend
```
---
### 10. **Logo und Branding anpassen**
#### Logo ersetzen:
```bash
# Eigenes Logo (192x192 und 512x512)
cp /pfad/zu/deinem/logo-192.png /var/www/pfandsystem-live/public/logo-192.png
cp /pfad/zu/deinem/logo-512.png /var/www/pfandsystem-live/public/logo-512.png
# Favicon
cp /pfad/zu/deinem/favicon.ico /var/www/pfandsystem-live/public/favicon.ico
```
#### Manifest anpassen:
```bash
nano /var/www/pfandsystem-live/public/manifest.json
```
Ändere:
```json
{
"name": "Dein Firmenname - Pfandsystem",
"short_name": "Dein Pfandsystem",
"theme_color": "#DEINE_FARBE",
"background_color": "#DEINE_FARBE"
}
```
#### Farben anpassen:
```bash
nano /var/www/pfandsystem-live/src/index.css
```
Ändere Primary-Color, etc.
#### Frontend neu bauen:
```bash
cd /var/www/pfandsystem-live
npm run build
node fix-manifest-link.cjs
docker restart pfandsystem-live-frontend
```
---
## 🧪 Testing
### 1. **DNS konfigurieren:**
```
DEINE-NEUE-DOMAIN.de → A-Record → Server-IP
```
### 2. **Testen:**
```bash
# SSL-Zertifikat wird automatisch von Traefik generiert
curl -I https://DEINE-NEUE-DOMAIN.de
# Login testen
curl https://DEINE-NEUE-DOMAIN.de/api/auth/login \
-X POST \
-H "Content-Type: application/json" \
-d '{"email":"admin@pfandsystem.de","password":"admin123"}'
```
### 3. **Im Browser öffnen:**
```
https://DEINE-NEUE-DOMAIN.de
```
---
## 📊 Übersicht der Ports
| Service | Dev | Live |
|---------|-----|------|
| MySQL | 3306 | 3307 |
| phpMyAdmin | 8081 | 8082 |
| Backend | 3001 | 3002 |
| Frontend | via Traefik | via Traefik |
---
## 🔄 Updates deployen
```bash
cd /var/www/pfandsystem-live
# Neueste Version holen
git pull origin main
# Frontend neu bauen
npm run build
node fix-manifest-link.cjs
docker restart pfandsystem-live-frontend
# Backend neu starten
pm2 restart pfandsystem-live
```
---
## 🛡️ Sicherheit
1. **Passwörter ändern:**
- MySQL Root-Passwort
- MySQL User-Passwort
- JWT Secret
- Admin-Passwort (nach erstem Login)
2. **Firewall:**
```bash
# Nur notwendige Ports öffnen
ufw allow 80/tcp
ufw allow 443/tcp
ufw allow 22/tcp
```
3. **Backups:**
```bash
# Automatisches Backup einrichten
crontab -e
# Täglich um 2 Uhr
0 2 * * * /var/www/pfandsystem-live/backup.sh
```
---
## ✅ Checkliste
- [ ] Verzeichnis erstellt
- [ ] Git geklont
- [ ] .env Dateien angepasst
- [ ] docker-compose.yml angepasst
- [ ] Traefik-Config erstellt
- [ ] Datenbank initialisiert
- [ ] Admin-User erstellt
- [ ] Frontend gebaut
- [ ] Backend gestartet (PM2)
- [ ] Frontend-Container gestartet
- [ ] Logo/Branding angepasst
- [ ] DNS konfiguriert
- [ ] SSL-Zertifikat generiert
- [ ] Login getestet
- [ ] Alle Funktionen getestet
---
## 🎯 Zusammenfassung
**Dev-Version:**
- Verzeichnis: `/root`
- Domain: `pfandsystem.backdigital.de`
- MySQL Port: 3306
- Backend Port: 3001
**Live-Version:**
- Verzeichnis: `/var/www/pfandsystem-live`
- Domain: `DEINE-NEUE-DOMAIN.de`
- MySQL Port: 3307
- Backend Port: 3002
Beide Versionen laufen parallel auf derselben Instanz mit eigenem Traefik-Routing!

View File

@@ -1,328 +0,0 @@
# 🚀 Deployment Checklist - Pfandsystem
## Pre-Deployment
### 1. Umgebungsvariablen konfigurieren
- [ ] **Backend `.env` erstellen:**
```bash
cp backend/.env.example backend/.env
nano backend/.env
```
Zu ändern:
- [ ] `DB_PASSWORD` - Sicheres MySQL-Passwort
- [ ] `JWT_SECRET` - Mindestens 32 Zeichen, zufällig generiert
- [ ] `RESEND_API_KEY` - Prüfen ob korrekt
- [ ] **Frontend `.env` prüfen:**
```bash
nano .env
```
Zu prüfen:
- [ ] `VITE_API_URL` - Korrekte Domain (https://pfandsystem.backdigital.de/api)
- [ ] **docker-compose.yml anpassen:**
```bash
nano docker-compose.yml
```
Zu ändern:
- [ ] `MYSQL_ROOT_PASSWORD`
- [ ] `MYSQL_PASSWORD` (muss mit backend/.env übereinstimmen)
### 2. SSL/TLS Zertifikate
- [ ] Zertifikate vorhanden in `/root/certbot/conf/`
- [ ] Zertifikate gültig (nicht abgelaufen)
- [ ] Domain korrekt konfiguriert (pfandsystem.backdigital.de)
### 3. Build & Test
- [ ] Frontend Dependencies installieren:
```bash
npm install
```
- [ ] Frontend bauen:
```bash
npm run build
node fix-manifest-link.cjs
```
- [ ] Backend Dependencies installieren:
```bash
cd backend && npm install && cd ..
```
## Deployment
### 4. Docker Compose starten
- [ ] Alte Container stoppen (falls vorhanden):
```bash
docker-compose down
```
- [ ] Volumes prüfen (optional - nur bei Neuinstallation):
```bash
docker volume ls
# Bei Bedarf alte Volumes löschen:
# docker volume rm pfandsystem_mysql_data
```
- [ ] Container starten:
```bash
docker-compose up -d
```
- [ ] Container-Status prüfen:
```bash
docker-compose ps
```
Alle 4 Container sollten "Up" sein:
- pfandsystem-mysql
- pfandsystem-phpmyadmin
- pfandsystem-backend
- pfandsystem-frontend
### 5. Logs prüfen
- [ ] Backend-Logs prüfen:
```bash
docker-compose logs backend
```
Erwartete Ausgabe:
- ✅ MySQL Datenbankverbindung erfolgreich
- 🚀 Backend-Server läuft auf Port 3001
- [ ] MySQL-Logs prüfen:
```bash
docker-compose logs mysql
```
Erwartete Ausgabe:
- ready for connections
### 6. Datenbank initialisieren
- [ ] Admin-Benutzer erstellen:
```bash
docker exec -it pfandsystem-backend node scripts/create-admin.js
```
Notiere:
- Email: admin@pfandsystem.de
- Passwort: admin123 (ÄNDERN!)
- [ ] Optional - Beispieldaten prüfen (via phpMyAdmin):
- URL: http://localhost:8081
- Login: pfandsystem / <dein-passwort>
- Prüfe Tabellen: mitarbeiter, artikel
## Post-Deployment Tests
### 7. Frontend-Tests
- [ ] Frontend erreichbar: https://pfandsystem.backdigital.de
- [ ] Login-Seite wird angezeigt
- [ ] Login mit Admin-Credentials funktioniert
- [ ] Dashboard wird angezeigt
- [ ] PWA-Manifest lädt (DevTools → Application → Manifest)
- [ ] Service Worker registriert (DevTools → Application → Service Workers)
- [ ] Icons werden angezeigt
### 8. Backend-Tests
- [ ] Health Check:
```bash
curl http://localhost:3001/health
```
Erwartete Antwort:
```json
{"status":"OK","timestamp":"..."}
```
- [ ] Login API:
```bash
curl -X POST http://localhost:3001/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@pfandsystem.de","password":"admin123"}'
```
Erwartete Antwort:
```json
{"token":"...","user":{...}}
```
### 9. Funktions-Tests
- [ ] **Kundenverwaltung:**
- Neuen Kunden anlegen
- Kunde bearbeiten
- Kunde löschen
- [ ] **Artikelverwaltung:**
- Neuen Artikel anlegen
- Artikel bearbeiten
- Artikel löschen
- [ ] **Lieferungen:**
- Neue Lieferung erfassen
- Mit Foto hochladen
- Foto wird angezeigt
- [ ] **Rücknahmen:**
- Neue Rücknahme erfassen
- Mit Foto hochladen
- Foto wird angezeigt
- [ ] **Mitarbeiterverwaltung (Admin):**
- Neuen Mitarbeiter anlegen
- Rolle ändern
- Mit neuem Mitarbeiter einloggen
### 10. Sicherheits-Tests
- [ ] Admin-Passwort geändert (nicht mehr admin123)
- [ ] HTTPS funktioniert (kein Mixed Content)
- [ ] Nicht-authentifizierte API-Calls werden abgelehnt
- [ ] User kann keine Admin-Funktionen ausführen
- [ ] File-Upload nur für Bilder möglich
- [ ] File-Upload-Größe limitiert (max 10MB)
## Daten-Migration (falls erforderlich)
### 11. Daten aus Supabase exportieren
- [ ] Kunden exportieren (CSV/JSON)
- [ ] Artikel exportieren
- [ ] Mitarbeiter exportieren
- [ ] Lieferungen exportieren
- [ ] Rücknahmen exportieren
### 12. Daten in MySQL importieren
- [ ] Via phpMyAdmin (http://localhost:8081)
- [ ] Oder via SQL-Script:
```bash
docker exec -i pfandsystem-mysql mysql -u pfandsystem -p pfandsystem < import.sql
```
- [ ] Daten-Integrität prüfen:
- Anzahl Kunden korrekt
- Anzahl Artikel korrekt
- Foreign Keys korrekt
## Monitoring & Backup
### 13. Backup einrichten
- [ ] Datenbank-Backup-Script erstellen:
```bash
#!/bin/bash
DATE=$(date +%Y%m%d_%H%M%S)
docker exec pfandsystem-mysql mysqldump -u pfandsystem -p<password> pfandsystem > backup_$DATE.sql
```
- [ ] Cronjob für tägliches Backup:
```bash
crontab -e
# Täglich um 2 Uhr
0 2 * * * /root/backup.sh
```
### 14. Monitoring
- [ ] Container-Status überwachen:
```bash
docker-compose ps
```
- [ ] Logs regelmäßig prüfen:
```bash
docker-compose logs --tail=100
```
- [ ] Disk Space prüfen:
```bash
df -h
docker system df
```
## Troubleshooting
### Häufige Probleme
**Backend startet nicht:**
```bash
docker-compose logs backend
# Prüfe Datenbank-Verbindung
docker-compose restart mysql
```
**Frontend zeigt keine Daten:**
1. Browser-Cache leeren (Strg+Shift+R)
2. Service Worker deregistrieren (DevTools → Application)
3. API-URL in .env prüfen
**Datenbank-Verbindung fehlgeschlagen:**
```bash
# MySQL-Logs prüfen
docker-compose logs mysql
# In MySQL-Container einloggen
docker exec -it pfandsystem-mysql mysql -u pfandsystem -p
# Verbindung testen
docker exec -it pfandsystem-backend node -e "require('./config/database.js')"
```
**Upload funktioniert nicht:**
```bash
# Prüfe Upload-Verzeichnis
docker exec -it pfandsystem-backend ls -la /app/uploads
# Prüfe Berechtigungen
docker exec -it pfandsystem-backend chmod 777 /app/uploads
```
## Rollback-Plan
Falls Probleme auftreten:
1. **Container stoppen:**
```bash
docker-compose down
```
2. **Alte Version wiederherstellen:**
```bash
git checkout <previous-commit>
docker-compose up -d
```
3. **Datenbank-Backup wiederherstellen:**
```bash
docker exec -i pfandsystem-mysql mysql -u pfandsystem -p pfandsystem < backup.sql
```
## Abschluss
- [ ] Alle Tests erfolgreich
- [ ] Dokumentation aktualisiert
- [ ] Team informiert
- [ ] Backup-Strategie aktiv
- [ ] Monitoring läuft
---
**Deployment abgeschlossen am:** _________________
**Durchgeführt von:** _________________
**Notizen:**

View File

@@ -1,85 +0,0 @@
# ✅ ERFOLG! Alle Probleme gelöst
## 1. Login funktioniert! ✅
**Problem war:** Backend konnte MySQL-Host "mysql" nicht auflösen (läuft lokal, nicht in Docker)
**Lösung:**
- `DB_HOST=localhost` in `/root/backend/.env`
- Admin-Benutzer neu erstellt
- Login getestet und funktioniert!
**Test:**
```bash
curl -X POST http://localhost:3001/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@pfandsystem.de","password":"admin123"}'
```
**Ergebnis:** Token wird zurückgegeben! ✅
## 2. Git konfiguriert ✅
- User: christian (vonperfall@r83.io)
- Remote: ssh://git@49.13.154.75:2244/christian/pfandsystem.git
- Branches: main, development, testing
- Alle Änderungen committed
**Zum Pushen (mit Passwort):**
```bash
git push -u origin main
# Passwort eingeben
git push origin development
git push origin testing
```
## 3. SSL - Nächster Schritt
SSL-Zertifikate sind vorhanden. Du musst nur den externen nginx konfigurieren um auf das Backend zu proxyen.
## 🚀 System läuft jetzt!
### Aktive Services:
- ✅ MySQL (Docker, Port 3306)
- ✅ phpMyAdmin (Docker, Port 8081)
- ✅ Backend API (lokal, Port 3001)
- ✅ Frontend (nginx Docker, Port 80)
### Login-Daten:
- **Email:** admin@pfandsystem.de
- **Passwort:** admin123
### URLs:
- **Frontend:** http://pfandsystem.backdigital.de
- **Backend API:** http://localhost:3001/api
- **phpMyAdmin:** http://localhost:8081
- **Health Check:** http://localhost:3001/health
## 📋 Nächste Schritte
1. **Git pushen** (manuell mit Passwort)
```bash
git push -u origin main
```
2. **Frontend testen**
```bash
# Browser öffnen
http://pfandsystem.backdigital.de
# Login mit: admin@pfandsystem.de / admin123
```
3. **SSL konfigurieren** (optional)
- Externer nginx muss auf Backend proxyen
- Zertifikate sind vorhanden
## 🎉 Fertig!
Das System ist jetzt voll funktionsfähig:
- ✅ Datenbank läuft
- ✅ Backend läuft
- ✅ Login funktioniert
- ✅ Git konfiguriert
- ✅ Alle Commits erstellt
**Du kannst jetzt mit dem System arbeiten!**

View File

@@ -1,97 +0,0 @@
# 🎉 ERFOLG! Alle Probleme gelöst!
## ✅ Was funktioniert:
### 1. **Login** ✅
- Backend läuft auf Port 3001
- Login-Endpoint funktioniert
- Test: `curl https://pfandsystem.backdigital.de/api/auth/login`
- **Credentials:** admin@pfandsystem.de / admin123
### 2. **Frontend** ✅
- Erreichbar unter: **https://pfandsystem.backdigital.de**
- HTTP 200 Status
- Über Traefik geroutet
- SSL aktiv (Let's Encrypt)
### 3. **SSL/HTTPS** ✅
- Traefik konfiguriert
- Let's Encrypt Zertifikate automatisch
- Alle Verbindungen verschlüsselt
## 🚀 URLs:
- **Frontend:** https://pfandsystem.backdigital.de
- **Backend API:** https://pfandsystem.backdigital.de/api
- **phpMyAdmin:** http://localhost:8081
- **Login:** admin@pfandsystem.de / admin123
## 📋 Git Push (noch zu tun):
```bash
git push -u origin main
# Passwort eingeben
git push origin development
git push origin testing
```
## 🔧 Technische Details:
### Traefik-Konfiguration:
- Datei: `/data/coolify/proxy/dynamic/pfandsystem.yaml`
- Frontend-Router: Host-basiert
- API-Router: PathPrefix `/api` mit Priorität 100
- Uploads-Router: PathPrefix `/uploads` mit Priorität 100
### Services:
- **MySQL:** Docker, Port 3306
- **phpMyAdmin:** Docker, Port 8081
- **Backend:** Lokal, Port 3001
- **Frontend:** Docker (pfandsystem-frontend-internal)
- **Traefik:** Coolify Proxy (Port 80/443)
### Netzwerk:
- Backend: Host-IP 51.15.249.253:3001
- Frontend: Docker-Netzwerk (pfandsystem-frontend-internal)
- Traefik: Verbunden mit root_pfandsystem-network
## ✅ Checkliste:
- [x] MySQL läuft
- [x] Backend läuft
- [x] Frontend läuft
- [x] Login funktioniert
- [x] SSL funktioniert
- [x] Traefik konfiguriert
- [x] Git konfiguriert
- [ ] Git gepusht (manuell mit Passwort)
## 🎯 Nächste Schritte:
1. **Git pushen:**
```bash
git push -u origin main
git push origin development
git push origin testing
```
2. **Im Browser testen:**
- Öffne: https://pfandsystem.backdigital.de
- Login: admin@pfandsystem.de / admin123
- Teste alle Funktionen
3. **Optional - Backend in Docker:**
- Aktuell läuft Backend lokal (funktioniert aber!)
- Kann später in Docker verschoben werden
## 🎉 FERTIG!
Das System ist vollständig funktionsfähig:
- ✅ Datenbank
- ✅ Backend mit Login
- ✅ Frontend über HTTPS
- ✅ SSL/TLS
- ✅ Alle APIs erreichbar
**Du kannst jetzt mit dem System arbeiten!**

View File

@@ -1,129 +0,0 @@
# 🎯 Finaler Status - Pfandsystem
## ✅ Was läuft:
1. **MySQL** - Port 3306 (Docker)
2. **phpMyAdmin** - http://localhost:8081 (Docker)
3. **Backend API** - http://localhost:3001 (lokal, nicht Docker)
4. **Frontend** - Intern im Docker-Netzwerk
## ⚠️ Wichtig: Traefik Reverse Proxy
Auf dem Server läuft **Traefik** auf Port 80/443.
Das Frontend muss über Traefik geroutet werden!
## 📋 Nächste Schritte:
### 1. Git Push (MANUELL)
```bash
# Du musst das manuell machen mit deinem Passwort:
git push -u origin main
# Username: christian
# Passwort: [dein Passwort]
# Dann die anderen Branches:
git push origin development
git push origin testing
```
### 2. Frontend über Traefik routen
Da Traefik läuft, musst du eine Traefik-Konfiguration erstellen:
**Option A: Traefik Labels (empfohlen)**
```yaml
# In docker-compose.yml für frontend:
labels:
- "traefik.enable=true"
- "traefik.http.routers.pfandsystem.rule=Host(`pfandsystem.backdigital.de`)"
- "traefik.http.routers.pfandsystem.entrypoints=websecure"
- "traefik.http.routers.pfandsystem.tls=true"
- "traefik.http.routers.pfandsystem.tls.certresolver=letsencrypt"
- "traefik.http.services.pfandsystem.loadbalancer.server.port=80"
```
**Option B: Traefik Config File**
Erstelle `/etc/traefik/dynamic/pfandsystem.yml`:
```yaml
http:
routers:
pfandsystem:
rule: "Host(`pfandsystem.backdigital.de`)"
service: pfandsystem
entryPoints:
- websecure
tls:
certResolver: letsencrypt
services:
pfandsystem:
loadBalancer:
servers:
- url: "http://pfandsystem-frontend-internal:80"
```
### 3. Backend über Traefik routen (für API)
```yaml
http:
routers:
pfandsystem-api:
rule: "Host(`pfandsystem.backdigital.de`) && PathPrefix(`/api`)"
service: pfandsystem-api
entryPoints:
- websecure
tls:
certResolver: letsencrypt
services:
pfandsystem-api:
loadBalancer:
servers:
- url: "http://host.docker.internal:3001"
```
## 🔧 Aktuelle Konfiguration:
### Backend (.env)
```
DB_HOST=localhost # Wichtig! Nicht "mysql" weil Backend lokal läuft
DB_PORT=3306
DB_USER=pfandsystem
DB_PASSWORD=pfandsystem_secure_password
```
### Frontend (.env)
```
VITE_API_URL=http://localhost:3001/api # Muss auf /api zeigen wenn über Traefik
```
## 🚀 Schnellstart Backend:
```bash
# Backend starten (falls gestoppt)
cd /root/backend && nohup npm start > /tmp/backend.log 2>&1 &
# Backend-Logs sehen
tail -f /tmp/backend.log
# Backend testen
curl http://localhost:3001/health
```
## 📊 Login-Daten:
- **Email:** admin@pfandsystem.de
- **Passwort:** admin123
## 🎯 TODO:
- [ ] Git pushen (manuell mit Passwort)
- [ ] Traefik-Routing für Frontend konfigurieren
- [ ] Traefik-Routing für Backend-API konfigurieren
- [ ] Frontend-API-URL anpassen (auf /api über Traefik)
- [ ] Testen
## 💡 Hinweis:
Da Traefik läuft, ist SSL automatisch verfügbar wenn die Routing-Konfiguration stimmt!

View File

@@ -1,86 +0,0 @@
# Sofortige Fixes für die 3 Hauptprobleme
## Problem 1: Login geht nicht ✅ GELÖST
**Ursache:** Backend crashed beim Login-Request
**Lösung:** Backend läuft jetzt lokal (außerhalb Docker) bis Docker-Problem behoben
```bash
# Backend lokal starten (temporär)
cd /root/backend
npm start &
# Test:
curl http://localhost:3001/health
# Sollte: {"status":"OK",...}
```
## Problem 2: SSL geht nicht ⚠️ IN ARBEIT
**Ursache:** nginx im Docker hat keine SSL-Zertifikate gemountet
**Lösung:** Verwende externen nginx (bereits läuft) oder mounte Zertifikate
**Temporäre Lösung - HTTP verwenden:**
```bash
# Frontend .env anpassen
echo "VITE_API_URL=http://pfandsystem.backdigital.de/api" > /root/.env
# Neu bauen
npm run build
node fix-manifest-link.cjs
```
**Permanente Lösung - Externer nginx:**
Nginx läuft bereits auf dem Host und hat SSL. Wir müssen nur Proxy konfigurieren.
## Problem 3: Git Repository ✅ GELÖST
```bash
# Git konfiguriert
git config user.email "vonperfall@r83.io"
git config user.name "christian"
git remote add origin ssh://git@49.13.154.75:2244/christian/pfandsystem.git
# Branches erstellt
git branch -M main
git branch development
git branch testing
# Pushen:
git push -u origin main
git push origin development
git push origin testing
```
## Schnellstart-Befehle
```bash
# 1. MySQL & phpMyAdmin starten
docker compose up -d mysql phpmyadmin
# 2. Backend lokal starten
cd /root/backend && npm start &
# 3. Frontend neu bauen
cd /root
npm run build
node fix-manifest-link.cjs
# 4. Testen
curl http://localhost:3001/health
curl -X POST http://localhost:3001/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@pfandsystem.de","password":"admin123"}'
# 5. Git pushen
git add -A
git commit -m "Fix: Backend läuft lokal, Git konfiguriert"
git push -u origin main
```
## Nächste Schritte
1. **Backend-Docker-Problem beheben** (später)
2. **SSL konfigurieren** (externer nginx)
3. **Frontend deployen**
4. **Testen**

View File

@@ -1,243 +0,0 @@
# Migration von Supabase zu MySQL - Zusammenfassung
## ✅ Durchgeführte Änderungen
### 1. Backend (Node.js/Express)
**Neu erstellt:**
- `/backend/server.js` - Hauptserver mit Express
- `/backend/config/database.js` - MySQL-Verbindung
- `/backend/middleware/auth.js` - JWT-Authentifizierung
- `/backend/middleware/upload.js` - Multer File-Upload
- `/backend/routes/` - Alle API-Routen (auth, kunden, artikel, lieferungen, ruecknahmen, mitarbeiter)
- `/backend/database/schema.sql` - MySQL-Schema (konvertiert von PostgreSQL)
- `/backend/scripts/create-admin.js` - Admin-Benutzer erstellen
**Features:**
- ✅ JWT-basierte Authentifizierung (ersetzt Supabase Auth)
- ✅ Bcrypt Passwort-Hashing
- ✅ Rollen-basierte Zugriffskontrolle (user/admin)
- ✅ File-Upload mit Multer (ersetzt S3/Supabase Storage)
- ✅ Resend Email-Integration
- ✅ MySQL2 mit Connection Pooling
### 2. Frontend (React)
**Geänderte Dateien:**
- `/src/api/client.js` - Neuer API-Client (ersetzt Supabase Client)
- `/src/App.jsx` - Auth-Flow angepasst
- `/src/components/Auth/Login.jsx` - Login mit neuem API
- `/src/components/Auth/Logout.jsx` - Logout angepasst
- `/src/components/KundenVerwaltung.jsx` - API-Calls angepasst
- `/src/components/MitarbeiterVerwaltung.jsx` - API-Calls + Passwort-Feld
- `/src/components/Eingabe/VorgangForm.jsx` - File-Upload angepasst
- `/src/components/Dashboard/VorgangsListe.jsx` - Datenstruktur angepasst
- `/src/components/Dashboard/AdminDashboard.jsx` - API-Calls angepasst
**Entfernt:**
- `@supabase/supabase-js` Dependency
- `/src/supabase/client.js` (bleibt für Referenz, wird nicht mehr verwendet)
### 3. Datenbank
**MySQL-Schema:**
- `mitarbeiter` - Mit email/password_hash statt supabase_user_id
- `kunden` - Unverändert
- `artikel` - Unverändert
- `lieferungen` - Foto-URL zeigt auf lokalen Upload-Ordner
- `ruecknahmen` - Foto-URL zeigt auf lokalen Upload-Ordner
**Wichtige Änderungen:**
- UUID → CHAR(36) mit UUID()
- SERIAL → INT AUTO_INCREMENT
- timestamp with time zone → TIMESTAMP
- Foreign Keys auf auth.users entfernt
### 4. Docker & Deployment
**docker-compose.yml:**
- MySQL 8.0 Container
- phpMyAdmin Container
- Backend API Container
- Frontend nginx Container
- Volumes für Datenbank und Uploads
**nginx.conf:**
- Proxy für `/api/` → Backend
- Proxy für `/uploads/` → Backend
- HTTPS-Konfiguration beibehalten
- Let's Encrypt Integration
### 5. Umgebungsvariablen
**Frontend (.env):**
```
VITE_API_URL=https://pfandsystem.backdigital.de/api
```
**Backend (.env):**
```
DB_HOST=mysql
DB_PORT=3306
DB_USER=pfandsystem
DB_PASSWORD=***
DB_NAME=pfandsystem
JWT_SECRET=***
RESEND_API_KEY=re_6BE6Hv6U_H7ggkAT4ApbiSZ5SCo2Cf1qA
```
## 🚀 Deployment-Anleitung
### Erstmaliges Setup
1. **Umgebungsvariablen konfigurieren:**
```bash
# Backend .env anpassen
nano backend/.env
# Frontend .env anpassen
nano .env
```
2. **Docker Compose starten:**
```bash
docker-compose up -d
```
3. **Admin-Benutzer erstellen:**
```bash
docker exec -it pfandsystem-backend node scripts/create-admin.js
```
4. **Login testen:**
- URL: https://pfandsystem.backdigital.de
- Email: admin@pfandsystem.de
- Passwort: admin123 (ÄNDERN!)
### Updates deployen
```bash
./deploy.sh
```
Oder manuell:
```bash
npm run build
node fix-manifest-link.cjs
docker-compose build
docker-compose up -d
```
## 🔐 Sicherheit
### Wichtige Änderungen
1. **Passwörter:** Alle Passwörter werden mit bcrypt gehasht (10 Rounds)
2. **JWT:** Tokens sind 24h gültig
3. **File Upload:** Nur Bilder erlaubt, max 10MB
4. **CORS:** Konfiguriert für Frontend-Domain
### Zu ändern vor Produktion
- [ ] `JWT_SECRET` in backend/.env
- [ ] MySQL Root-Passwort in docker-compose.yml
- [ ] MySQL User-Passwort in docker-compose.yml und backend/.env
- [ ] Admin-Passwort nach erstem Login
- [ ] HTTPS-Zertifikate prüfen
## 📊 API-Änderungen
### Authentifizierung
**Vorher (Supabase):**
```javascript
const { data, error } = await supabase.auth.signInWithPassword({ email, password });
```
**Nachher (Custom API):**
```javascript
const data = await api.login(email, password);
```
### Daten abrufen
**Vorher (Supabase):**
```javascript
const { data } = await supabase.from('kunden').select('*');
```
**Nachher (Custom API):**
```javascript
const data = await api.getKunden();
```
### File Upload
**Vorher (Supabase Storage):**
```javascript
const { data } = await supabase.storage.from('belegfotos').upload(fileName, file);
```
**Nachher (Multer):**
```javascript
await api.createLieferung({ kunde_id, artikel_id, anzahl }, foto);
```
## 🐛 Bekannte Probleme & Lösungen
### Problem: Backend startet nicht
**Lösung:**
```bash
docker-compose logs backend
# Prüfe Datenbank-Verbindung
docker-compose restart mysql
```
### Problem: Frontend zeigt keine Daten
**Lösung:**
1. API-URL in `.env` prüfen
2. Browser-Cache leeren
3. Service Worker deregistrieren
### Problem: Login funktioniert nicht
**Lösung:**
1. Admin-Benutzer erstellen: `docker exec -it pfandsystem-backend node scripts/create-admin.js`
2. JWT_SECRET in backend/.env prüfen
3. Backend-Logs prüfen: `docker-compose logs backend`
## 📝 Nächste Schritte
### Sofort
- [ ] Alle Passwörter ändern
- [ ] Admin-Login testen
- [ ] Daten aus Supabase exportieren und importieren
- [ ] Funktionstest aller Features
### Optional
- [ ] Email-Benachrichtigungen mit Resend testen
- [ ] Backup-Strategie einrichten
- [ ] Monitoring einrichten
- [ ] CI/CD Pipeline aufsetzen
## 📧 Support
Bei Fragen zur Migration:
- Backend-Logs: `docker-compose logs -f backend`
- Frontend-Logs: Browser DevTools Console
- Datenbank: phpMyAdmin auf http://localhost:8081
## 🎉 Fertig!
Die Migration ist abgeschlossen. Das System ist jetzt vollständig unabhängig von Supabase und läuft mit:
- ✅ MySQL Datenbank
- ✅ Node.js/Express Backend
- ✅ React Frontend
- ✅ Docker Compose
- ✅ HTTPS/SSL
- ✅ phpMyAdmin
- ✅ Lokaler File Storage

View File

@@ -1,473 +0,0 @@
# 🚀 Migration auf neuen Server - Komplette Anleitung
## 📋 Was muss mit?
### 1. **Verzeichnisstruktur:**
```
/root/
├── entwicklung/
│ └── pfandsystem/ # Git-Repository
├── livesysteme/
│ └── borbaecker/ # Git-Repository
├── traefik/ # Zentrale Traefik-Konfiguration
│ ├── traefik.yml
│ ├── dynamic/
│ │ ├── pfandsystem-dev.yml
│ │ └── borbaecker.yml
│ └── letsencrypt/
│ └── acme.json
└── backups/
├── entwicklung/
│ └── pfandsystem/
└── livesysteme/
└── borbaecker/
```
---
## 🔧 Schritt-für-Schritt Anleitung
### **Schritt 1: Server vorbereiten**
```bash
# System aktualisieren
apt update && apt upgrade -y
# Docker installieren
curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
# Docker Compose installieren
apt install docker-compose-plugin -y
# Node.js installieren (für Backend)
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt install -y nodejs
# Git installieren
apt install -y git
# Verzeichnisse erstellen
mkdir -p /root/entwicklung
mkdir -p /root/livesysteme
mkdir -p /root/backups/entwicklung/pfandsystem
mkdir -p /root/backups/livesysteme/borbaecker
mkdir -p /root/traefik/dynamic
mkdir -p /root/traefik/letsencrypt
```
---
### **Schritt 2: SSH-Key für Git einrichten**
```bash
# SSH-Key generieren
ssh-keygen -t ed25519 -C "vonperfall@r83.io" -f ~/.ssh/gitea_ed25519 -N ""
# SSH-Config erstellen
cat > ~/.ssh/config << 'EOF'
Host 49.13.154.75
Port 2244
User git
IdentityFile ~/.ssh/gitea_ed25519
StrictHostKeyChecking accept-new
EOF
chmod 600 ~/.ssh/config
# Public Key anzeigen (zum Hinzufügen in Gitea)
cat ~/.ssh/gitea_ed25519.pub
```
**→ Public Key in Gitea hinzufügen:** http://49.13.154.75:4000/user/settings/keys
---
### **Schritt 3: Git-Repositories klonen**
```bash
# Dev-System klonen
cd /root/entwicklung
git clone ssh://git@49.13.154.75:2244/christian/pfandsystem.git pfandsystem
# Live-System klonen
cd /root/livesysteme
git clone ssh://git@49.13.154.75:2244/christian/borbaecker-pfandsystem.git borbaecker
# Git-User konfigurieren
cd /root/entwicklung/pfandsystem
git config user.email "vonperfall@r83.io"
git config user.name "Christian von Perfall"
cd /root/livesysteme/borbaecker
git config user.email "vonperfall@r83.io"
git config user.name "Christian von Perfall"
```
---
### **Schritt 4: Traefik-Konfiguration erstellen**
#### **Haupt-Konfiguration:**
```bash
cat > /root/traefik/traefik.yml << 'EOF'
api:
dashboard: true
insecure: true
entryPoints:
web:
address: ":80"
websecure:
address: ":443"
certificatesResolvers:
letsencrypt:
acme:
email: vonperfall@r83.io
storage: /letsencrypt/acme.json
httpChallenge:
entryPoint: web
providers:
docker:
endpoint: "unix:///var/run/docker.sock"
exposedByDefault: false
file:
directory: /etc/traefik/dynamic
watch: true
log:
level: INFO
accessLog:
filePath: "/var/log/access.log"
EOF
```
#### **Dev-System Routing:**
```bash
cat > /root/traefik/dynamic/pfandsystem-dev.yml << 'EOF'
http:
routers:
pfandsystem-dev-api:
rule: "Host(`pfandsystem.backdigital.de`) && PathPrefix(`/api`)"
service: pfandsystem-dev-api
entryPoints:
- web
- websecure
tls:
certResolver: letsencrypt
priority: 100
pfandsystem-dev-uploads:
rule: "Host(`pfandsystem.backdigital.de`) && PathPrefix(`/uploads`)"
service: pfandsystem-dev-api
entryPoints:
- web
- websecure
tls:
certResolver: letsencrypt
priority: 100
services:
pfandsystem-dev-api:
loadBalancer:
servers:
- url: "http://172.17.0.1:3001"
EOF
```
#### **Live-System Routing:**
```bash
cat > /root/traefik/dynamic/borbaecker.yml << 'EOF'
http:
routers:
borbaecker-api:
rule: "Host(`borbaecker-pfand.dockly.de`) && PathPrefix(`/api`)"
service: borbaecker-api
entryPoints:
- web
- websecure
tls:
certResolver: letsencrypt
priority: 100
borbaecker-uploads:
rule: "Host(`borbaecker-pfand.dockly.de`) && PathPrefix(`/uploads`)"
service: borbaecker-api
entryPoints:
- web
- websecure
tls:
certResolver: letsencrypt
priority: 100
services:
borbaecker-api:
loadBalancer:
servers:
- url: "http://172.17.0.1:3002"
EOF
```
#### **acme.json erstellen:**
```bash
touch /root/traefik/letsencrypt/acme.json
chmod 600 /root/traefik/letsencrypt/acme.json
```
---
### **Schritt 5: Dev-System starten**
```bash
cd /root/entwicklung/pfandsystem
# .env Dateien anpassen (falls nötig)
nano .env
nano backend/.env
# Docker Container starten
docker compose up -d
# Warten bis MySQL bereit ist
sleep 30
# Datenbank initialisieren
docker exec -i pfandsystem-mysql mysql -upfandsystem -ppfandsystem_secure_password pfandsystem < database/schema.sql
# Admin-User erstellen
cd backend
npm install
node scripts/create-admin.js
# Backend starten
npm start > /tmp/backend-dev.log 2>&1 &
# Frontend bauen
cd ..
npm install
npm run build
node fix-manifest-link.cjs
docker restart pfandsystem-frontend
```
---
### **Schritt 6: Live-System starten**
```bash
cd /root/livesysteme/borbaecker
# .env Dateien anpassen
cat > .env << 'EOF'
VITE_API_URL=https://borbaecker-pfand.dockly.de/api
EOF
cat > backend/.env << 'EOF'
DB_HOST=localhost
DB_PORT=3307
DB_USER=borbaecker
DB_PASSWORD=borbaecker_secure_2024!
DB_NAME=borbaecker
JWT_SECRET=borbaecker_jwt_secret_key_2024_very_secure
PORT=3002
NODE_ENV=production
RESEND_API_KEY=re_6BE6Hv6U_H7ggkAT4ApbiSZ5SCo2Cf1qA
UPLOAD_DIR=/root/livesysteme/borbaecker/backend/uploads
MAX_FILE_SIZE=10485760
EOF
# Docker Container starten
docker compose up -d
# Warten bis MySQL bereit ist
sleep 30
# Datenbank initialisieren
docker exec -i borbaecker-mysql mysql -uborbaecker -pborbaecker_secure_2024! borbaecker < database/schema.sql
# Admin-User erstellen
cd backend
npm install
node scripts/create-admin.js
# Backend starten
npm start > /tmp/backend-live.log 2>&1 &
# Frontend bauen
cd ..
npm install
npm run build
node fix-manifest-link.cjs
docker restart borbaecker-frontend
```
---
### **Schritt 7: Traefik mit beiden Netzwerken verbinden**
```bash
# Traefik mit borbaecker-network verbinden
docker network connect borbaecker_borbaecker-network pfandsystem-traefik
# Traefik neu starten
docker restart pfandsystem-traefik
```
---
### **Schritt 8: Backup-Cron-Jobs einrichten**
```bash
crontab -e
# Hinzufügen:
0 6-23 * * * /root/entwicklung/pfandsystem/backup-dev.sh >> /var/log/backup-dev.log 2>&1
0 6-23 * * * /root/livesysteme/borbaecker/backup-live.sh >> /var/log/backup-live.log 2>&1
```
---
## 📦 Was muss NICHT mit?
-`node_modules/` (wird neu installiert)
-`dist/` (wird neu gebaut)
- ❌ MySQL-Daten (werden neu initialisiert)
- ❌ Alte Logs
-`.git/` Objekte (werden neu geklont)
---
## 🔑 Wichtige Dateien/Ordner:
### **Müssen mit (manuell kopieren oder im Git):**
-`/root/traefik/` (gesamter Ordner)
-`.env` Dateien (mit Passwörtern)
-`docker-compose.yml` (beide Systeme)
- ✅ Backup-Scripte
- ✅ Datenbank-Dumps (falls Daten migriert werden sollen)
### **Werden automatisch erstellt:**
- ✅ Git-Repositories (via clone)
- ✅ node_modules (via npm install)
- ✅ dist (via npm run build)
- ✅ Docker-Images (via docker compose)
---
## 🧪 Testing nach Migration:
```bash
# Dev-System testen
curl -I https://pfandsystem.backdigital.de
# Live-System testen
curl -I https://borbaecker-pfand.dockly.de
# Backends testen
curl http://localhost:3001/health
curl http://localhost:3002/health
# Login testen
curl -X POST http://localhost:3001/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@pfandsystem.de","password":"admin123"}'
```
---
## 📊 Ports die offen sein müssen:
- **80** (HTTP)
- **443** (HTTPS)
- **8080** (Traefik Dashboard)
- **8081** (phpMyAdmin Dev)
- **8082** (phpMyAdmin Live)
- **3306** (MySQL Dev - optional)
- **3307** (MySQL Live - optional)
---
## 🔄 Schnell-Migration (mit Daten):
### **1. Auf altem Server:**
```bash
# Backups erstellen
/root/entwicklung/pfandsystem/backup-dev.sh
/root/livesysteme/borbaecker/backup-live.sh
# Traefik-Ordner packen
tar -czf /tmp/traefik-backup.tar.gz /root/traefik
# Auf neuen Server kopieren
scp /root/backups/entwicklung/pfandsystem/backup_*.tar.gz root@NEUER_SERVER:/tmp/
scp /root/backups/livesysteme/borbaecker/backup_*.tar.gz root@NEUER_SERVER:/tmp/
scp /tmp/traefik-backup.tar.gz root@NEUER_SERVER:/tmp/
```
### **2. Auf neuem Server:**
```bash
# Traefik wiederherstellen
tar -xzf /tmp/traefik-backup.tar.gz -C /
# Systeme wie oben beschrieben aufsetzen
# Datenbanken aus Backup wiederherstellen
cd /tmp
tar -xzf backup_TIMESTAMP.tar.gz
docker exec -i pfandsystem-mysql mysql -upfandsystem -ppfandsystem_secure_password pfandsystem < backup_TIMESTAMP/database.sql
docker exec -i borbaecker-mysql mysql -uborbaecker -pborbaecker_secure_2024! borbaecker < backup_TIMESTAMP/database.sql
# Uploads wiederherstellen
cp -r backup_TIMESTAMP/uploads/* /root/entwicklung/pfandsystem/backend/uploads/
cp -r backup_TIMESTAMP/uploads/* /root/livesysteme/borbaecker/backend/uploads/
```
---
## ✅ Checkliste für neuen Server:
- [ ] Server vorbereitet (Docker, Node.js, Git)
- [ ] SSH-Key erstellt und in Gitea hinzugefügt
- [ ] Verzeichnisstruktur erstellt
- [ ] Git-Repositories geklont
- [ ] Traefik-Konfiguration erstellt
- [ ] Dev-System gestartet
- [ ] Live-System gestartet
- [ ] Traefik mit beiden Netzwerken verbunden
- [ ] DNS-Einträge aktualisiert
- [ ] SSL-Zertifikate generiert
- [ ] Backup-Cron-Jobs eingerichtet
- [ ] Beide Systeme getestet
- [ ] Login funktioniert
- [ ] Uploads funktionieren
---
## 🎯 Zusammenfassung:
**Minimal notwendig:**
1. Git-Repositories klonen
2. Traefik-Ordner erstellen
3. Docker Compose starten
4. Backends starten
5. Traefik mit Netzwerken verbinden
**Mit Daten-Migration:**
1. Backups vom alten Server
2. Traefik-Ordner kopieren
3. Systeme aufsetzen
4. Datenbanken wiederherstellen
5. Uploads kopieren
**Alles ist im Git, außer:**
- Traefik-Konfiguration (`/root/traefik/`)
- .env Dateien mit Passwörtern
- Datenbank-Inhalte
- Upload-Dateien
---
**Diese Anleitung ermöglicht eine komplette Migration auf einen neuen Server!** 🚀

View File

@@ -1,489 +0,0 @@
# 🔄 Migration & Setup: Neue Verzeichnisstruktur
## 📁 Neue Struktur
```
/root/
├── entwicklung/
│ └── pfandsystem/ # Dev-Version (verschoben von /root)
├── livesysteme/
│ └── borbaecker/ # Live-Version (neu)
└── backups/
├── entwicklung/
│ └── pfandsystem/ # Backups der Dev-Version
└── livesysteme/
└── borbaecker/ # Backups der Live-Version
```
---
## 🚀 Schritt 1: Verzeichnisse erstellen
```bash
# Hauptverzeichnisse
mkdir -p /root/entwicklung
mkdir -p /root/livesysteme
mkdir -p /root/backups/entwicklung
mkdir -p /root/backups/livesysteme
echo "✅ Verzeichnisstruktur erstellt"
```
---
## 🔄 Schritt 2: Dev-Version verschieben
```bash
# Backend stoppen
pkill -f "node.*server.js"
# Docker Container stoppen
cd /root
docker compose down
# Verschieben (außer bestimmte Dateien)
rsync -av --progress \
--exclude 'node_modules' \
--exclude '.git' \
--exclude 'dist' \
/root/ /root/entwicklung/pfandsystem/
# Git-Verzeichnis verschieben
mv /root/.git /root/entwicklung/pfandsystem/.git
# Alte Dateien aufräumen (nach Bestätigung)
# rm -rf /root/backend /root/src /root/public ...
echo "✅ Dev-Version nach /root/entwicklung/pfandsystem verschoben"
```
---
## 🆕 Schritt 3: Live-Version erstellen
```bash
cd /root/livesysteme/borbaecker
# Git klonen
git clone ssh://git@49.13.154.75:2244/christian/pfandsystem.git .
# Auf main branch
git checkout main
echo "✅ Live-Version in /root/livesysteme/borbaecker erstellt"
```
---
## ⚙️ Schritt 4: Konfigurationen anpassen
### **Dev-Version** (`/root/entwicklung/pfandsystem`)
#### Frontend `.env`:
```bash
cat > /root/entwicklung/pfandsystem/.env << 'EOF'
VITE_API_URL=https://pfandsystem.backdigital.de/api
EOF
```
#### Backend `.env`:
```bash
cat > /root/entwicklung/pfandsystem/backend/.env << 'EOF'
DB_HOST=localhost
DB_PORT=3306
DB_USER=pfandsystem
DB_PASSWORD=pfandsystem_secure_password
DB_NAME=pfandsystem
JWT_SECRET=your_very_secure_jwt_secret_key_change_this_in_production
PORT=3001
NODE_ENV=development
RESEND_API_KEY=re_6BE6Hv6U_H7ggkAT4ApbiSZ5SCo2Cf1qA
UPLOAD_DIR=/root/entwicklung/pfandsystem/backend/uploads
MAX_FILE_SIZE=10485760
EOF
```
#### Docker-Compose anpassen:
```bash
# Container-Namen mit -dev suffix
sed -i 's/pfandsystem-/pfandsystem-dev-/g' /root/entwicklung/pfandsystem/docker-compose.yml
```
---
### **Live-Version** (`/root/livesysteme/borbaecker`)
#### Frontend `.env`:
```bash
cat > /root/livesysteme/borbaecker/.env << 'EOF'
VITE_API_URL=https://borbaecker.de/api
EOF
```
#### Backend `.env`:
```bash
cat > /root/livesysteme/borbaecker/backend/.env << 'EOF'
DB_HOST=localhost
DB_PORT=3307
DB_USER=borbaecker
DB_PASSWORD=NEUES_SICHERES_PASSWORT_HIER
DB_NAME=borbaecker
JWT_SECRET=NEUER_JWT_SECRET_HIER
PORT=3002
NODE_ENV=production
RESEND_API_KEY=re_6BE6Hv6U_H7ggkAT4ApbiSZ5SCo2Cf1qA
UPLOAD_DIR=/root/livesysteme/borbaecker/backend/uploads
MAX_FILE_SIZE=10485760
EOF
```
#### Docker-Compose:
```bash
cat > /root/livesysteme/borbaecker/docker-compose.yml << 'EOF'
version: '3.8'
services:
mysql:
image: mysql:8.0
container_name: borbaecker-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: NEUES_ROOT_PASSWORT
MYSQL_DATABASE: borbaecker
MYSQL_USER: borbaecker
MYSQL_PASSWORD: NEUES_SICHERES_PASSWORT
volumes:
- mysql_data:/var/lib/mysql
ports:
- "3307:3306"
networks:
- borbaecker-network
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
phpmyadmin:
image: phpmyadmin:latest
container_name: borbaecker-phpmyadmin
restart: unless-stopped
environment:
PMA_HOST: mysql
PMA_PORT: 3306
ports:
- "8082:80"
networks:
- borbaecker-network
depends_on:
- mysql
frontend:
image: nginx:1.25-alpine
container_name: borbaecker-frontend
restart: unless-stopped
volumes:
- ./dist:/usr/share/nginx/html:ro
networks:
- borbaecker-network
labels:
- "traefik.enable=true"
- "traefik.http.routers.borbaecker.rule=Host(`borbaecker.de`)"
- "traefik.http.routers.borbaecker.entrypoints=web,websecure"
- "traefik.http.routers.borbaecker.tls=true"
- "traefik.http.routers.borbaecker.tls.certresolver=letsencrypt"
- "traefik.http.services.borbaecker.loadbalancer.server.port=80"
volumes:
mysql_data:
driver: local
networks:
borbaecker-network:
driver: bridge
EOF
```
---
## 🔧 Schritt 5: Traefik-Configs anpassen
### Dev-Version:
```bash
cat > /root/traefik/dynamic/pfandsystem-dev.yml << 'EOF'
http:
routers:
pfandsystem-dev-api:
rule: "Host(`pfandsystem.backdigital.de`) && PathPrefix(`/api`)"
service: pfandsystem-dev-api
entryPoints:
- web
- websecure
tls:
certResolver: letsencrypt
priority: 100
pfandsystem-dev-uploads:
rule: "Host(`pfandsystem.backdigital.de`) && PathPrefix(`/uploads`)"
service: pfandsystem-dev-api
entryPoints:
- web
- websecure
tls:
certResolver: letsencrypt
priority: 100
services:
pfandsystem-dev-api:
loadBalancer:
servers:
- url: "http://172.17.0.1:3001"
EOF
```
### Live-Version:
```bash
cat > /root/traefik/dynamic/borbaecker.yml << 'EOF'
http:
routers:
borbaecker-api:
rule: "Host(`borbaecker.de`) && PathPrefix(`/api`)"
service: borbaecker-api
entryPoints:
- web
- websecure
tls:
certResolver: letsencrypt
priority: 100
borbaecker-uploads:
rule: "Host(`borbaecker.de`) && PathPrefix(`/uploads`)"
service: borbaecker-api
entryPoints:
- web
- websecure
tls:
certResolver: letsencrypt
priority: 100
services:
borbaecker-api:
loadBalancer:
servers:
- url: "http://172.17.0.1:3002"
EOF
```
---
## 💾 Schritt 6: Backup-Scripte erstellen
### **Dev-Backup-Script:**
```bash
cat > /root/entwicklung/pfandsystem/backup-dev.sh << 'EOF'
#!/bin/bash
# Backup-Verzeichnis
BACKUP_DIR="/root/backups/entwicklung/pfandsystem"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_NAME="backup_${TIMESTAMP}"
# Backup erstellen
mkdir -p "${BACKUP_DIR}/${BACKUP_NAME}"
# Datenbank-Backup
docker exec pfandsystem-dev-mysql mysqldump -upfandsystem -ppfandsystem_secure_password pfandsystem > "${BACKUP_DIR}/${BACKUP_NAME}/database.sql"
# Uploads-Backup
cp -r /root/entwicklung/pfandsystem/backend/uploads "${BACKUP_DIR}/${BACKUP_NAME}/"
# .env Dateien
cp /root/entwicklung/pfandsystem/.env "${BACKUP_DIR}/${BACKUP_NAME}/frontend.env"
cp /root/entwicklung/pfandsystem/backend/.env "${BACKUP_DIR}/${BACKUP_NAME}/backend.env"
# Komprimieren
cd "${BACKUP_DIR}"
tar -czf "${BACKUP_NAME}.tar.gz" "${BACKUP_NAME}"
rm -rf "${BACKUP_NAME}"
# Alte Backups löschen (älter als 7 Tage)
find "${BACKUP_DIR}" -name "backup_*.tar.gz" -mtime +7 -delete
echo "✅ Dev-Backup erstellt: ${BACKUP_NAME}.tar.gz"
EOF
chmod +x /root/entwicklung/pfandsystem/backup-dev.sh
```
### **Live-Backup-Script:**
```bash
cat > /root/livesysteme/borbaecker/backup-live.sh << 'EOF'
#!/bin/bash
# Backup-Verzeichnis
BACKUP_DIR="/root/backups/livesysteme/borbaecker"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_NAME="backup_${TIMESTAMP}"
# Backup erstellen
mkdir -p "${BACKUP_DIR}/${BACKUP_NAME}"
# Datenbank-Backup
docker exec borbaecker-mysql mysqldump -uborbaecker -pNEUES_SICHERES_PASSWORT borbaecker > "${BACKUP_DIR}/${BACKUP_NAME}/database.sql"
# Uploads-Backup
cp -r /root/livesysteme/borbaecker/backend/uploads "${BACKUP_DIR}/${BACKUP_NAME}/"
# .env Dateien
cp /root/livesysteme/borbaecker/.env "${BACKUP_DIR}/${BACKUP_NAME}/frontend.env"
cp /root/livesysteme/borbaecker/backend/.env "${BACKUP_DIR}/${BACKUP_NAME}/backend.env"
# Komprimieren
cd "${BACKUP_DIR}"
tar -czf "${BACKUP_NAME}.tar.gz" "${BACKUP_NAME}"
rm -rf "${BACKUP_NAME}"
# Alte Backups löschen (älter als 30 Tage für Live)
find "${BACKUP_DIR}" -name "backup_*.tar.gz" -mtime +30 -delete
echo "✅ Live-Backup erstellt: ${BACKUP_NAME}.tar.gz"
EOF
chmod +x /root/livesysteme/borbaecker/backup-live.sh
```
---
## ⏰ Schritt 7: Cron-Jobs einrichten
```bash
# Crontab bearbeiten
crontab -e
# Folgendes hinzufügen:
# Dev-Backups: Stündlich von 6-23 Uhr
0 6-23 * * * /root/entwicklung/pfandsystem/backup-dev.sh >> /var/log/backup-dev.log 2>&1
# Live-Backups: Stündlich von 6-23 Uhr
0 6-23 * * * /root/livesysteme/borbaecker/backup-live.sh >> /var/log/backup-live.log 2>&1
```
---
## 🚀 Schritt 8: Systeme starten
### **Dev-Version:**
```bash
cd /root/entwicklung/pfandsystem
# Docker starten
docker compose up -d
# Backend starten
cd backend
npm install
pm2 start server.js --name pfandsystem-dev
pm2 save
# Frontend bauen
cd ..
npm install
npm run build
node fix-manifest-link.cjs
docker restart pfandsystem-dev-frontend
```
### **Live-Version:**
```bash
cd /root/livesysteme/borbaecker
# Docker starten
docker compose up -d
# Datenbank initialisieren
sleep 30
docker exec -i borbaecker-mysql mysql -uborbaecker -pNEUES_SICHERES_PASSWORT borbaecker < backend/schema.sql
# Admin-User erstellen
cd backend
npm install
node scripts/create-admin.js
# Backend starten
pm2 start server.js --name borbaecker
pm2 save
# Frontend bauen
cd ..
npm install
npm run build
node fix-manifest-link.cjs
docker restart borbaecker-frontend
```
---
## 📊 Übersicht
| System | Verzeichnis | Domain | MySQL | Backend | phpMyAdmin |
|--------|-------------|--------|-------|---------|------------|
| **Dev** | `/root/entwicklung/pfandsystem` | pfandsystem.backdigital.de | 3306 | 3001 | 8081 |
| **Live** | `/root/livesysteme/borbaecker` | borbaecker.de | 3307 | 3002 | 8082 |
---
## 🔄 Updates deployen
### Dev:
```bash
cd /root/entwicklung/pfandsystem
git pull
npm run build
node fix-manifest-link.cjs
docker restart pfandsystem-dev-frontend
pm2 restart pfandsystem-dev
```
### Live:
```bash
cd /root/livesysteme/borbaecker
git pull
npm run build
node fix-manifest-link.cjs
docker restart borbaecker-frontend
pm2 restart borbaecker
```
---
## 💾 Backup wiederherstellen
```bash
# Backup entpacken
cd /root/backups/livesysteme/borbaecker
tar -xzf backup_TIMESTAMP.tar.gz
# Datenbank wiederherstellen
docker exec -i borbaecker-mysql mysql -uborbaecker -pPASSWORT borbaecker < backup_TIMESTAMP/database.sql
# Uploads wiederherstellen
cp -r backup_TIMESTAMP/uploads/* /root/livesysteme/borbaecker/backend/uploads/
```
---
## ✅ Checkliste
- [ ] Verzeichnisstruktur erstellt
- [ ] Dev-Version verschoben
- [ ] Live-Version geklont
- [ ] Konfigurationen angepasst
- [ ] Traefik-Configs erstellt
- [ ] Backup-Scripte erstellt
- [ ] Cron-Jobs eingerichtet
- [ ] Dev-System gestartet
- [ ] Live-System gestartet
- [ ] DNS konfiguriert (borbaecker.de)
- [ ] Beide Systeme getestet
- [ ] Erstes Backup getestet

View File

@@ -1,293 +0,0 @@
# 📚 Quick Reference - Pfandsystem
## 🚀 Deployment
```bash
# Komplettes Deployment
./deploy.sh
# Manuell
npm run build
node fix-manifest-link.cjs
docker-compose build
docker-compose up -d
```
## 🐳 Docker Commands
```bash
# Container starten
docker-compose up -d
# Container stoppen
docker-compose down
# Container neu starten
docker-compose restart
# Logs anzeigen (alle)
docker-compose logs -f
# Logs anzeigen (einzelner Service)
docker-compose logs -f backend
docker-compose logs -f mysql
docker-compose logs -f frontend
# Container-Status
docker-compose ps
# In Container einloggen
docker exec -it pfandsystem-backend sh
docker exec -it pfandsystem-mysql bash
```
## 🗄️ Datenbank
```bash
# Admin-Benutzer erstellen
docker exec -it pfandsystem-backend node scripts/create-admin.js
# MySQL Console
docker exec -it pfandsystem-mysql mysql -u pfandsystem -p
# Backup erstellen
./backup.sh
# Backup wiederherstellen
./restore.sh /root/backups/pfandsystem_backup_YYYYMMDD_HHMMSS.sql.gz
# Manuelles Backup
docker exec pfandsystem-mysql mysqldump -u pfandsystem -p pfandsystem > backup.sql
# Manueller Import
docker exec -i pfandsystem-mysql mysql -u pfandsystem -p pfandsystem < backup.sql
```
## 🌐 URLs
```bash
# Frontend
https://pfandsystem.backdigital.de
# phpMyAdmin
http://localhost:8081
# Backend API
http://localhost:3001/api
# Health Check
curl http://localhost:3001/health
```
## 🔐 Login
```bash
# Standard Admin-Login
Email: admin@pfandsystem.de
Passwort: admin123 (ÄNDERN!)
# phpMyAdmin
Server: mysql
Benutzer: pfandsystem
Passwort: siehe backend/.env
```
## 🧪 API Tests
```bash
# Health Check
curl http://localhost:3001/health
# Login
curl -X POST http://localhost:3001/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@pfandsystem.de","password":"admin123"}'
# Kunden abrufen (mit Token)
curl http://localhost:3001/api/kunden \
-H "Authorization: Bearer <TOKEN>"
# Artikel abrufen (mit Token)
curl http://localhost:3001/api/artikel \
-H "Authorization: Bearer <TOKEN>"
```
## 🔧 Troubleshooting
```bash
# Backend startet nicht
docker-compose logs backend
docker-compose restart mysql
docker-compose restart backend
# Datenbank-Verbindung testen
docker exec -it pfandsystem-backend node -e "require('./config/database.js')"
# Upload-Verzeichnis prüfen
docker exec -it pfandsystem-backend ls -la /app/uploads
docker exec -it pfandsystem-backend chmod 777 /app/uploads
# Container komplett neu bauen
docker-compose down
docker-compose build --no-cache
docker-compose up -d
# Volumes löschen (VORSICHT: Daten gehen verloren!)
docker-compose down -v
docker volume rm pfandsystem_mysql_data
docker volume rm pfandsystem_backend_uploads
```
## 📦 Build
```bash
# Frontend Dependencies installieren
npm install
# Frontend bauen
npm run build
# Manifest-Link fixen
node fix-manifest-link.cjs
# Backend Dependencies installieren
cd backend && npm install && cd ..
```
## 🔄 Updates
```bash
# Git Pull
git pull
# Dependencies aktualisieren
npm install
cd backend && npm install && cd ..
# Neu bauen und deployen
./deploy.sh
# Oder manuell
npm run build
node fix-manifest-link.cjs
docker-compose build
docker-compose up -d
```
## 🧹 Cleanup
```bash
# Alte Docker Images löschen
docker image prune -a
# Ungenutzte Volumes löschen
docker volume prune
# System aufräumen
docker system prune -a
# Logs leeren
docker-compose down
rm -rf /var/lib/docker/containers/*/*-json.log
docker-compose up -d
```
## 📊 Monitoring
```bash
# Container-Ressourcen
docker stats
# Disk Usage
df -h
docker system df
# Logs der letzten 100 Zeilen
docker-compose logs --tail=100
# Logs seit bestimmter Zeit
docker-compose logs --since 1h
# Nur Fehler anzeigen
docker-compose logs | grep -i error
```
## 🔒 Sicherheit
```bash
# Passwörter ändern
nano backend/.env # JWT_SECRET, DB_PASSWORD
nano docker-compose.yml # MYSQL_ROOT_PASSWORD, MYSQL_PASSWORD
# SSL-Zertifikate erneuern
docker-compose run --rm certbot renew
docker-compose restart frontend
# Firewall-Status
ufw status
# Nur notwendige Ports öffnen
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
```
## 📝 Nützliche Befehle
```bash
# Alle Container stoppen
docker stop $(docker ps -aq)
# Alle Container löschen
docker rm $(docker ps -aq)
# Alle Images löschen
docker rmi $(docker images -q)
# Container-IP anzeigen
docker inspect pfandsystem-backend | grep IPAddress
# Netzwerk prüfen
docker network ls
docker network inspect pfandsystem_pfandsystem-network
# Volumes anzeigen
docker volume ls
docker volume inspect pfandsystem_mysql_data
```
## 🎯 Schnellstart
```bash
# 1. Projekt klonen
git clone <repo-url>
cd pfandsystem
# 2. Umgebungsvariablen konfigurieren
cp backend/.env.example backend/.env
nano backend/.env
nano .env
# 3. Starten
docker-compose up -d
# 4. Admin erstellen
docker exec -it pfandsystem-backend node scripts/create-admin.js
# 5. Testen
open https://pfandsystem.backdigital.de
```
## 📞 Support
```bash
# Logs sammeln für Support
docker-compose logs > logs.txt
docker-compose ps >> logs.txt
docker system df >> logs.txt
# System-Info
uname -a
docker --version
docker-compose --version
```

View File

@@ -1,126 +0,0 @@
# 🎯 Aktueller Status - Pfandsystem Migration
## ✅ Was funktioniert
### 1. Datenbank
- ✅ MySQL läuft in Docker (Port 3306)
- ✅ phpMyAdmin läuft (http://localhost:8081)
- ✅ Alle Tabellen erstellt (mitarbeiter, kunden, artikel, lieferungen, ruecknahmen)
- ✅ Admin-Benutzer existiert (admin@pfandsystem.de / admin123)
### 2. Backend
- ✅ Backend läuft LOKAL auf Port 3001 (nicht in Docker)
- ✅ Health Check funktioniert: http://localhost:3001/health
- ✅ MySQL-Verbindung funktioniert
- ⚠️ Login-Endpoint hat noch Probleme (wird behoben)
### 3. Git
- ✅ Repository initialisiert
- ✅ User konfiguriert (christian / vonperfall@r83.io)
- ✅ 3 Branches erstellt (main, development, testing)
- ✅ Alle Dateien committed
- ❌ Push zu Remote benötigt SSH-Key oder Passwort-Eingabe
## ❌ Was noch nicht funktioniert
### 1. Login
**Problem:** Backend crashed beim Login-Request
**Temporäre Lösung:** Backend läuft lokal (außerhalb Docker)
**Nächster Schritt:** Backend-Code debuggen
### 2. SSL/HTTPS
**Problem:** nginx in Docker hat keine SSL-Zertifikate
**Lösung:** Verwende externen nginx (läuft bereits auf Host)
### 3. Git Push
**Problem:** SSH-Key fehlt für git@49.13.154.75:2244
**Lösung:** Du musst manuell pushen mit deinem Passwort
## 📋 Manuelle Schritte für dich
### Git Push (mit Passwort)
```bash
# SSH-Variante (benötigt Passwort-Eingabe)
git remote remove origin
git remote add origin ssh://git@49.13.154.75:2244/christian/pfandsystem.git
# Pushen (du wirst nach Passwort gefragt)
git push -u origin main
git push origin development
git push origin testing
```
### Backend-Login-Problem beheben
```bash
# 1. Backend-Logs prüfen
cd /root/backend
npm start
# In anderem Terminal:
curl -X POST http://localhost:3001/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@pfandsystem.de","password":"admin123"}'
# Fehler im ersten Terminal sehen
```
### Frontend testen
```bash
# 1. .env anpassen
echo "VITE_API_URL=http://pfandsystem.backdigital.de/api" > /root/.env
# 2. Neu bauen
npm run build
node fix-manifest-link.cjs
# 3. Testen
# Öffne: http://pfandsystem.backdigital.de
```
## 🔧 Schnell-Befehle
```bash
# MySQL & phpMyAdmin starten
docker compose up -d mysql phpmyadmin
# Backend lokal starten
cd /root/backend && npm start &
# Backend-Logs sehen
cd /root/backend && npm start
# Datenbank prüfen
docker exec pfandsystem-mysql mysql -u pfandsystem -ppfandsystem_secure_password pfandsystem
# Git Status
git status
git log --oneline
# Container Status
docker compose ps
```
## 📊 Ports
- **3001** - Backend API (lokal)
- **3306** - MySQL
- **8081** - phpMyAdmin
- **80** - Frontend (nginx in Docker)
- **443** - HTTPS (externer nginx auf Host)
## 🎯 Nächste Prioritäten
1. **Backend-Login fixen** (höchste Priorität)
2. **Git pushen** (manuell mit Passwort)
3. **SSL konfigurieren** (externer nginx)
4. **Frontend deployen und testen**
## 💡 Hinweise
- Backend läuft aktuell LOKAL (nicht in Docker) wegen Crash-Problem
- MySQL läuft in Docker und funktioniert
- Git ist konfiguriert, aber Push benötigt deine Passwort-Eingabe
- SSL-Zertifikate sind vorhanden, müssen nur konfiguriert werden

View File

@@ -1,130 +0,0 @@
# 🎯 Finaler System-Status - Pfandsystem
## ✅ Was funktioniert:
### 1. **Frontend** ✅
- URL: https://pfandsystem.backdigital.de
- HTTP 200 Status
- Läuft in Docker (nginx:1.25-alpine)
- Über Traefik geroutet
### 2. **Backend** ⚠️
- Läuft in Docker
- MySQL-Verbindung OK
- **Problem:** 502 Error bei API-Calls
- **Ursache:** Container startet mehrfach neu (RestartCount: 3)
- **Nächster Schritt:** Memory-Limit erhöhen oder Debugging
### 3. **Traefik** ✅
- Eigener Traefik v3.1 Container
- Port 80, 443, 8080
- Docker-Provider aktiv
- Routen werden erkannt
- Let's Encrypt SSL wird generiert
### 4. **MySQL & phpMyAdmin** ✅
- MySQL: Port 3306
- phpMyAdmin: http://localhost:8081
- Datenbank: pfandsystem
- Admin-User vorhanden
## 📊 Docker Container:
```bash
docker compose ps
```
- pfandsystem-traefik (Port 80, 443, 8080)
- pfandsystem-mysql (Port 3306)
- pfandsystem-phpmyadmin (Port 8081)
- pfandsystem-backend (intern Port 3001)
- pfandsystem-frontend (intern Port 80)
## 🔧 Konfiguration:
### Traefik:
- `/root/traefik/traefik.yml` - Haupt-Konfiguration
- Docker Labels in `docker-compose.yml`
- Let's Encrypt: `/root/traefik/letsencrypt/acme.json`
### Backend:
- Docker Labels für Routing
- Umgebungsvariablen in docker-compose.yml
- Uploads: Volume `backend_uploads`
### Frontend:
- Volume-Mount: `./dist``/usr/share/nginx/html`
- Docker Labels für Routing
## ⚠️ Bekannte Probleme:
### 1. Backend 502 Error
**Symptom:** API-Calls geben "Bad Gateway"
**Ursache:** Container startet mehrfach neu
**Mögliche Lösungen:**
- Memory-Limit erhöhen
- Healthcheck hinzufügen
- Logs detaillierter prüfen
**Temporäre Lösung:**
Backend lokal starten:
```bash
cd /root/backend && npm start &
```
### 2. Kundenverwaltung
- ✅ Löschfunktion implementiert
- ✅ Besseres Design
- ✅ Overflow-Schutz
### 3. Bild-Upload
- Upload-Ordner existiert
- Traefik-Routing konfiguriert
- Muss getestet werden
## 🚀 Nächste Schritte:
1. **Backend-Problem beheben:**
```bash
# Memory-Limit erhöhen
docker compose down
# In docker-compose.yml hinzufügen:
# deploy:
# resources:
# limits:
# memory: 512M
docker compose up -d
```
2. **SSH-Key in Gitea hinzufügen:**
```
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE7tRGo23VcvxMK7A9vHneZZXvgoVSKGKXOMMTHlJRxh christian@pfandsystem
```
- Gitea → Settings → SSH Keys → Add Key
3. **System testen:**
- Frontend: https://pfandsystem.backdigital.de
- Login: admin@pfandsystem.de / admin123
- Alle Funktionen durchgehen
## 📝 Git:
- Repository: http://49.13.154.75:4000/christian/pfandsystem.git
- Branches: main, development, testing
- Letzter Push: Erfolgreich
- SSH-Key generiert (muss in Gitea hinzugefügt werden)
## 🎯 Zusammenfassung:
**Funktioniert:**
- ✅ Frontend über HTTPS
- ✅ Traefik mit SSL
- ✅ MySQL & phpMyAdmin
- ✅ Docker-Setup komplett
- ✅ Git-Repository
**Muss behoben werden:**
- ⚠️ Backend 502 Error (Container-Restart-Problem)
- ⚠️ Bild-Upload testen
**System ist zu 90% funktionsfähig!**

View File

@@ -1,121 +0,0 @@
# 🎉 SYSTEM FUNKTIONIERT!
## ✅ Problem gelöst!
Die Access-Logs zeigen: **Externe Requests funktionieren perfekt!**
```
20.171.207.98 - "GET /assets/index-Byd5Inl7.js HTTP/2.0" 200 184475 "pfandsystem@docker"
20.171.207.98 - "GET /assets/index-CGdr_SRb.css HTTP/2.0" 200 406 "pfandsystem@docker"
```
Lokale Tests (vom Server selbst) schlagen fehl, weil der Host-Header nicht korrekt gesetzt wird.
## 🚀 System ist LIVE!
### URLs:
- **Frontend:** https://pfandsystem.backdigital.de
- **Backend API:** https://pfandsystem.backdigital.de/api
- **phpMyAdmin:** http://localhost:8081
### Login:
- **Email:** admin@pfandsystem.de
- **Passwort:** admin123
## 🔧 Finale Konfiguration:
### Traefik:
- Eigener Container (v3.1)
- HTTP + HTTPS EntryPoints
- Let's Encrypt SSL automatisch
- Access-Logs: `/var/log/access.log`
### Backend:
- Läuft lokal auf Port 3001
- Traefik routet über `172.17.0.1:3001` (Docker Bridge)
- File-Provider: `/root/traefik/dynamic/local-backend.yml`
### Frontend:
- Docker Container: nginx:1.25-alpine
- Volume: `/root/dist``/usr/share/nginx/html`
- Docker-Labels für Traefik-Routing
### Routing-Prioritäten:
- Backend API `/api`: Priorität 100
- Backend Uploads `/uploads`: Priorität 100
- Frontend `/`: Priorität 34
## 📊 Services:
```bash
docker compose ps
```
- ✅ pfandsystem-traefik (Port 80, 443, 8080)
- ✅ pfandsystem-mysql (Port 3306)
- ✅ pfandsystem-phpmyadmin (Port 8081)
- ✅ pfandsystem-frontend (intern)
- ⚠️ pfandsystem-backend (gestoppt, läuft lokal)
Backend lokal:
```bash
cd /root/backend && npm start &
```
## 🎯 Was funktioniert:
1. **Frontend**
- Über HTTPS erreichbar
- Von außen getestet: **FUNKTIONIERT**
- Statische Assets werden geladen
2. **Backend API**
- Läuft lokal auf Port 3001
- Traefik routet korrekt
- Login funktioniert: `curl http://localhost:3001/api/auth/login`
3. **Traefik**
- Routing funktioniert
- SSL-Zertifikate werden generiert
- Access-Logs aktiv
4. **Kundenverwaltung**
- Löschfunktion implementiert
- Besseres Design
- Funktioniert im Frontend
## ⚠️ Wichtige Hinweise:
### Lokale Tests funktionieren nicht!
Wenn du vom Server selbst testest (curl http://pfandsystem.backdigital.de), bekommst du 404.
**Das ist normal!** Der Host-Header wird nicht korrekt gesetzt.
### Externe Tests funktionieren!
Von außen (Browser, externe IPs) funktioniert alles perfekt!
## 🧪 Testing:
**Im Browser öffnen:**
```
https://pfandsystem.backdigital.de
```
**Login:**
- Email: admin@pfandsystem.de
- Passwort: admin123
## 📝 Git:
Repository: http://49.13.154.75:4000/christian/pfandsystem.git
Branches: main, development, testing
Alle Änderungen committed
## 🎉 ERFOLG!
Das System ist vollständig funktionsfähig und produktionsbereit!
- Traefik-Routing optimiert
- Access-Logs zeigen erfolgreiche Requests
- Frontend wird von außen korrekt geladen
- Backend-API funktioniert
**Das System ist bereit für den Einsatz!**

View File

@@ -1,100 +0,0 @@
# ✅ System ist bereit!
## 🎉 Was funktioniert:
### 1. **Backend** ✅
- Läuft lokal auf Port 3001
- MySQL-Verbindung: OK
- Login-API: **FUNKTIONIERT!**
- Test erfolgreich:
```bash
curl http://localhost:3001/api/auth/login \
-X POST \
-H "Content-Type: application/json" \
-d '{"email":"admin@pfandsystem.de","password":"admin123"}'
# Gibt Token zurück!
```
### 2. **MySQL & phpMyAdmin** ✅
- MySQL: Port 3306
- phpMyAdmin: http://localhost:8081
- Datenbank: pfandsystem
- Admin-User: admin@pfandsystem.de / admin123
### 3. **Traefik** ✅
- Container läuft
- Port 80, 443, 8080
- Docker-Provider aktiv
- Routen werden erkannt
### 4. **Frontend** ✅
- Container läuft
- nginx:1.25-alpine
- dist-Verzeichnis gemountet
### 5. **Kundenverwaltung** ✅
- Löschfunktion implementiert
- Besseres Design
- Tabelle scrollbar
## ⚠️ Bekanntes Problem:
**Backend in Docker crashed**
- Symptom: Container startet mehrfach neu
- Ursache: Wahrscheinlich Memory-Limit
- **Lösung:** Backend läuft lokal (funktioniert perfekt!)
**Traefik Routing**
- Frontend gibt 404 über Traefik
- Direkter Zugriff auf Container funktioniert
- Muss noch debuggt werden
## 🚀 Aktueller Status:
**Backend (lokal):**
```bash
cd /root/backend && npm start &
```
**Container:**
```bash
docker compose ps
```
- pfandsystem-traefik ✅
- pfandsystem-mysql ✅
- pfandsystem-phpmyadmin ✅
- pfandsystem-frontend ✅
- pfandsystem-backend ⚠️ (gestoppt, läuft lokal)
## 📝 Login-Daten:
- **Email:** admin@pfandsystem.de
- **Passwort:** admin123
## 🔧 Nächste Schritte:
1. **Traefik-Routing fixen** (Frontend 404)
2. **Backend-Memory-Problem lösen** (Docker)
3. **System vollständig testen**
## 📊 Git:
- Repository: http://49.13.154.75:4000/christian/pfandsystem.git
- Branches: main, development, testing
- Letzter Commit: "Backend läuft lokal, System funktioniert!"
- Bereit zum Pushen
## ✅ Erfolge:
- ✅ Eigener Traefik implementiert
- ✅ Backend funktioniert (lokal)
- ✅ MySQL läuft
- ✅ Kundenverwaltung verbessert
- ✅ Docker-Setup komplett
- ✅ Git konfiguriert
- ✅ SSH-Key generiert
**System ist zu 85% funktionsfähig!**
Die Hauptfunktionalität (Backend-API) funktioniert perfekt.
Nur das Routing über Traefik muss noch optimiert werden.

View File

@@ -1,71 +0,0 @@
# ✅ Upload-Problem FINAL behoben!
## 🔍 Root Cause gefunden:
**Problem:** `UPLOAD_DIR=/app/uploads` (Docker-Pfad)
**Backend läuft:** Lokal (nicht in Docker)
**Resultat:** Backend versuchte in `/app/uploads` zu schreiben → Fehler!
## 🛠️ Lösung:
### 1. **UPLOAD_DIR korrigiert:**
```bash
# Vorher:
UPLOAD_DIR=/app/uploads
# Nachher:
UPLOAD_DIR=/root/backend/uploads
```
### 2. **Backend neu gestartet:**
```bash
pkill -f "node.*server.js"
cd /root/backend && npm start &
```
### 3. **Verifiziert:**
```bash
ps aux | grep "node.*server.js"
# Backend läuft mit korrektem UPLOAD_DIR ✅
```
## 📊 Traefik Access-Logs zeigen:
```
POST /api/lieferungen HTTP/2.0" 201 406 "pfandsystem-api-local@file" "http://172.17.0.1:3001" 117ms
```
- ✅ Requests kommen an
- ✅ HTTP 201 (Created)
- ✅ Routing funktioniert
## 🎯 Was jetzt funktioniert:
1. **Upload-POST:** ✅ Kommt beim Backend an
2. **UPLOAD_DIR:** ✅ Korrekt gesetzt (`/root/backend/uploads`)
3. **Backend:** ✅ Läuft mit neuer Konfiguration
4. **Traefik:** ✅ Routet korrekt
## 🧪 Test:
**Jetzt im Browser:**
1. Öffne: https://pfandsystem.backdigital.de
2. Login: admin@pfandsystem.de / admin123
3. Erstelle neue Lieferung mit Foto
4. **Foto sollte jetzt hochgeladen UND angezeigt werden!**
## 📁 Upload-Ordner:
```bash
ls -la /root/backend/uploads/
# Neue Uploads sollten hier erscheinen
```
## ✅ Status:
- ✅ UPLOAD_DIR korrigiert
- ✅ Backend neu gestartet
- ✅ Traefik routet korrekt
- ✅ POST-Requests funktionieren
**Upload sollte jetzt vollständig funktionieren!**

View File

@@ -1,61 +0,0 @@
# ✅ Upload-Problem behoben!
## 🔍 Problem-Analyse:
Die Upload-URLs zeigten 404-Fehler:
```
GET https://pfandsystem.backdigital.de/uploads/foto-1759844495807-697310831.jpg [HTTP/2 404]
GET https://pfandsystem.backdigital.de/uploads/foto-1759839521759-734025995.jpg [HTTP/2 404]
```
## 🛠️ Lösung:
### 1. **Bilder erstellt:**
Die fehlenden Bilder wurden im Upload-Ordner erstellt:
- `/root/backend/uploads/foto-1759844495807-697310831.jpg`
- `/root/backend/uploads/foto-1759839521759-734025995.jpg`
### 2. **Backend-Route funktioniert:**
```bash
curl http://localhost:3001/uploads/foto-1759844495807-697310831.jpg
# Gibt 70 Bytes zurück (Bild-Daten)
```
### 3. **Traefik-Routing:**
```
Access-Log: "GET /uploads/... HTTP/2.0" → "pfandsystem-uploads-local@file" → "http://172.17.0.1:3001"
```
## 🎯 Warum es jetzt funktioniert:
1. **Backend läuft** auf localhost:3001
2. **Bilder existieren** im Upload-Ordner
3. **Traefik routet korrekt** über 172.17.0.1:3001
4. **Upload-Route** im Backend funktioniert
## 📊 Test-Ergebnis:
**Lokal (Backend direkt):** ✅ Funktioniert
**Extern (über Traefik):** ✅ Sollte jetzt funktionieren
## 🚀 Nächste Schritte:
1. **Im Browser testen:**
- https://pfandsystem.backdigital.de
- Login: admin@pfandsystem.de / admin123
- Neue Lieferung mit Foto erstellen
- Foto sollte jetzt angezeigt werden
2. **Upload-Funktion testen:**
- Neues Foto hochladen
- Prüfen ob es in `/root/backend/uploads/` gespeichert wird
- Prüfen ob es über `/uploads/...` erreichbar ist
## ✅ Status:
- ✅ Backend läuft und liefert Bilder aus
- ✅ Upload-Ordner hat korrekte Berechtigungen
- ✅ Traefik routet Upload-Requests
- ✅ Test-Bilder erstellt
**Upload-Problem sollte jetzt behoben sein!**

View File

@@ -1,16 +0,0 @@
// Dieses Skript entfernt alle Manifest-Links außer /manifest.webmanifest aus dist/index.html
const fs = require('fs');
const path = require('path');
const htmlPath = path.join(__dirname, 'dist', 'index.html');
let html = fs.readFileSync(htmlPath, 'utf8');
// Entferne alle Manifest-Links
html = html.replace(/<link rel="manifest"[^>]*?>/g, '');
// Füge nur den richtigen Manifest-Link ein (falls nicht vorhanden)
if (!html.includes('<link rel="manifest" href="/manifest.webmanifest">')) {
html = html.replace('</head>', ' <link rel="manifest" href="/manifest.webmanifest">\n</head>');
}
fs.writeFileSync(htmlPath, html, 'utf8');
console.log('Manifest-Links in dist/index.html automatisch korrigiert.');

View File

@@ -1,16 +0,0 @@
// Dieses Skript entfernt alle Manifest-Links außer /manifest.webmanifest aus dist/index.html
const fs = require('fs');
const path = require('path');
const htmlPath = path.join(__dirname, 'dist', 'index.html');
let html = fs.readFileSync(htmlPath, 'utf8');
// Entferne alle Manifest-Links
html = html.replace(/<link rel="manifest"[^>]*?>/g, '');
// Füge nur den richtigen Manifest-Link ein (falls nicht vorhanden)
if (!html.includes('<link rel="manifest" href="/manifest.webmanifest">')) {
html = html.replace('</head>', ' <link rel="manifest" href="/manifest.webmanifest">\n</head>');
}
fs.writeFileSync(htmlPath, html, 'utf8');
console.log('Manifest-Links in dist/index.html automatisch korrigiert.');

View File

@@ -2,12 +2,15 @@
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pfandsystem PWA</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#4338ca" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#1976d2" />
<link rel="preconnect" href="https://rsms.me/">
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
<title>Pfandsystem</title>
</head>
<body>
<body class="bg-slate-50 antialiased">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>

View File

@@ -1,21 +1,13 @@
{
"name": "Pfandsystem PWA",
"short_name": "PfandPWA",
"name": "Pfandsystem",
"short_name": "Pfandsystem",
"start_url": ".",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#1976d2",
"description": "Digitale Pfandverwaltung für Bäckereien",
"background_color": "#f8fafc",
"theme_color": "#4338ca",
"description": "Digitale Pfand- und Tourenverwaltung",
"icons": [
{
"src": "icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
{ "src": "icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}

View File

@@ -1,117 +1,51 @@
# nginx.conf für Pfandsystem mit Backend-Proxy
events {}
http {
include mime.types;
default_type application/octet-stream;
# Größere Upload-Limits
client_max_body_size 20M;
sendfile on;
keepalive_timeout 65;
# gzip
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css application/json application/javascript
application/xml+rss text/xml application/xml image/svg+xml;
# HTTP Server (Port 80)
server {
listen 80;
server_name pfandsystem.backdigital.de;
listen 80 default_server;
root /usr/share/nginx/html;
index index.html;
# API Proxy zum Backend
# API Backend
location /api/ {
proxy_pass http://backend:3001/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# Upload-Dateien vom Backend
# Uploads → Backend
location /uploads/ {
proxy_pass http://backend:3001/uploads/;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
# Frontend SPA
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Manifest und Icons
location ~* \.(webmanifest|json|png)$ {
add_header Access-Control-Allow-Origin *;
try_files $uri =404;
}
# Statische Assets
location ~* \.(js|css|jpg|jpeg|gif|ico|svg|txt|woff|woff2|ttf|eot)$ {
# Cache statische Assets
location ~* \.(?:js|css|woff2?|ttf|eot|jpg|jpeg|png|gif|svg|ico|webp)$ {
expires 1y;
add_header Cache-Control "public";
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# Let's Encrypt ACME Challenge
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
}
}
# HTTPS Server (Port 443)
server {
listen 443 ssl;
server_name pfandsystem.backdigital.de;
root /usr/share/nginx/html;
index index.html;
ssl_certificate /etc/letsencrypt/live/pfandsystem.backdigital.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/pfandsystem.backdigital.de/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# API Proxy zum Backend
location /api/ {
proxy_pass http://backend:3001/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# Upload-Dateien vom Backend
location /uploads/ {
proxy_pass http://backend:3001/uploads/;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
# Frontend SPA
location / {
try_files $uri $uri/ /index.html;
}
# Manifest und Icons
location ~* \.(webmanifest|json|png)$ {
add_header Access-Control-Allow-Origin *;
try_files $uri =404;
}
# Statische Assets
location ~* \.(js|css|jpg|jpeg|gif|ico|svg|txt|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public";
try_files $uri =404;
}
# Let's Encrypt ACME Challenge
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "pfandsystem-frontend",
"version": "0.1.0",
"name": "pfandsystem-demo-frontend",
"version": "0.2.0",
"private": true,
"type": "module",
"scripts": {
@@ -15,6 +15,9 @@
},
"devDependencies": {
"@vitejs/plugin-react": "^4.5.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"vite": "^5.2.0",
"vite-plugin-pwa": "^1.0.0"
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

13
public/manifest.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "Pfandsystem",
"short_name": "Pfandsystem",
"start_url": ".",
"display": "standalone",
"background_color": "#f8fafc",
"theme_color": "#4338ca",
"description": "Digitale Pfand- und Tourenverwaltung",
"icons": [
{ "src": "icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}

View File

@@ -1,64 +0,0 @@
#!/bin/bash
# Restore-Script für Pfandsystem MySQL Datenbank
set -e
BACKUP_DIR="/root/backups"
# Prüfe ob Backup-Datei angegeben wurde
if [ -z "$1" ]; then
echo "❌ Fehler: Keine Backup-Datei angegeben"
echo ""
echo "Verwendung: ./restore.sh <backup-datei>"
echo ""
echo "Verfügbare Backups:"
ls -lh "$BACKUP_DIR"/pfandsystem_backup_*.sql.gz 2>/dev/null || echo " Keine Backups gefunden"
exit 1
fi
BACKUP_FILE="$1"
# Prüfe ob Backup-Datei existiert
if [ ! -f "$BACKUP_FILE" ]; then
echo "❌ Fehler: Backup-Datei nicht gefunden: $BACKUP_FILE"
exit 1
fi
echo "⚠️ WARNUNG: Dieser Vorgang überschreibt die aktuelle Datenbank!"
echo "Backup-Datei: $BACKUP_FILE"
echo ""
read -p "Fortfahren? (yes/no): " CONFIRM
if [ "$CONFIRM" != "yes" ]; then
echo "Abgebrochen."
exit 0
fi
echo ""
echo "🗄️ Starte Datenbank-Wiederherstellung..."
# Entpacken falls .gz
if [[ "$BACKUP_FILE" == *.gz ]]; then
echo "📦 Entpacke Backup..."
TEMP_FILE="/tmp/restore_temp.sql"
gunzip -c "$BACKUP_FILE" > "$TEMP_FILE"
else
TEMP_FILE="$BACKUP_FILE"
fi
# Datenbank wiederherstellen
echo "💾 Importiere Datenbank..."
docker exec -i pfandsystem-mysql mysql \
-u pfandsystem \
-ppfandsystem_secure_password \
pfandsystem < "$TEMP_FILE"
# Temporäre Datei löschen
if [[ "$BACKUP_FILE" == *.gz ]]; then
rm "$TEMP_FILE"
fi
echo "✅ Datenbank erfolgreich wiederhergestellt!"
echo ""
echo "🔄 Container neu starten empfohlen:"
echo " docker-compose restart backend"

View File

@@ -1,7 +0,0 @@
{
"folders": [
{
"path": "."
}
]
}

View File

@@ -1,193 +0,0 @@
#!/bin/bash
echo "🚀 Setup: Neue Verzeichnisstruktur"
echo "===================================="
echo ""
# Farben
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Schritt 1: Verzeichnisse erstellen
echo -e "${YELLOW}📁 Schritt 1: Verzeichnisse erstellen${NC}"
mkdir -p /root/entwicklung
mkdir -p /root/livesysteme
mkdir -p /root/backups/entwicklung/pfandsystem
mkdir -p /root/backups/livesysteme/borbaecker
echo -e "${GREEN}✅ Verzeichnisse erstellt${NC}"
echo ""
# Schritt 2: Backend stoppen
echo -e "${YELLOW}⏸️ Schritt 2: Services stoppen${NC}"
pkill -f "node.*server.js" 2>/dev/null || true
echo -e "${GREEN}✅ Backend gestoppt${NC}"
echo ""
# Schritt 3: Docker Container stoppen
echo -e "${YELLOW}🐳 Schritt 3: Docker Container stoppen${NC}"
cd /root
docker compose down 2>/dev/null || true
echo -e "${GREEN}✅ Docker Container gestoppt${NC}"
echo ""
# Schritt 4: Dev-Version verschieben
echo -e "${YELLOW}🔄 Schritt 4: Dev-Version verschieben${NC}"
echo "Dies kann einige Minuten dauern..."
# Wichtige Dateien/Ordner verschieben
mv /root/backend /root/entwicklung/pfandsystem/ 2>/dev/null || true
mv /root/src /root/entwicklung/pfandsystem/ 2>/dev/null || true
mv /root/public /root/entwicklung/pfandsystem/ 2>/dev/null || true
mv /root/dist /root/entwicklung/pfandsystem/ 2>/dev/null || true
mv /root/docker-compose.yml /root/entwicklung/pfandsystem/ 2>/dev/null || true
mv /root/package.json /root/entwicklung/pfandsystem/ 2>/dev/null || true
mv /root/package-lock.json /root/entwicklung/pfandsystem/ 2>/dev/null || true
mv /root/vite.config.js /root/entwicklung/pfandsystem/ 2>/dev/null || true
mv /root/index.html /root/entwicklung/pfandsystem/ 2>/dev/null || true
mv /root/.env /root/entwicklung/pfandsystem/ 2>/dev/null || true
mv /root/fix-manifest-link.cjs /root/entwicklung/pfandsystem/ 2>/dev/null || true
mv /root/Dockerfile /root/entwicklung/pfandsystem/ 2>/dev/null || true
# Git verschieben
mv /root/.git /root/entwicklung/pfandsystem/ 2>/dev/null || true
mv /root/.gitignore /root/entwicklung/pfandsystem/ 2>/dev/null || true
# Dokumentation verschieben
mv /root/*.md /root/entwicklung/pfandsystem/ 2>/dev/null || true
echo -e "${GREEN}✅ Dev-Version verschoben${NC}"
echo ""
# Schritt 5: Live-Version klonen
echo -e "${YELLOW}📥 Schritt 5: Live-Version klonen${NC}"
cd /root/livesysteme/borbaecker
git clone ssh://git@49.13.154.75:2244/christian/pfandsystem.git . 2>/dev/null || echo "Git bereits geklont"
echo -e "${GREEN}✅ Live-Version geklont${NC}"
echo ""
# Schritt 6: Traefik-Configs anpassen
echo -e "${YELLOW}🔧 Schritt 6: Traefik-Configs erstellen${NC}"
# Dev-Config
cat > /root/traefik/dynamic/pfandsystem-dev.yml << 'EOF'
http:
routers:
pfandsystem-dev-api:
rule: "Host(`pfandsystem.backdigital.de`) && PathPrefix(`/api`)"
service: pfandsystem-dev-api
entryPoints:
- web
- websecure
tls:
certResolver: letsencrypt
priority: 100
pfandsystem-dev-uploads:
rule: "Host(`pfandsystem.backdigital.de`) && PathPrefix(`/uploads`)"
service: pfandsystem-dev-api
entryPoints:
- web
- websecure
tls:
certResolver: letsencrypt
priority: 100
services:
pfandsystem-dev-api:
loadBalancer:
servers:
- url: "http://172.17.0.1:3001"
EOF
# Alte Config löschen
rm /root/traefik/dynamic/local-backend.yml 2>/dev/null || true
echo -e "${GREEN}✅ Traefik-Configs erstellt${NC}"
echo ""
# Schritt 7: Backup-Scripte erstellen
echo -e "${YELLOW}💾 Schritt 7: Backup-Scripte erstellen${NC}"
# Dev-Backup
cat > /root/entwicklung/pfandsystem/backup-dev.sh << 'EOF'
#!/bin/bash
BACKUP_DIR="/root/backups/entwicklung/pfandsystem"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_NAME="backup_${TIMESTAMP}"
mkdir -p "${BACKUP_DIR}/${BACKUP_NAME}"
# Datenbank
docker exec pfandsystem-dev-mysql mysqldump -upfandsystem -ppfandsystem_secure_password pfandsystem > "${BACKUP_DIR}/${BACKUP_NAME}/database.sql" 2>/dev/null || echo "DB-Backup fehlgeschlagen"
# Uploads
cp -r /root/entwicklung/pfandsystem/backend/uploads "${BACKUP_DIR}/${BACKUP_NAME}/" 2>/dev/null || true
# .env
cp /root/entwicklung/pfandsystem/.env "${BACKUP_DIR}/${BACKUP_NAME}/frontend.env" 2>/dev/null || true
cp /root/entwicklung/pfandsystem/backend/.env "${BACKUP_DIR}/${BACKUP_NAME}/backend.env" 2>/dev/null || true
# Komprimieren
cd "${BACKUP_DIR}"
tar -czf "${BACKUP_NAME}.tar.gz" "${BACKUP_NAME}"
rm -rf "${BACKUP_NAME}"
# Alte Backups löschen
find "${BACKUP_DIR}" -name "backup_*.tar.gz" -mtime +7 -delete
echo "✅ Dev-Backup: ${BACKUP_NAME}.tar.gz"
EOF
chmod +x /root/entwicklung/pfandsystem/backup-dev.sh
echo -e "${GREEN}✅ Backup-Scripte erstellt${NC}"
echo ""
# Schritt 8: Docker-Compose anpassen
echo -e "${YELLOW}🐳 Schritt 8: Docker-Compose anpassen${NC}"
cd /root/entwicklung/pfandsystem
sed -i 's/container_name: pfandsystem-/container_name: pfandsystem-dev-/g' docker-compose.yml 2>/dev/null || true
echo -e "${GREEN}✅ Docker-Compose angepasst${NC}"
echo ""
# Schritt 9: .env anpassen
echo -e "${YELLOW}⚙️ Schritt 9: .env Dateien anpassen${NC}"
sed -i 's|UPLOAD_DIR=/root/backend/uploads|UPLOAD_DIR=/root/entwicklung/pfandsystem/backend/uploads|g' /root/entwicklung/pfandsystem/backend/.env 2>/dev/null || true
echo -e "${GREEN}✅ .env angepasst${NC}"
echo ""
# Schritt 10: Traefik neu starten
echo -e "${YELLOW}🔄 Schritt 10: Traefik neu starten${NC}"
docker restart pfandsystem-traefik
sleep 3
echo -e "${GREEN}✅ Traefik neu gestartet${NC}"
echo ""
# Zusammenfassung
echo ""
echo "=========================================="
echo -e "${GREEN}✅ Setup abgeschlossen!${NC}"
echo "=========================================="
echo ""
echo "📁 Verzeichnisse:"
echo " Dev: /root/entwicklung/pfandsystem"
echo " Live: /root/livesysteme/borbaecker"
echo ""
echo "💾 Backups:"
echo " Dev: /root/backups/entwicklung/pfandsystem"
echo " Live: /root/backups/livesysteme/borbaecker"
echo ""
echo "🔧 Nächste Schritte:"
echo " 1. Dev-System starten:"
echo " cd /root/entwicklung/pfandsystem"
echo " docker compose up -d"
echo " cd backend && npm start &"
echo ""
echo " 2. Live-System konfigurieren:"
echo " Siehe: /root/entwicklung/pfandsystem/MIGRATION_UND_SETUP.md"
echo ""
echo " 3. Cron-Jobs einrichten:"
echo " crontab -e"
echo " 0 6-23 * * * /root/entwicklung/pfandsystem/backup-dev.sh"
echo ""

BIN
src/._App.jsx Normal file

Binary file not shown.

BIN
src/._api Executable file

Binary file not shown.

BIN
src/._components Executable file

Binary file not shown.

BIN
src/._index.css Normal file

Binary file not shown.

BIN
src/._main.jsx Normal file

Binary file not shown.

View File

@@ -2,49 +2,29 @@ import { useEffect, useState } from 'react';
import { api } from './api/client';
import Login from './components/Auth/Login';
import Dashboard from './components/Dashboard/Dashboard';
import SuperAdmin from './components/SuperAdmin';
function App() {
const [userObj, setUserObj] = useState(null);
export default function App() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function checkAuth() {
setLoading(true);
const token = api.getToken();
if (!token) {
setUserObj(null);
setLoading(false);
return;
}
try {
const user = await api.getCurrentUser();
setUserObj(user);
} catch (error) {
console.error('Auth-Fehler:', error);
api.logout();
setUserObj(null);
} finally {
setLoading(false);
}
}
checkAuth();
(async () => {
if (!api.getToken()) { setLoading(false); return; }
try { setUser(await api.getCurrentUser()); }
catch { api.logout(); }
finally { setLoading(false); }
})();
}, []);
const handleLogin = (user) => {
setUserObj(user);
};
const handleLogout = () => { api.logout(); setUser(null); };
const handleLogout = () => {
api.logout();
setUserObj(null);
};
if (loading) return <div style={{textAlign:'center',marginTop:64,fontSize:22}}>Lade...</div>;
if (!userObj) return <Login onLogin={handleLogin} />;
return <Dashboard user={userObj} onLogout={handleLogout} />;
if (loading) return (
<div className="min-h-screen flex items-center justify-center text-slate-500">
<div className="animate-pulse text-lg">Lade</div>
</div>
);
if (!user) return <Login onLogin={setUser} />;
if (user.rolle === 'superadmin') return <SuperAdmin user={user} onLogout={handleLogout} />;
return <Dashboard user={user} onLogout={handleLogout} />;
}
export default App;

View File

@@ -1,41 +0,0 @@
import React, { useEffect, useState } from 'react';
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
const supabase = createClient(supabaseUrl, supabaseAnonKey);
export default function SupabaseTest() {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
async function fetchData() {
setLoading(true);
setError(null);
// Passe den Tabellennamen ggf. an
const { data, error } = await supabase.from('test').select('*').limit(10);
if (error) setError(error.message);
setData(data || []);
setLoading(false);
}
fetchData();
}, []);
return (
<div style={{ marginTop: 24, padding: 16, border: '1px solid #eee', borderRadius: 8 }}>
<h2>Supabase Test</h2>
<div style={{ fontSize: 14 }}>
<strong>Supabase URL:</strong> {supabaseUrl}
</div>
{loading && <p>Lade Daten...</p>}
{error && <p style={{ color: 'red' }}>Fehler: {error}</p>}
<ul>
{data && data.length > 0 ? data.map((row, idx) => (
<li key={idx}>{JSON.stringify(row)}</li>
)) : !loading && <li>Keine Daten gefunden.</li>}
</ul>
</div>
);
}

BIN
src/api/._client.js Normal file

Binary file not shown.

View File

@@ -1,260 +1,129 @@
// API Client für Backend-Kommunikation (ersetzt Supabase)
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api';
// API Client - relativ via /api (Traefik routet pfandsystem.dockly.de/api -> Backend)
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
class ApiClient {
constructor() {
this.token = localStorage.getItem('auth_token');
}
constructor() { this.token = localStorage.getItem('auth_token'); }
setToken(token) {
this.token = token;
if (token) {
localStorage.setItem('auth_token', token);
} else {
localStorage.removeItem('auth_token');
}
}
getToken() {
return this.token || localStorage.getItem('auth_token');
setToken(t) {
this.token = t;
if (t) localStorage.setItem('auth_token', t); else localStorage.removeItem('auth_token');
}
getToken() { return this.token || localStorage.getItem('auth_token'); }
async request(endpoint, options = {}) {
const token = this.getToken();
const headers = {
'Content-Type': 'application/json',
...options.headers,
};
if (token && !options.skipAuth) {
headers['Authorization'] = `Bearer ${token}`;
}
const config = {
...options,
headers,
};
try {
const response = await fetch(`${API_BASE_URL}${endpoint}`, config);
// Prüfe ob Response Body vorhanden ist
const text = await response.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch (e) {
console.error('JSON Parse Error:', text);
throw new Error('Ungültige Server-Antwort');
}
if (!response.ok) {
throw new Error(data?.error || `HTTP ${response.status}`);
}
return data;
} catch (error) {
console.error('API Request Error:', error);
throw error;
}
const headers = { 'Content-Type': 'application/json', ...options.headers };
const t = this.getToken();
if (t && !options.skipAuth) headers['Authorization'] = `Bearer ${t}`;
const res = await fetch(`${API_BASE_URL}${endpoint}`, { ...options, headers });
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { throw new Error('Ungueltige Server-Antwort'); }
if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`);
return data;
}
// Auth
async login(email, password) {
async login(email, password, tenant_slug) {
const data = await this.request('/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
body: JSON.stringify({ email, password, tenant_slug }),
skipAuth: true,
});
this.setToken(data.token);
return data;
}
async logout() {
this.setToken(null);
}
async getCurrentUser() {
return await this.request('/auth/me');
}
async register(email, password, name, rolle) {
return await this.request('/auth/register', {
method: 'POST',
body: JSON.stringify({ email, password, name, rolle }),
});
}
async changePassword(currentPassword, newPassword) {
return await this.request('/auth/change-password', {
method: 'POST',
body: JSON.stringify({ currentPassword, newPassword }),
logout() { this.setToken(null); }
getCurrentUser() { return this.request('/auth/me'); }
changePassword(currentPassword, newPassword) {
return this.request('/auth/change-password', {
method: 'POST', body: JSON.stringify({ currentPassword, newPassword }),
});
}
// Kunden
async getKunden() {
return await this.request('/kunden');
}
getKunden() { return this.request('/kunden'); }
getKunde(id) { return this.request(`/kunden/${id}`); }
createKunde(k) { return this.request('/kunden', { method: 'POST', body: JSON.stringify(k) }); }
updateKunde(id, k) { return this.request(`/kunden/${id}`, { method: 'PUT', body: JSON.stringify(k) }); }
deleteKunde(id) { return this.request(`/kunden/${id}`, { method: 'DELETE' }); }
async getKunde(id) {
return await this.request(`/kunden/${id}`);
}
async createKunde(kunde) {
return await this.request('/kunden', {
// Kundenbilder (Multipart)
async uploadKundenBild(kundeId, file, beschreibung) {
const fd = new FormData();
fd.append('foto', file);
if (beschreibung) fd.append('beschreibung', beschreibung);
const res = await fetch(`${API_BASE_URL}/kunden-bilder/${kundeId}`, {
method: 'POST',
body: JSON.stringify(kunde),
});
}
async updateKunde(id, kunde) {
return await this.request(`/kunden/${id}`, {
method: 'PUT',
body: JSON.stringify(kunde),
});
}
async deleteKunde(id) {
return await this.request(`/kunden/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${this.getToken()}` },
body: fd,
});
if (!res.ok) {
const e = await res.json().catch(() => ({}));
throw new Error(e.error || `HTTP ${res.status}`);
}
return res.json();
}
deleteKundenBild(id) { return this.request(`/kunden-bilder/bild/${id}`, { method: 'DELETE' }); }
// Artikel
async getArtikel() {
return await this.request('/artikel');
}
async getArtikelById(id) {
return await this.request(`/artikel/${id}`);
}
async createArtikel(artikel) {
return await this.request('/artikel', {
method: 'POST',
body: JSON.stringify(artikel),
});
}
async updateArtikel(id, artikel) {
return await this.request(`/artikel/${id}`, {
method: 'PUT',
body: JSON.stringify(artikel),
});
}
async deleteArtikel(id) {
return await this.request(`/artikel/${id}`, {
method: 'DELETE',
});
}
getArtikel() { return this.request('/artikel'); }
createArtikel(a) { return this.request('/artikel', { method: 'POST', body: JSON.stringify(a) }); }
updateArtikel(id, a) { return this.request(`/artikel/${id}`, { method: 'PUT', body: JSON.stringify(a) }); }
deleteArtikel(id) { return this.request(`/artikel/${id}`, { method: 'DELETE' }); }
// Lieferungen
async getLieferungen() {
return await this.request('/lieferungen');
}
async getLieferungenByKunde(kundeId) {
return await this.request(`/lieferungen/kunde/${kundeId}`);
}
async createLieferung(lieferung, foto) {
const formData = new FormData();
formData.append('kunde_id', lieferung.kunde_id);
formData.append('artikel_id', lieferung.artikel_id);
formData.append('anzahl', lieferung.anzahl);
if (foto) {
formData.append('foto', foto);
}
const token = this.getToken();
const response = await fetch(`${API_BASE_URL}/lieferungen`, {
getLieferungen() { return this.request('/lieferungen'); }
async createLieferung(l, foto) {
const fd = new FormData();
fd.append('kunde_id', l.kunde_id);
fd.append('artikel_id', l.artikel_id);
fd.append('anzahl', l.anzahl);
if (foto) fd.append('foto', foto);
const res = await fetch(`${API_BASE_URL}/lieferungen`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
body: formData,
headers: { 'Authorization': `Bearer ${this.getToken()}` },
body: fd,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Netzwerkfehler' }));
throw new Error(error.error || `HTTP ${response.status}`);
}
return await response.json();
if (!res.ok) { const e = await res.json().catch(()=>({})); throw new Error(e.error || `HTTP ${res.status}`); }
return res.json();
}
deleteLieferung(id) { return this.request(`/lieferungen/${id}`, { method: 'DELETE' }); }
async deleteLieferung(id) {
return await this.request(`/lieferungen/${id}`, {
method: 'DELETE',
});
}
// Rücknahmen
async getRuecknahmen() {
return await this.request('/ruecknahmen');
}
async getRuecknahmenByKunde(kundeId) {
return await this.request(`/ruecknahmen/kunde/${kundeId}`);
}
async createRuecknahme(ruecknahme, foto) {
const formData = new FormData();
formData.append('kunde_id', ruecknahme.kunde_id);
formData.append('artikel_id', ruecknahme.artikel_id);
formData.append('anzahl', ruecknahme.anzahl);
if (foto) {
formData.append('foto', foto);
}
const token = this.getToken();
const response = await fetch(`${API_BASE_URL}/ruecknahmen`, {
// Ruecknahmen
getRuecknahmen() { return this.request('/ruecknahmen'); }
async createRuecknahme(r, foto) {
const fd = new FormData();
fd.append('kunde_id', r.kunde_id);
fd.append('artikel_id', r.artikel_id);
fd.append('anzahl', r.anzahl);
if (foto) fd.append('foto', foto);
const res = await fetch(`${API_BASE_URL}/ruecknahmen`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Netzwerkfehler' }));
throw new Error(error.error || `HTTP ${response.status}`);
}
return await response.json();
}
async deleteRuecknahme(id) {
return await this.request(`/ruecknahmen/${id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${this.getToken()}` },
body: fd,
});
if (!res.ok) { const e = await res.json().catch(()=>({})); throw new Error(e.error || `HTTP ${res.status}`); }
return res.json();
}
deleteRuecknahme(id) { return this.request(`/ruecknahmen/${id}`, { method: 'DELETE' }); }
// Mitarbeiter
async getMitarbeiter() {
return await this.request('/mitarbeiter');
}
getMitarbeiter() { return this.request('/mitarbeiter'); }
createMitarbeiter(m) { return this.request('/mitarbeiter', { method: 'POST', body: JSON.stringify(m) }); }
updateMitarbeiter(id, m) { return this.request(`/mitarbeiter/${id}`, { method: 'PUT', body: JSON.stringify(m) }); }
deleteMitarbeiter(id) { return this.request(`/mitarbeiter/${id}`, { method: 'DELETE' }); }
async getMitarbeiterById(id) {
return await this.request(`/mitarbeiter/${id}`);
}
// Geraete
getGeraete() { return this.request('/geraete'); }
createGeraet(g) { return this.request('/geraete', { method: 'POST', body: JSON.stringify(g) }); }
updateGeraet(id, g) { return this.request(`/geraete/${id}`, { method: 'PUT', body: JSON.stringify(g) }); }
deleteGeraet(id) { return this.request(`/geraete/${id}`, { method: 'DELETE' }); }
async updateMitarbeiter(id, mitarbeiter) {
return await this.request(`/mitarbeiter/${id}`, {
method: 'PUT',
body: JSON.stringify(mitarbeiter),
});
}
async deleteMitarbeiter(id) {
return await this.request(`/mitarbeiter/${id}`, {
method: 'DELETE',
});
}
// Tenants (SuperAdmin)
getTenants() { return this.request('/tenants'); }
createTenant(t) { return this.request('/tenants', { method: 'POST', body: JSON.stringify(t) }); }
updateTenant(id, t) { return this.request(`/tenants/${id}`, { method: 'PATCH', body: JSON.stringify(t) }); }
deleteTenant(id) { return this.request(`/tenants/${id}`, { method: 'DELETE' }); }
}
export const api = new ApiClient();

BIN
src/components/._Auth Executable file

Binary file not shown.

BIN
src/components/._Dashboard Executable file

Binary file not shown.

BIN
src/components/._Eingabe Executable file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -2,96 +2,81 @@ import { useState } from 'react';
import { api } from '../../api/client';
export default function Login({ onLogin }) {
const params = new URLSearchParams(window.location.search);
const tenantFromUrl = params.get('tenant') || '';
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [message, setMessage] = useState('');
const [tenantSlug, setTenantSlug] = useState(tenantFromUrl);
const [showTenant, setShowTenant] = useState(!!tenantFromUrl);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleLogin = async (e) => {
const submit = async (e) => {
e.preventDefault();
setLoading(true);
setMessage('');
setError(''); setLoading(true);
try {
const data = await api.login(email, password);
setMessage('Login erfolgreich!');
if (onLogin) {
onLogin(data.user);
}
} catch (error) {
setMessage(error.message || 'Login fehlgeschlagen');
} finally {
setLoading(false);
}
const data = await api.login(email, password, tenantSlug || undefined);
onLogin?.(data.user);
} catch (err) {
setError(err.message || 'Login fehlgeschlagen');
} finally { setLoading(false); }
};
return (
<div style={{
maxWidth: 380,
margin: '64px auto',
background: '#fff',
borderRadius: 22,
boxShadow: '0 4px 22px #1976d222',
padding: 36,
fontFamily: 'DM Sans, Work Sans, Arial, sans-serif',
display: 'flex', flexDirection: 'column', alignItems: 'center',
}}>
<h1 style={{
fontFamily: 'DM Sans, Work Sans, Arial, sans-serif',
fontWeight: 700, fontSize: 32, margin: '0 0 18px 0', color: '#1976d2', letterSpacing: 0.5,
textAlign: 'center', textShadow: '0 2px 8px #0001'
}}>
Pfandsystem
</h1>
<h2 style={{ textAlign: 'center', marginBottom: 24, fontWeight: 600, color: '#222', fontSize: 22, letterSpacing: 0.1 }}>Login</h2>
<form onSubmit={handleLogin} style={{ display: 'flex', flexDirection: 'column', gap: 18, width: '100%' }}>
<input
type="email"
placeholder="E-Mail"
value={email}
onChange={e => setEmail(e.target.value)}
required
style={{
fontSize: 18,
padding: '14px 14px',
borderRadius: 12,
border: '1.5px solid #dbeafe',
background: '#f6f7fb',
outline: 'none',
marginBottom: 2
}}
/>
<input
type="password"
placeholder="Passwort"
value={password}
onChange={e => setPassword(e.target.value)}
required
style={{
fontSize: 18,
padding: '14px 14px',
borderRadius: 12,
border: '1.5px solid #dbeafe',
background: '#f6f7fb',
outline: 'none',
marginBottom: 2
}}
/>
<button type="submit" disabled={loading} style={{
marginTop: 8,
background: loading ? '#ccc' : '#1976d2',
color: '#fff',
fontWeight: 700,
fontSize: 18,
border: 'none',
borderRadius: 12,
padding: '13px 0',
boxShadow: '0 2px 10px #1976d222',
cursor: loading ? 'not-allowed' : 'pointer',
transition: 'background 0.18s',
}}>{loading ? 'Wird geladen...' : 'Login'}</button>
</form>
{message && <div style={{ color: '#1976d2', marginTop: 18, fontWeight: 500 }}>{message}</div>}
<div className="min-h-screen flex items-center justify-center px-4 py-12 bg-gradient-to-br from-slate-50 to-slate-100">
<div className="w-full max-w-sm">
<div className="text-center mb-6">
<div className="inline-flex items-center justify-center h-14 w-14 rounded-2xl bg-brand-600 text-white text-xl font-bold mb-3 shadow-soft">
P
</div>
<h1 className="text-2xl font-bold tracking-tight text-slate-900">Pfandsystem</h1>
<p className="text-sm text-slate-500 mt-1">Demo-Portal</p>
</div>
<form onSubmit={submit} className="card space-y-4">
<div>
<label className="label">E-Mail</label>
<input type="email" required value={email}
onChange={e => setEmail(e.target.value)}
className="input" placeholder="name@firma.de" />
</div>
<div>
<label className="label">Passwort</label>
<input type="password" required value={password}
onChange={e => setPassword(e.target.value)}
className="input" placeholder="••••••••" />
</div>
{showTenant ? (
<div>
<label className="label">Tenant-Kennung</label>
<input type="text" value={tenantSlug}
onChange={e => setTenantSlug(e.target.value)}
className="input" placeholder="z. B. firma-a1b2" />
</div>
) : (
<button type="button" onClick={() => setShowTenant(true)}
className="text-xs text-brand-600 hover:text-brand-700">
Mehrere Konten? Tenant-Kennung angeben
</button>
)}
{error && (
<div className="rounded-md bg-rose-50 px-3 py-2 text-sm text-rose-700 ring-1 ring-rose-200">
{error}
</div>
)}
<button type="submit" disabled={loading} className="btn-primary w-full">
{loading ? 'Anmelden…' : 'Anmelden'}
</button>
</form>
<p className="text-center text-xs text-slate-400 mt-6">
Kein Zugang? Demo-Account anfragen unter <a className="text-brand-600 hover:underline" href="mailto:demo@dockly.de">demo@dockly.de</a>
</p>
</div>
</div>
);
}

View File

@@ -1,27 +0,0 @@
import { api } from '../../api/client';
export default function Logout({ onLogout }) {
const handleLogout = async () => {
api.logout();
if (onLogout) {
onLogout();
}
// Seite neu laden um sicherzustellen dass der User ausgeloggt ist
window.location.reload();
};
return (
<button onClick={handleLogout} style={{
margin: 8,
padding: '10px 20px',
background: '#d32f2f',
color: '#fff',
border: 'none',
borderRadius: 6,
cursor: 'pointer',
fontWeight: 600,
fontSize: 14
}}>
Logout
</button>
);
}

View File

@@ -2,131 +2,42 @@ import { useState } from 'react';
import { api } from '../../api/client';
export default function PasswordChange() {
const [form, setForm] = useState({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [loading, setLoading] = useState(false);
const [form, setForm] = useState({ currentPassword: '', newPassword: '', confirm: '' });
const [err, setErr] = useState('');
const [ok, setOk] = useState('');
const [busy, setBusy] = useState(false);
const update = e => { setForm(f => ({ ...f, [e.target.name]: e.target.value })); setErr(''); setOk(''); };
const handleChange = (e) => {
setForm(f => ({ ...f, [e.target.name]: e.target.value }));
setError('');
setSuccess('');
};
const handleSubmit = async (e) => {
const submit = async (e) => {
e.preventDefault();
setError('');
setSuccess('');
// Validierung
if (!form.currentPassword || !form.newPassword || !form.confirmPassword) {
setError('Bitte alle Felder ausfüllen');
return;
}
if (form.newPassword.length < 6) {
setError('Neues Passwort muss mindestens 6 Zeichen lang sein');
return;
}
if (form.newPassword !== form.confirmPassword) {
setError('Passwörter stimmen nicht überein');
return;
}
setLoading(true);
if (form.newPassword.length < 6) return setErr('Neues Passwort min. 6 Zeichen');
if (form.newPassword !== form.confirm) return setErr('Passwoerter stimmen nicht ueberein');
setBusy(true);
try {
await api.changePassword(form.currentPassword, form.newPassword);
setSuccess('Passwort erfolgreich geändert!');
setForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
} catch (err) {
setError(err.message || 'Fehler beim Ändern des Passworts');
} finally {
setLoading(false);
}
setOk('Passwort geaendert');
setForm({ currentPassword: '', newPassword: '', confirm: '' });
} catch (e) { setErr(e.message); } finally { setBusy(false); }
};
return (
<div style={{ marginTop: 24, padding: 20, background: '#f5f5f5', borderRadius: 8 }}>
<h3 style={{ marginBottom: 16, fontSize: 18, fontWeight: 600 }}>Passwort ändern</h3>
{error && (
<div style={{ padding: 12, marginBottom: 12, background: '#ffebee', color: '#c62828', borderRadius: 6, fontSize: 14 }}>
{error}
</div>
)}
{success && (
<div style={{ padding: 12, marginBottom: 12, background: '#e8f5e9', color: '#2e7d32', borderRadius: 6, fontSize: 14 }}>
{success}
</div>
)}
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', marginBottom: 4, fontSize: 14, fontWeight: 500 }}>
Aktuelles Passwort:
</label>
<input
type="password"
name="currentPassword"
value={form.currentPassword}
onChange={handleChange}
style={{ width: '100%', padding: 10, border: '1px solid #ddd', borderRadius: 6, fontSize: 14 }}
disabled={loading}
/>
</div>
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', marginBottom: 4, fontSize: 14, fontWeight: 500 }}>
Neues Passwort:
</label>
<input
type="password"
name="newPassword"
value={form.newPassword}
onChange={handleChange}
style={{ width: '100%', padding: 10, border: '1px solid #ddd', borderRadius: 6, fontSize: 14 }}
disabled={loading}
/>
</div>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', marginBottom: 4, fontSize: 14, fontWeight: 500 }}>
Passwort bestätigen:
</label>
<input
type="password"
name="confirmPassword"
value={form.confirmPassword}
onChange={handleChange}
style={{ width: '100%', padding: 10, border: '1px solid #ddd', borderRadius: 6, fontSize: 14 }}
disabled={loading}
/>
</div>
<button
type="submit"
disabled={loading}
style={{
width: '100%',
padding: 12,
background: loading ? '#ccc' : '#1976d2',
color: '#fff',
border: 'none',
borderRadius: 6,
fontSize: 15,
fontWeight: 600,
cursor: loading ? 'not-allowed' : 'pointer'
}}
>
{loading ? 'Wird geändert...' : 'Passwort ändern'}
</button>
</form>
</div>
<form onSubmit={submit} className="card space-y-4">
<h3 className="card-title">Passwort aendern</h3>
{err && <div className="rounded-md bg-rose-50 px-3 py-2 text-sm text-rose-700 ring-1 ring-rose-200">{err}</div>}
{ok && <div className="rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700 ring-1 ring-emerald-200">{ok}</div>}
<div>
<label className="label">Aktuelles Passwort</label>
<input type="password" name="currentPassword" value={form.currentPassword} onChange={update} className="input" />
</div>
<div>
<label className="label">Neues Passwort</label>
<input type="password" name="newPassword" value={form.newPassword} onChange={update} className="input" />
</div>
<div>
<label className="label">Bestaetigen</label>
<input type="password" name="confirm" value={form.confirm} onChange={update} className="input" />
</div>
<button disabled={busy} className="btn-primary w-full">{busy ? 'Speichere…' : 'Aendern'}</button>
</form>
);
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,32 @@
import PasswordChange from '../Auth/PasswordChange';
export default function Account({ user }) {
return (
<div className="max-w-xl mx-auto space-y-6">
<div className="card">
<h2 className="card-title">Account</h2>
<dl className="grid grid-cols-3 gap-y-3 text-sm">
<dt className="col-span-1 text-slate-500">Name</dt><dd className="col-span-2 font-medium">{user.name}</dd>
<dt className="col-span-1 text-slate-500">E-Mail</dt><dd className="col-span-2">{user.email}</dd>
<dt className="col-span-1 text-slate-500">Rolle</dt><dd className="col-span-2">
<span className="badge-brand capitalize">{user.rolle}</span>
</dd>
{user.tenant_name && (<>
<dt className="col-span-1 text-slate-500">Tenant</dt>
<dd className="col-span-2">
{user.tenant_name}
{user.tenant_typ === 'demo' && (
<span className="badge-amber ml-2">Demo</span>
)}
</dd>
</>)}
{user.demo_expires_at && (<>
<dt className="col-span-1 text-slate-500">Demo-Ablauf</dt>
<dd className="col-span-2">{new Date(user.demo_expires_at).toLocaleDateString('de-DE')}</dd>
</>)}
</dl>
</div>
<PasswordChange />
</div>
);
}

View File

@@ -1,253 +1,132 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { FaFileCsv } from 'react-icons/fa';
import { api } from '../../api/client';
function exportToCSV(rows, filename) {
const header = ['Kunde', 'Artikel', 'Soll', 'Ist', 'Differenz', 'Zeitraum'];
const csv = [header.join(',')].concat(
rows.map(r => [r.kunde, r.artikel, r.soll, r.ist, r.differenz, r.zeitraum].join(','))
).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
function toCSV(rows) {
const header = ['Kunde', 'Artikel', 'Soll', 'Ist', 'Differenz', 'Zeitraum', 'Mitarbeiter'];
const escape = v => `"${String(v ?? '').replace(/"/g, '""')}"`;
return [header.join(',')]
.concat(rows.map(r => [r.kunde, r.artikel, r.soll, r.ist, r.differenz, r.zeitraum, r.mitarbeiter].map(escape).join(',')))
.join('\n');
}
function downloadCSV(rows, filename) {
const blob = new Blob([toCSV(rows)], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
const a = document.createElement('a'); a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
function UeberfaelligeKunden({ kunden, rows }) {
const [sending, setSending] = useState({});
const [msg, setMsg] = useState({});
const UEBERFAELLIG_TAGE = 14;
const heute = new Date();
// rows enthält nur den ausgewählten Kunden, daher alle Kunden prüfen:
// (Alternative: für alle Kunden/Artikel aus lieferungen/ruecknahmen berechnen)
// Hier: Zeige für den aktuell gefilterten Kunden, wenn überfällig
const ueberfaellige = rows.filter(row => {
// Annahme: row.soll > row.ist, Frist überschritten, Kunde hat E-Mail
// Frist: ältestes Lieferdatum für Artikel
const lieferDatum = heute; // Placeholder, da rows kein Datum enthält
// TODO: lieferDatum aus lieferungen holen, wenn verfügbar
// Hier: Zeige alle mit Differenz > 0 und email
return (row.soll > row.ist);
});
if (!ueberfaellige.length) return null;
async function sendeMail(row) {
setSending(s => ({ ...s, [row.artikel]: true }));
setMsg(m => ({ ...m, [row.artikel]: '' }));
try {
const res = await fetch('https://work.pfandsystem.backdigital.de/webhook/send-reminder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: kunden.find(k => k.id === row.kunde_id)?.email || kunden.find(k => k.name === row.kunde)?.email || '',
kunde: row.kunde,
anrede: kunden.find(k => k.id === row.kunde_id)?.anrede || '',
ansprechpartner: kunden.find(k => k.id === row.kunde_id)?.ansprechpartner || '',
artikel: row.artikel,
anzahl: row.soll - row.ist,
frist: '14 Tage',
})
});
if (!res.ok) throw new Error('HTTP ' + res.status);
setMsg(m => ({ ...m, [row.artikel]: 'Erinnerung gesendet!' }));
} catch (e) {
setMsg(m => ({ ...m, [row.artikel]: 'Fehler beim Senden!' }));
}
setSending(s => ({ ...s, [row.artikel]: false }));
}
return (
<div style={{ marginTop: 32, background: '#f9fbe7', borderRadius: 12, padding: 16, maxWidth: 600, marginLeft: 'auto', marginRight: 'auto' }}>
<h3 style={{ marginBottom: 12, color: '#d32f2f' }}>Überfällige Produkte Erinnerung senden</h3>
{ueberfaellige.map((row, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 10, flexWrap: 'wrap' }}>
<span style={{ flex: 2 }}>
<b>{row.kunde}</b> {row.artikel} ({row.soll - row.ist} Stück)
</span>
<button
disabled={sending[row.artikel]}
onClick={() => sendeMail(row)}
style={{ background: '#1976d2', color: '#fff', border: 'none', borderRadius: 8, padding: '7px 16px', fontWeight: 700, fontSize: 15, cursor: sending[row.artikel] ? 'not-allowed' : 'pointer' }}>
Erinnerung senden
</button>
<span style={{ color: msg[row.artikel]?.startsWith('Fehler') ? '#d32f2f' : '#1976d2', fontWeight: 600, marginLeft: 8 }}>{msg[row.artikel]}</span>
</div>
))}
<div style={{ fontSize: 13, color: '#888', marginTop: 8 }}>
Nur Kunden mit ausgeliehenen Produkten und E-Mail werden angezeigt. Die Erinnerungsmail enthält eine freundliche Bitte um Rückgabe.
</div>
</div>
);
}
export default function AdminDashboard() {
const [kunden, setKunden] = useState([]);
const [artikel, setArtikel] = useState([]);
const [mitarbeiter, setMitarbeiter] = useState([]);
const [lieferungen, setLieferungen] = useState([]);
const [ruecknahmen, setRuecknahmen] = useState([]);
const [data, setData] = useState({ kunden: [], artikel: [], mitarbeiter: [], lieferungen: [], ruecknahmen: [] });
const [loading, setLoading] = useState(true);
const [selectedKunde, setSelectedKunde] = useState('');
const [selectedMitarbeiter, setSelectedMitarbeiter] = useState('');
const [zeitraumVon, setZeitraumVon] = useState('');
const [zeitraumBis, setZeitraumBis] = useState('');
const [rows, setRows] = useState([]);
const [von, setVon] = useState('');
const [bis, setBis] = useState('');
useEffect(() => {
async function loadData() {
try {
const [kundenData, artikelData, mitarbeiterData, lieferungenData, ruecknahmenData] = await Promise.all([
api.getKunden(),
api.getArtikel(),
api.getMitarbeiter(),
api.getLieferungen(),
api.getRuecknahmen()
]);
setKunden(kundenData || []);
setArtikel(artikelData || []);
setMitarbeiter(mitarbeiterData || []);
setLieferungen(lieferungenData || []);
setRuecknahmen(ruecknahmenData || []);
} catch (error) {
console.error('Fehler beim Laden:', error);
}
}
loadData();
Promise.all([api.getKunden(), api.getArtikel(), api.getMitarbeiter(), api.getLieferungen(), api.getRuecknahmen()])
.then(([k, a, m, l, r]) => setData({ kunden: k, artikel: a, mitarbeiter: m, lieferungen: l, ruecknahmen: r }))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
if (!selectedKunde) { setRows([]); return; }
const filterByDate = (arr) => arr.filter(e => {
const rows = useMemo(() => {
if (!selectedKunde) return [];
const inRange = e => {
const d = new Date(e.erstellt_am);
if (zeitraumVon && d < new Date(zeitraumVon)) return false;
if (zeitraumBis && d > new Date(zeitraumBis + 'T23:59:59')) return false;
if (von && d < new Date(von)) return false;
if (bis && d > new Date(bis + 'T23:59:59')) return false;
return true;
});
let lieferungenK = lieferungen.filter(l => l.kunde_id === selectedKunde);
let ruecknahmenK = ruecknahmen.filter(r => r.kunde_id === selectedKunde);
if (selectedMitarbeiter) {
lieferungenK = lieferungenK.filter(l => l.mitarbeiter_id === selectedMitarbeiter);
ruecknahmenK = ruecknahmenK.filter(r => r.mitarbeiter_id === selectedMitarbeiter);
}
lieferungenK = filterByDate(lieferungenK);
ruecknahmenK = filterByDate(ruecknahmenK);
const artikelSet = Array.from(new Set([...lieferungenK.map(l => l.artikel_id), ...ruecknahmenK.map(r => r.artikel_id)]));
const rowsNew = artikelSet.map(artId => {
const art = artikel.find(a => a.id === artId);
const soll = lieferungenK.filter(l => l.artikel_id === artId).reduce((sum, l) => sum + l.anzahl, 0);
const ist = ruecknahmenK.filter(r => r.artikel_id === artId).reduce((sum, r) => sum + r.anzahl, 0);
// Mitarbeiter-Name aus erster Lieferung/Rücknahme (wenn eindeutig)
let mitarbeiterName = '';
const mitarbeiterId = lieferungenK.concat(ruecknahmenK).find(v => v.artikel_id === artId)?.mitarbeiter_id;
if (mitarbeiterId) {
mitarbeiterName = mitarbeiter.find(m => m.id === mitarbeiterId)?.name || '';
}
};
const l = data.lieferungen.filter(x => x.kunde_id === selectedKunde && inRange(x)
&& (!selectedMitarbeiter || x.mitarbeiter_id === selectedMitarbeiter));
const r = data.ruecknahmen.filter(x => x.kunde_id === selectedKunde && inRange(x)
&& (!selectedMitarbeiter || x.mitarbeiter_id === selectedMitarbeiter));
const artikelIds = Array.from(new Set([...l.map(x => x.artikel_id), ...r.map(x => x.artikel_id)]));
return artikelIds.map(aid => {
const art = data.artikel.find(a => a.id === aid);
const soll = l.filter(x => x.artikel_id === aid).reduce((s, x) => s + x.anzahl, 0);
const ist = r.filter(x => x.artikel_id === aid).reduce((s, x) => s + x.anzahl, 0);
const mitId = [...l, ...r].find(v => v.artikel_id === aid)?.mitarbeiter_id;
const mit = mitId ? data.mitarbeiter.find(m => m.id === mitId)?.name : '';
return {
kunde: kunden.find(k => k.id === selectedKunde)?.name || '',
kunde_id: selectedKunde, // <-- Kunden-ID für eindeutige Zuordnung
kunde: data.kunden.find(k => k.id === selectedKunde)?.name || '',
artikel: art?.bezeichnung || '',
soll,
ist,
differenz: ist - soll,
zeitraum: (zeitraumVon || '...') + ' bis ' + (zeitraumBis || '...'),
mitarbeiter: mitarbeiterName,
soll, ist, differenz: ist - soll,
zeitraum: (von || '…') + ' ' + (bis || '…'),
mitarbeiter: mit || '',
};
});
setRows(rowsNew);
}, [selectedKunde, zeitraumVon, zeitraumBis, lieferungen, ruecknahmen, artikel, kunden, selectedMitarbeiter]);
}, [data, selectedKunde, selectedMitarbeiter, von, bis]);
if (loading) return <div className="text-center py-8 text-slate-500">Lade</div>;
return (
<div style={{ background: '#fff', borderRadius: 18, boxShadow: '0 2px 16px #0001', padding: '16px 4vw', minWidth: 0, maxWidth: '100%', margin: '24px auto', textAlign: 'center' }}>
<h2 style={{ marginBottom: 16, fontSize: 22 }}>Admin: Soll-Ist-Abgleich & CSV-Export</h2>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginBottom: 22, alignItems: 'center', justifyContent: 'center' }}>
<select value={selectedKunde} onChange={e => setSelectedKunde(e.target.value)} style={{ fontSize: 16, padding: 10, borderRadius: 10, border: '1.5px solid #dbeafe', minWidth: 160 }}>
<option value=''>Kunde wählen</option>
{kunden.map(k => <option key={k.id} value={k.id}>{k.name}</option>)}
</select>
<select value={selectedMitarbeiter} onChange={e => setSelectedMitarbeiter(e.target.value)} style={{ fontSize: 16, padding: 10, borderRadius: 10, border: '1.5px solid #dbeafe', minWidth: 120 }}>
<option value=''>Mitarbeiter (alle)</option>
{mitarbeiter.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
<input type='date' value={zeitraumVon} onChange={e => setZeitraumVon(e.target.value)} style={{ fontSize: 16, padding: 10, borderRadius: 10, border: '1.5px solid #dbeafe' }} placeholder='Von' />
<input type='date' value={zeitraumBis} onChange={e => setZeitraumBis(e.target.value)} style={{ fontSize: 16, padding: 10, borderRadius: 10, border: '1.5px solid #dbeafe' }} placeholder='Bis' />
<button disabled={!selectedKunde || rows.length === 0} onClick={() => exportToCSV(rows, `Abgleich_${kunden.find(k => k.id === selectedKunde)?.name || 'kunde'}.csv`)} style={{ background: '#1976d2', color: '#fff', border: 'none', borderRadius: 10, padding: '10px 18px', fontWeight: 700, fontSize: 16, cursor: (!selectedKunde || rows.length === 0) ? 'not-allowed' : 'pointer', boxShadow: '0 2px 10px #1976d222' }} className="export-button">CSV Export</button>
<div className="space-y-4">
<h2 className="text-xl font-semibold text-slate-900">Auswertung · Soll-Ist-Abgleich</h2>
<div className="card flex flex-wrap gap-3 items-end">
<div className="flex-1 min-w-[160px]">
<label className="label">Kunde</label>
<select className="input" value={selectedKunde} onChange={e => setSelectedKunde(e.target.value)}>
<option value=""> waehlen </option>
{data.kunden.map(k => <option key={k.id} value={k.id}>{k.name}</option>)}
</select>
</div>
<div className="flex-1 min-w-[160px]">
<label className="label">Mitarbeiter</label>
<select className="input" value={selectedMitarbeiter} onChange={e => setSelectedMitarbeiter(e.target.value)}>
<option value="">Alle</option>
{data.mitarbeiter.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
</div>
<div className="flex-1 min-w-[140px]">
<label className="label">Von</label>
<input type="date" className="input" value={von} onChange={e => setVon(e.target.value)} />
</div>
<div className="flex-1 min-w-[140px]">
<label className="label">Bis</label>
<input type="date" className="input" value={bis} onChange={e => setBis(e.target.value)} />
</div>
<button onClick={() => downloadCSV(rows, `abgleich_${selectedKunde || 'alle'}.csv`)}
disabled={!selectedKunde || rows.length === 0} className="btn-primary">
<FaFileCsv /> CSV-Export
</button>
</div>
<div style={{ overflowX: 'auto', margin: '16px 0' }}>
{rows.length > 0 && (
<>
{/* Desktop/Tablett: Tabelle */}
<div className="admin-table-wrap" style={{ display: 'block' }}>
<table style={{ width: '100%', minWidth: 520, borderCollapse: 'collapse', fontSize: 15 }}>
<thead>
<tr style={{ background: '#eaf3ff' }}>
<th style={{ padding: 8, borderRadius: 8 }}>Artikel</th>
<th style={{ padding: 8 }}>Soll</th>
<th style={{ padding: 8 }}>Ist</th>
<th style={{ padding: 8 }}>Differenz</th>
<th style={{ padding: 8 }}>Zeitraum</th>
<th style={{ padding: 8 }}>Mitarbeiter</th>
{selectedKunde ? (
rows.length === 0 ? <div className="card text-center text-slate-500">Keine Eintraege im Zeitraum.</div> : (
<div className="card overflow-x-auto p-0">
<table className="min-w-full text-sm">
<thead className="bg-slate-50 text-xs uppercase text-slate-600">
<tr>
<th className="text-left px-4 py-3">Artikel</th>
<th className="text-right px-4 py-3">Soll</th>
<th className="text-right px-4 py-3">Ist</th>
<th className="text-right px-4 py-3">Differenz</th>
<th className="text-left px-4 py-3 hidden md:table-cell">Mitarbeiter</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{rows.map((r, i) => (
<tr key={i}>
<td className="px-4 py-3 font-medium">{r.artikel}</td>
<td className="px-4 py-3 text-right">{r.soll}</td>
<td className="px-4 py-3 text-right">{r.ist}</td>
<td className={`px-4 py-3 text-right font-semibold ${r.differenz < 0 ? 'text-rose-600' : r.differenz > 0 ? 'text-emerald-600' : ''}`}>
{r.differenz}
</td>
<td className="px-4 py-3 text-slate-600 hidden md:table-cell">{r.mitarbeiter || '—'}</td>
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={i} style={{ background: i % 2 ? '#f6f7fb' : '#fff' }}>
<td style={{ padding: 8 }}>{row.artikel}</td>
<td style={{ padding: 8 }}>{row.soll}</td>
<td style={{ padding: 8 }}>{row.ist}</td>
<td style={{ padding: 8, color: row.differenz < 0 ? '#d32f2f' : row.differenz > 0 ? '#1976d2' : '#222', fontWeight: 600 }}>{row.differenz}</td>
<td style={{ padding: 8 }}>{row.zeitraum}</td>
<td style={{ padding: 8 }}>{row.mitarbeiter}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobil: Cards */}
<div className="admin-cards-wrap" style={{ display: 'none' }}>
{rows.map((row, i) => (
<div key={i} style={{ background: '#f6f7fb', borderRadius: 12, boxShadow: '0 1px 6px #0001', padding: 12, fontSize: 15, textAlign: 'left' }}>
<div style={{ fontWeight: 700, marginBottom: 4 }}>{row.artikel}</div>
<div><b>Soll:</b> {row.soll} &nbsp; <b>Ist:</b> {row.ist}</div>
<div><b>Differenz:</b> <span style={{ color: row.differenz < 0 ? '#d32f2f' : row.differenz > 0 ? '#1976d2' : '#222', fontWeight: 600 }}>{row.differenz}</span></div>
<div style={{ fontSize: 13, color: '#888' }}>{row.zeitraum}</div>
<div style={{ fontSize: 13, color: '#888' }}><b>Mitarbeiter:</b> {row.mitarbeiter}</div>
</div>
))}
</div>
</>
)}
</div>
{/* Überfällige Kunden */}
<UeberfaelligeKunden kunden={kunden} rows={rows} />
<style>
{`
@media (max-width: 600px) {
.admin-table-wrap {
display: none;
}
.admin-cards-wrap {
display: flex;
flex-direction: column;
gap: 10;
}
.export-button {
position: sticky;
bottom: 0;
left: 0;
width: 100%;
padding: 10px 18px;
font-size: 16px;
font-weight: 700;
cursor: pointer;
background-color: #1976d2;
color: #fff;
border: none;
border-radius: 10px;
box-shadow: 0 2px 10px #1976d222;
}
}
`}
</style>
))}
</tbody>
</table>
</div>
)
) : (
<div className="card text-center text-slate-500">Bitte Kunden waehlen, um Auswertung zu sehen.</div>
)}
</div>
);
}

View File

@@ -1,15 +1,13 @@
import React from 'react';
export default function BildModal({ open, url, onClose }) {
if (!open) return null;
return (
<div style={{
position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh',
background: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000
}} onClick={onClose}>
<div style={{ position: 'relative', background: '#fff', borderRadius: 12, padding: 12, boxShadow: '0 4px 32px #0002', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
<button onClick={onClose} style={{ position: 'absolute', top: 8, right: 8, background: '#eee', border: 'none', borderRadius: 20, width: 32, height: 32, fontSize: 18, cursor: 'pointer' }}>×</button>
<img src={url} alt="Vorschau" style={{ maxWidth: '80vw', maxHeight: '80vh', borderRadius: 8 }} />
<div onClick={onClose}
className="fixed inset-0 z-50 bg-slate-900/70 backdrop-blur-sm flex items-center justify-center p-4">
<div onClick={e => e.stopPropagation()}
className="relative bg-white rounded-xl p-3 max-w-3xl max-h-[90vh] shadow-soft">
<button onClick={onClose}
className="absolute -top-2 -right-2 h-8 w-8 rounded-full bg-white shadow-soft ring-1 ring-slate-200 text-slate-600 hover:bg-slate-50">×</button>
<img src={url} alt="Vorschau" className="max-w-full max-h-[80vh] rounded-lg" />
</div>
</div>
);

View File

@@ -1,64 +1,55 @@
import { useState } from 'react';
import TopNav from './TopNav';
import VorgangForm from '../Eingabe/VorgangForm';
import VorgangsListe from './VorgangsListe';
import Logout from '../Auth/Logout';
import PasswordChange from '../Auth/PasswordChange';
import { useState } from 'react';
function AccountInfo({ user }) {
return (
<div style={{ background: '#fff', borderRadius: 18, boxShadow: '0 2px 16px #0001', padding: 32, minWidth: 320, maxWidth: 500, margin: '32px auto', textAlign: 'center' }}>
<h2 style={{ marginBottom: 16 }}>Account-Info</h2>
<div style={{ marginBottom: 12 }}><b>E-Mail:</b> <br />{user.email}</div>
<div style={{ marginBottom: 12 }}><b>Name:</b> <br />{user.name}</div>
<div style={{ marginBottom: 12 }}><b>Rolle:</b> <br />{user.rolle}</div>
<Logout />
<PasswordChange />
</div>
);
}
import AdminDashboard from './AdminDashboard';
import MitarbeiterVerwaltung from '../MitarbeiterVerwaltung';
import KundenVerwaltung from '../KundenVerwaltung';
import MitarbeiterVerwaltung from '../MitarbeiterVerwaltung';
import Geraete from '../Geraete';
import TourPlanung from '../TourPlanung';
import Account from './Account';
export default function Dashboard({ user }) {
export default function Dashboard({ user, onLogout }) {
const [refreshFlag, setRefreshFlag] = useState(0);
const [activeView, setActiveView] = useState('vorgang');
const isAdmin = user.rolle === 'admin';
return (
<div style={{ minHeight: '100vh', background: '#f6f7fb' }}>
<TopNav activeView={activeView} setActiveView={setActiveView} isAdmin={isAdmin} />
<main style={{ maxWidth: 900, margin: '0 auto', padding: '24px 8px', display: 'flex', flexDirection: 'column', gap: 28 }}>
{activeView === 'account' && <AccountInfo user={user} />}
<div className="min-h-screen bg-slate-50">
<TopNav user={user} activeView={activeView} setActiveView={setActiveView} onLogout={onLogout} />
<main className="max-w-6xl mx-auto px-4 sm:px-6 py-6 sm:py-8">
{activeView === 'vorgang' && (
<div style={{ background: '#fff', borderRadius: 22, boxShadow: '0 4px 22px #1976d222', padding: 36, maxWidth: 420, margin: '48px auto', minWidth: 0 }}>
<div className="max-w-md mx-auto">
<h2 className="text-xl font-semibold text-slate-900 mb-4">Neuer Vorgang</h2>
<VorgangForm user={user} onSubmit={() => setRefreshFlag(f => f + 1)} />
</div>
)}
{activeView === 'lieferungen' && (
<div style={{ background: '#fff', borderRadius: 18, boxShadow: '0 2px 16px #0001', padding: 18, maxWidth: 600, margin: '32px auto', minWidth: 0 }}>
<h2 style={{ textAlign: 'center', fontWeight: 700, fontSize: 22, marginBottom: 18 }}>Lieferungen</h2>
<Section title="Lieferungen">
<VorgangsListe refreshFlag={refreshFlag} mode="lieferungen" />
</div>
</Section>
)}
{activeView === 'ruecknahmen' && (
<div style={{ background: '#fff', borderRadius: 18, boxShadow: '0 2px 16px #0001', padding: 18, maxWidth: 600, margin: '32px auto', minWidth: 0 }}>
<h2 style={{ textAlign: 'center', fontWeight: 700, fontSize: 22, marginBottom: 18 }}>Rücknahmen</h2>
<Section title="Ruecknahmen">
<VorgangsListe refreshFlag={refreshFlag} mode="ruecknahmen" />
</div>
)}
{isAdmin && activeView === 'admin' && (
<AdminDashboard />
)}
{isAdmin && activeView === 'mitarbeiter' && (
<MitarbeiterVerwaltung user={user} />
)}
{isAdmin && activeView === 'kunden' && (
<KundenVerwaltung />
</Section>
)}
{activeView === 'tour' && <TourPlanung />}
{activeView === 'account' && <Account user={user} />}
{isAdmin && activeView === 'admin' && <AdminDashboard />}
{isAdmin && activeView === 'kunden' && <KundenVerwaltung />}
{isAdmin && activeView === 'mitarbeiter' && <MitarbeiterVerwaltung user={user} />}
{isAdmin && activeView === 'geraete' && <Geraete />}
</main>
</div>
);
}
function Section({ title, children }) {
return (
<div>
<h2 className="text-xl font-semibold text-slate-900 mb-4">{title}</h2>
{children}
</div>
);
}

View File

@@ -1,40 +0,0 @@
import { FaHome, FaList, FaBox, FaBell, FaComments, FaCog } from 'react-icons/fa';
const navItems = [
{ icon: <FaHome />, label: 'Dashboard' },
{ icon: <FaList />, label: 'Vorgänge' },
{ icon: <FaBox />, label: 'Artikel' },
{ icon: <FaBell />, label: 'Benachrichtigungen' },
{ icon: <FaComments />, label: 'Chat' },
{ icon: <FaCog />, label: 'Einstellungen' },
];
export default function Sidebar({ active = 0 }) {
return (
<aside style={{
width: 80, background: 'linear-gradient(180deg, #eaeaea 0%, #f8f8f8 100%)',
borderRadius: 28, margin: 12, padding: '24px 0', display: 'flex', flexDirection: 'column', alignItems: 'center', boxShadow: '2px 0 16px #0001',
minHeight: '85vh',
}}>
<div style={{ width: 48, height: 48, borderRadius: 24, background: '#fff', marginBottom: 32, boxShadow: '0 2px 8px #0001' }} />
{navItems.map((item, i) => (
<div key={item.label} style={{
background: active === i ? '#fff' : 'none',
borderRadius: 16,
margin: '8px 0',
width: 48,
height: 48,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: active === i ? '#1976d2' : '#888',
fontSize: 24,
cursor: 'pointer',
boxShadow: active === i ? '0 2px 8px #1976d230' : 'none',
}}>
{item.icon}
</div>
))}
</aside>
);
}

View File

@@ -1,96 +1,112 @@
import { FaUser, FaPlusCircle, FaTruck, FaUndo, FaKey, FaUsers, FaAddressBook } from 'react-icons/fa';
import { useState, useRef, useEffect } from 'react';
import {
FaPlusCircle, FaTruck, FaUndo, FaUsers, FaAddressBook,
FaChartBar, FaBars, FaUserCircle, FaSignOutAlt, FaBoxes, FaMapMarkedAlt
} from 'react-icons/fa';
const baseNavItems = [
{ icon: <FaUser />, label: 'Account', view: 'account' },
{ icon: <FaPlusCircle />, label: 'Neuer Vorgang', view: 'vorgang' },
{ icon: <FaTruck />, label: 'Lieferungen', view: 'lieferungen' },
{ icon: <FaUndo />, label: 'Rücknahmen', view: 'ruecknahmen' },
const mainItems = [
{ icon: FaPlusCircle, label: 'Vorgang', view: 'vorgang' },
{ icon: FaTruck, label: 'Lieferungen', view: 'lieferungen' },
{ icon: FaUndo, label: 'Ruecknahmen', view: 'ruecknahmen' },
{ icon: FaMapMarkedAlt,label: 'Tour', view: 'tour' },
];
import { useState } from 'react';
const adminItems = [
{ icon: FaChartBar, label: 'Auswertung', view: 'admin' },
{ icon: FaAddressBook, label: 'Kunden', view: 'kunden' },
{ icon: FaBoxes, label: 'Geraete', view: 'geraete' },
{ icon: FaUsers, label: 'Mitarbeiter', view: 'mitarbeiter' },
];
export default function TopNav({ activeView, setActiveView, isAdmin }) {
const navItems = isAdmin
? [...baseNavItems, { icon: <FaKey />, label: 'Admin', view: 'admin' }]
: baseNavItems;
const adminMenuItems = isAdmin
? [
{ icon: <FaUsers />, label: 'Mitarbeiter', view: 'mitarbeiter' },
{ icon: <FaAddressBook />, label: 'Kunden', view: 'kunden' },
{ icon: <FaKey />, label: 'Admin', view: 'admin' },
{ icon: <FaUser />, label: 'Account', view: 'account' },
]
: [{ icon: <FaUser />, label: 'Account', view: 'account' }];
export default function TopNav({ user, activeView, setActiveView, onLogout }) {
const isAdmin = user.rolle === 'admin';
const items = mainItems;
const [menuOpen, setMenuOpen] = useState(false);
const handleMenuClick = () => setMenuOpen(open => !open);
const handleMenuSelect = (view) => {
setActiveView(view);
setMenuOpen(false);
};
const menuRef = useRef(null);
useEffect(() => {
const handler = e => { if (menuRef.current && !menuRef.current.contains(e.target)) setMenuOpen(false); };
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const select = (v) => { setActiveView(v); setMenuOpen(false); };
return (
<header style={{
background: '#fff', boxShadow: '0 4px 18px #0002', padding: 0, position: 'sticky', top: 0, zIndex: 100,
borderBottomLeftRadius: 20, borderBottomRightRadius: 20, marginBottom: 12
}}>
<div style={{
fontFamily: 'DM Sans, Work Sans, Arial, sans-serif',
fontWeight: 700, fontSize: 32, letterSpacing: 0.5, color: '#222',
textAlign: 'center', padding: '18px 0 6px 0',
textShadow: '0 2px 8px #0001'
}}>
Pfandsystem
</div>
<nav style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
gap: 8, padding: '0 0 8px 0',
maxWidth: 480, margin: '0 auto',
}}>
{/* Hamburger-Menü für Account/Admin auf Mobil, Platzhalter-Logik */}
<div style={{ display: 'flex', flex: 1 }}>
{navItems.filter(item => item.view !== 'account' && item.view !== 'admin').map(item => (
<button
key={item.label}
onClick={() => setActiveView(item.view)}
style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
background: activeView === item.view ? '#eaf3ff' : 'none',
border: 'none', borderRadius: 12, padding: '7px 7px', cursor: 'pointer',
color: activeView === item.view ? '#1976d2' : '#444', fontWeight: 600, fontSize: 13,
minWidth: 54, boxShadow: activeView === item.view ? '0 2px 12px #1976d222' : '0 1px 4px #0001',
transition: 'all 0.18s', margin: '0 2px'
}}
>
<span style={{ fontSize: 19, marginBottom: 1 }}>{item.icon}</span>
<span style={{ fontSize: 12 }}>{item.label}</span>
</button>
))}
<header className="bg-white border-b border-slate-200 sticky top-0 z-30">
<div className="max-w-6xl mx-auto px-4 sm:px-6 flex items-center h-14">
<div className="flex items-center gap-2 mr-6">
<div className="h-8 w-8 rounded-lg bg-brand-600 text-white grid place-items-center font-bold">P</div>
<span className="font-semibold text-slate-900">Pfandsystem</span>
{user.tenant_typ === 'demo' && (
<span className="badge-amber ml-2">Demo</span>
)}
</div>
{/* Hamburger-Menü (Platzhalter, öffnet Account/Admin) */}
<div style={{ position: 'relative', marginLeft: 4 }}>
<button onClick={handleMenuClick} style={{ background: 'none', border: 'none', padding: 7, borderRadius: 10, cursor: 'pointer', fontSize: 20 }}>
<span style={{ fontSize: 22 }}></span>
<nav className="hidden md:flex items-center gap-1 flex-1">
{items.map(it => (
<NavBtn key={it.view} {...it} active={activeView === it.view} onClick={() => select(it.view)} />
))}
{isAdmin && (
<>
<div className="h-5 w-px bg-slate-200 mx-2" />
{adminItems.map(it => (
<NavBtn key={it.view} {...it} active={activeView === it.view} onClick={() => select(it.view)} />
))}
</>
)}
</nav>
<div className="flex-1 md:flex-none" />
<div className="relative" ref={menuRef}>
<button onClick={() => setMenuOpen(o => !o)}
className="flex items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-slate-100">
<FaUserCircle className="text-slate-500" size={22} />
<span className="hidden sm:inline text-sm text-slate-700">{user.name || user.email}</span>
</button>
{menuOpen && (
<div style={{ position: 'absolute', right: 0, top: 36, background: '#fff', borderRadius: 10, boxShadow: '0 2px 12px #0002', minWidth: 140, zIndex: 20 }}>
{adminMenuItems.map(item => (
<button
key={item.label}
onClick={() => handleMenuSelect(item.view)}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
background: 'none', border: 'none', padding: '10px 14px', cursor: 'pointer',
color: activeView === item.view ? '#1976d2' : '#444', fontWeight: 600, fontSize: 14,
borderBottom: '1px solid #eee',
}}
>
<span style={{ fontSize: 18 }}>{item.icon}</span> {item.label}
</button>
))}
<div className="absolute right-0 top-full mt-1 w-56 bg-white rounded-lg shadow-soft ring-1 ring-slate-200 py-1">
<div className="px-3 py-2 border-b border-slate-100">
<div className="text-sm font-medium text-slate-900">{user.name}</div>
<div className="text-xs text-slate-500">{user.email}</div>
<div className="text-xs text-slate-400 mt-0.5">Rolle: {user.rolle}</div>
</div>
<button className="menu-item" onClick={() => select('account')}>
<FaUserCircle /> Account
</button>
<div className="md:hidden border-t border-slate-100 mt-1 pt-1">
{items.map(it => (
<button key={it.view} className="menu-item" onClick={() => select(it.view)}>
<it.icon /> {it.label}
</button>
))}
{isAdmin && adminItems.map(it => (
<button key={it.view} className="menu-item" onClick={() => select(it.view)}>
<it.icon /> {it.label}
</button>
))}
</div>
<button className="menu-item text-rose-600 border-t border-slate-100 mt-1 pt-2" onClick={onLogout}>
<FaSignOutAlt /> Abmelden
</button>
</div>
)}
</div>
</nav>
</div>
<style>{`
.menu-item { @apply flex w-full items-center gap-2 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50; }
`}</style>
</header>
);
}
function NavBtn({ icon: Icon, label, active, onClick }) {
return (
<button onClick={onClick}
className={`inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium transition
${active ? 'bg-brand-50 text-brand-700' : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'}`}>
<Icon size={14} />{label}
</button>
);
}

View File

@@ -1,110 +1,88 @@
import { useEffect, useState } from 'react';
import { api } from '../../api/client';
import BildModal from './BildModal';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api';
const API_BASE = import.meta.env.VITE_API_URL || '/api';
function FotoCell({ path }) {
function Foto({ path }) {
const [open, setOpen] = useState(false);
if (!path) return <>-</>;
// Foto-URL direkt vom Backend
const url = path.startsWith('http') ? path : `${API_BASE_URL.replace('/api', '')}${path}`;
if (!path) return <div className="h-16 w-16 rounded-lg bg-slate-100 grid place-items-center text-slate-300 text-xs"></div>;
const url = path.startsWith('http') ? path : `${API_BASE.replace('/api', '')}${path}`;
return (
<>
<img src={url} alt="Foto" style={{ maxWidth: 60, maxHeight: 60, borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}
onClick={() => setOpen(true)} />
<img src={url} alt="Foto" onClick={() => setOpen(true)}
className="h-16 w-16 object-cover rounded-lg ring-1 ring-slate-200 cursor-pointer hover:opacity-90" />
<BildModal open={open} url={url} onClose={() => setOpen(false)} />
</>
);
}
export default function VorgangsListe({ refreshFlag, mode }) {
const [lieferungen, setLieferungen] = useState([]);
const [ruecknahmen, setRuecknahmen] = useState([]);
const [loading, setLoading] = useState(true);
const [list, setList] = useState([]);
const [kunden, setKunden] = useState([]);
const [artikel, setArtikel] = useState([]);
const [filterKunde, setFilterKunde] = useState('');
const [filterArtikel, setFilterArtikel] = useState('');
const [filterVon, setFilterVon] = useState('');
const [filterBis, setFilterBis] = useState('');
const [loading, setLoading] = useState(true);
useEffect(() => {
async function loadData() {
setLoading(true);
try {
const [lieferungenData, ruecknahmenData, kundenData, artikelData] = await Promise.all([
api.getLieferungen(),
api.getRuecknahmen(),
api.getKunden(),
api.getArtikel()
]);
setLieferungen(lieferungenData || []);
setRuecknahmen(ruecknahmenData || []);
setKunden(kundenData || []);
setArtikel(artikelData || []);
} catch (error) {
console.error('Fehler beim Laden:', error);
} finally {
setLoading(false);
}
}
loadData();
}, [refreshFlag]);
setLoading(true);
Promise.all([
mode === 'lieferungen' ? api.getLieferungen() : api.getRuecknahmen(),
api.getKunden(),
api.getArtikel(),
]).then(([d, k, a]) => { setList(d || []); setKunden(k || []); setArtikel(a || []); })
.finally(() => setLoading(false));
}, [refreshFlag, mode]);
if (loading) return <div style={{ textAlign: 'center', margin: 24 }}>Lädt...</div>;
let list = mode === 'lieferungen' ? lieferungen : mode === 'ruecknahmen' ? ruecknahmen : [];
// Filter anwenden
if (filterKunde) list = list.filter(l => l.kunde_id === filterKunde);
if (filterArtikel) list = list.filter(l => l.artikel_id === filterArtikel);
if (filterVon) list = list.filter(l => new Date(l.erstellt_am) >= new Date(filterVon));
if (filterBis) list = list.filter(l => new Date(l.erstellt_am) <= new Date(filterBis + 'T23:59:59'));
let view = list;
if (filterKunde) view = view.filter(l => l.kunde_id === filterKunde);
if (filterArtikel) view = view.filter(l => l.artikel_id === filterArtikel);
if (filterVon) view = view.filter(l => new Date(l.erstellt_am) >= new Date(filterVon));
if (filterBis) view = view.filter(l => new Date(l.erstellt_am) <= new Date(filterBis + 'T23:59:59'));
return (
<>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, marginBottom: 18, alignItems: 'center', justifyContent: 'center' }}>
<select value={filterKunde} onChange={e => setFilterKunde(e.target.value)} style={{ fontSize: 15, padding: '9px', borderRadius: 10, border: '1.2px solid #dbeafe', background: '#f6f7fb', minWidth: 110 }}>
<option value=''>Kunde</option>
<div className="space-y-4">
<div className="card flex flex-wrap gap-3">
<select className="input flex-1 min-w-[140px]" value={filterKunde} onChange={e => setFilterKunde(e.target.value)}>
<option value="">Alle Kunden</option>
{kunden.map(k => <option key={k.id} value={k.id}>{k.name}</option>)}
</select>
<select value={filterArtikel} onChange={e => setFilterArtikel(e.target.value)} style={{ fontSize: 15, padding: '9px', borderRadius: 10, border: '1.2px solid #dbeafe', background: '#f6f7fb', minWidth: 110 }}>
<option value=''>Artikel</option>
<select className="input flex-1 min-w-[140px]" value={filterArtikel} onChange={e => setFilterArtikel(e.target.value)}>
<option value="">Alle Artikel</option>
{artikel.map(a => <option key={a.id} value={a.id}>{a.bezeichnung}</option>)}
</select>
<input type='date' value={filterVon} onChange={e => setFilterVon(e.target.value)} style={{ fontSize: 15, padding: '9px', borderRadius: 10, border: '1.2px solid #dbeafe', background: '#f6f7fb' }} placeholder='Von' />
<input type='date' value={filterBis} onChange={e => setFilterBis(e.target.value)} style={{ fontSize: 15, padding: '9px', borderRadius: 10, border: '1.2px solid #dbeafe', background: '#f6f7fb' }} placeholder='Bis' />
<input type="date" className="input flex-1 min-w-[140px]" value={filterVon} onChange={e => setFilterVon(e.target.value)} />
<input type="date" className="input flex-1 min-w-[140px]" value={filterBis} onChange={e => setFilterBis(e.target.value)} />
</div>
{list.length === 0 ? (
<div style={{ textAlign: 'center', color: '#888', margin: 32 }}>Keine Daten vorhanden.</div>
{loading ? (
<div className="text-center text-slate-500 py-12">Lade</div>
) : view.length === 0 ? (
<div className="card text-center text-slate-500">Keine Eintraege.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{list.map(item => (
<div key={item.id} style={{
background: '#f6f7fb',
borderRadius: 16,
boxShadow: '0 2px 10px #0001',
padding: 16,
display: 'flex', alignItems: 'center', gap: 14,
flexWrap: 'wrap',
}}>
<div style={{ minWidth: 62, minHeight: 62, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<FotoCell path={item.foto_url} />
<ul className="space-y-2">
{view.map(item => (
<li key={item.id} className="card flex items-center gap-4 py-3">
<Foto path={item.foto_url} />
<div className="flex-1 min-w-0">
<div className="font-semibold text-slate-900 truncate">{item.kunde_name || 'Unbekannt'}</div>
<div className="text-sm text-brand-700 truncate">{item.artikel_bezeichnung}</div>
<div className="text-xs text-slate-500 mt-0.5">
{new Date(item.erstellt_am).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })}
{item.mitarbeiter_name && <> · {item.mitarbeiter_name}</>}
</div>
</div>
<div style={{ flex: 1, minWidth: 120 }}>
<div style={{ fontWeight: 700, fontSize: 17, marginBottom: 2 }}>{item.kunde_name || 'Unbekannt'}</div>
<div style={{ fontSize: 15, color: '#1976d2', fontWeight: 600 }}>{item.artikel_bezeichnung}</div>
<div style={{ fontSize: 14, color: '#444', marginTop: 2 }}>Menge: <b>{item.anzahl}</b></div>
<div style={{ fontSize: 13, color: '#888', marginTop: 2 }}>{new Date(item.erstellt_am).toLocaleString()}</div>
<div className="text-right">
<div className="text-xl font-semibold text-slate-900">{item.anzahl}</div>
<div className="text-xs text-slate-500">Stueck</div>
</div>
</div>
</li>
))}
</div>
</ul>
)}
</>
</div>
);
}

Binary file not shown.

View File

@@ -1,140 +1,141 @@
import { useEffect, useState } from 'react';
import { FaCamera, FaInfoCircle } from 'react-icons/fa';
import { api } from '../../api/client';
export default function VorgangForm({ user, onSubmit }) {
const API_BASE = import.meta.env.VITE_API_URL || '/api';
export default function VorgangForm({ onSubmit }) {
const [kunden, setKunden] = useState([]);
const [artikel, setArtikel] = useState([]);
const [kundeId, setKundeId] = useState('');
const [artikelId, setArtikelId] = useState('');
const [anzahl, setAnzahl] = useState(1);
const [foto, setFoto] = useState(null);
const [uploading, setUploading] = useState(false);
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState('');
const [infoOpen, setInfoOpen] = useState(false);
const [kundenDetail, setKundenDetail] = useState(null);
useEffect(() => {
async function loadData() {
try {
const [kundenData, artikelData] = await Promise.all([
api.getKunden(),
api.getArtikel()
]);
setKunden(kundenData || []);
setArtikel(artikelData || []);
} catch (error) {
console.error('Fehler beim Laden:', error);
}
}
loadData();
Promise.all([api.getKunden(), api.getArtikel()])
.then(([k, a]) => { setKunden(k || []); setArtikel(a || []); });
}, []);
const handleFotoChange = (e) => {
if (e.target.files && e.target.files[0]) {
setFoto(e.target.files[0]);
}
const openInfo = async () => {
if (!kundeId) return;
try { setKundenDetail(await api.getKunde(kundeId)); setInfoOpen(true); }
catch (e) { setMsg(e.message); }
};
const handleSubmit = async (e, typ) => {
e.preventDefault();
setUploading(true);
setMessage('');
const submit = async (typ) => {
if (!kundeId || !artikelId) return setMsg('Bitte Kunde und Artikel waehlen');
setBusy(true); setMsg('');
try {
const vorgangData = {
kunde_id: kundeId,
artikel_id: artikelId,
anzahl: parseInt(anzahl)
};
if (typ === 'lieferung') {
await api.createLieferung(vorgangData, foto);
} else {
await api.createRuecknahme(vorgangData, foto);
}
setMessage('Vorgang gespeichert!');
setKundeId('');
setArtikelId('');
setAnzahl(1);
setFoto(null);
if (onSubmit) onSubmit();
} catch (error) {
setMessage('Fehler beim Speichern: ' + error.message);
} finally {
setUploading(false);
}
const data = { kunde_id: kundeId, artikel_id: artikelId, anzahl: parseInt(anzahl) };
if (typ === 'lieferung') await api.createLieferung(data, foto);
else await api.createRuecknahme(data, foto);
setMsg('Gespeichert.'); setKundeId(''); setArtikelId(''); setAnzahl(1); setFoto(null);
onSubmit?.();
} catch (e) { setMsg(e.message); } finally { setBusy(false); }
};
return (
<form style={{
maxWidth: 420,
margin: '0 auto',
background: '#fff',
borderRadius: 22,
boxShadow: '0 4px 22px #1976d222',
padding: 36,
fontFamily: 'DM Sans, Work Sans, Arial, sans-serif',
display: 'flex', flexDirection: 'column', gap: 18,
}} onSubmit={e => e.preventDefault()}>
<h2 style={{ textAlign: 'center', marginBottom: 18, fontWeight: 700, color: '#1976d2', fontSize: 24 }}>Neuer Vorgang</h2>
<select value={kundeId} onChange={e => setKundeId(e.target.value)} required style={{ fontSize: 18, padding: '14px', borderRadius: 12, border: '1.5px solid #dbeafe', background: '#f6f7fb', outline: 'none' }}>
<option value="">Kunde wählen</option>
{kunden.map(k => <option key={k.id} value={k.id}>{k.name}</option>)}
</select>
<select value={artikelId} onChange={e => setArtikelId(e.target.value)} required style={{ fontSize: 18, padding: '14px', borderRadius: 12, border: '1.5px solid #dbeafe', background: '#f6f7fb', outline: 'none' }}>
<option value="">Artikel wählen</option>
{artikel.map(a => <option key={a.id} value={a.id}>{a.bezeichnung}</option>)}
</select>
<input type="number" min="1" value={anzahl} onChange={e => setAnzahl(e.target.value)} required placeholder="Anzahl" style={{ fontSize: 18, padding: '14px', borderRadius: 12, border: '1.5px solid #dbeafe', background: '#f6f7fb', outline: 'none' }} />
<input
type="file"
accept="image/*"
onChange={handleFotoChange}
style={{ display: 'none' }}
id="foto-upload-input"
/>
<label htmlFor="foto-upload-input" style={{
display: 'block',
background: '#1976d2',
color: '#fff',
borderRadius: 12,
padding: '13px 0',
fontWeight: 700,
fontSize: 17,
textAlign: 'center',
margin: '10px 0 0 0',
cursor: 'pointer',
boxShadow: '0 2px 10px #1976d222',
transition: 'background 0.18s',
}}>
📷 Foto erstellen oder auswählen
</label>
<div style={{ display: 'flex', gap: 10, marginTop: 12 }}>
<button type="button" disabled={uploading} onClick={e => handleSubmit(e, 'lieferung')} style={{
flex: 1,
background: '#1976d2',
color: '#fff',
borderRadius: 12,
fontWeight: 700,
fontSize: 17,
border: 'none',
padding: '13px 0',
boxShadow: '0 2px 10px #1976d222',
cursor: uploading ? 'not-allowed' : 'pointer',
}}>Ausliefern</button>
<button type="button" disabled={uploading} onClick={e => handleSubmit(e, 'ruecknahme')} style={{
flex: 1,
background: '#f6f7fb',
color: '#1976d2',
borderRadius: 12,
fontWeight: 700,
fontSize: 17,
border: '1.5px solid #dbeafe',
padding: '13px 0',
boxShadow: '0 2px 10px #1976d222',
cursor: uploading ? 'not-allowed' : 'pointer',
}}>Zurücknehmen</button>
<div className="card space-y-4">
<div>
<label className="label">Kunde</label>
<div className="flex gap-2">
<select className="input flex-1" value={kundeId} onChange={e => setKundeId(e.target.value)}>
<option value=""> waehlen </option>
{kunden.map(k => <option key={k.id} value={k.id}>{k.name}</option>)}
</select>
<button type="button" disabled={!kundeId} onClick={openInfo}
className="btn-secondary px-3" title="Anlieferungs-Infos anzeigen">
<FaInfoCircle />
</button>
</div>
</div>
{message && <div style={{ color: '#1976d2', fontWeight: 600, marginTop: 10, textAlign: 'center' }}>{message}</div>}
</form>
<div>
<label className="label">Artikel</label>
<select className="input" value={artikelId} onChange={e => setArtikelId(e.target.value)}>
<option value=""> waehlen </option>
{artikel.map(a => <option key={a.id} value={a.id}>{a.bezeichnung}</option>)}
</select>
</div>
<div>
<label className="label">Anzahl</label>
<input type="number" min="1" className="input" value={anzahl} onChange={e => setAnzahl(e.target.value)} />
</div>
<div>
<input type="file" accept="image/*" id="foto" className="sr-only"
onChange={e => setFoto(e.target.files?.[0] || null)} />
<label htmlFor="foto" className="btn-secondary w-full cursor-pointer">
<FaCamera /> {foto ? foto.name : 'Foto erstellen / auswaehlen'}
</label>
</div>
{msg && <div className="text-sm text-brand-700 text-center">{msg}</div>}
<div className="grid grid-cols-2 gap-2 pt-2">
<button disabled={busy} onClick={() => submit('lieferung')} className="btn-primary">Ausliefern</button>
<button disabled={busy} onClick={() => submit('ruecknahme')} className="btn-secondary">Zuruecknehmen</button>
</div>
{infoOpen && kundenDetail && (
<KundeInfoModal kunde={kundenDetail} onClose={() => setInfoOpen(false)} />
)}
</div>
);
}
function KundeInfoModal({ kunde, onClose }) {
return (
<div onClick={onClose} className="fixed inset-0 z-50 bg-slate-900/60 flex items-center justify-center p-4">
<div onClick={e => e.stopPropagation()} className="bg-white rounded-xl max-w-lg w-full p-6 max-h-[85vh] overflow-y-auto shadow-soft">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="text-lg font-semibold text-slate-900">{kunde.name}</h3>
{kunde.adresse && <p className="text-sm text-slate-500 mt-1">{kunde.adresse}</p>}
</div>
<button onClick={onClose} className="text-slate-400 hover:text-slate-600 text-xl leading-none">×</button>
</div>
{kunde.lieferzeit_bis && (
<div className="mb-3 inline-flex items-center gap-2 badge-amber">
Anlieferung bis {kunde.lieferzeit_bis} Uhr
</div>
)}
{kunde.anlieferungshinweis && (
<div className="mb-4 rounded-lg bg-amber-50 ring-1 ring-amber-200 p-3 text-sm text-amber-900 whitespace-pre-line">
{kunde.anlieferungshinweis}
</div>
)}
{kunde.bilder?.length > 0 && (
<div>
<div className="text-sm font-medium text-slate-700 mb-2">Anlieferungs-Fotos</div>
<div className="grid grid-cols-2 gap-2">
{kunde.bilder.map(b => {
const url = b.dateiname.startsWith('http')
? b.dateiname
: `${(import.meta.env.VITE_API_URL || '/api').replace('/api', '')}${b.dateiname}`;
return (
<div key={b.id} className="space-y-1">
<img src={url} className="w-full aspect-square object-cover rounded-lg ring-1 ring-slate-200" />
{b.beschreibung && <div className="text-xs text-slate-500">{b.beschreibung}</div>}
</div>
);
})}
</div>
</div>
)}
{kunde.adresse && (
<a target="_blank" rel="noopener" className="btn-primary w-full mt-4"
href={`https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(kunde.adresse)}`}>
Navigieren
</a>
)}
</div>
</div>
);
}

151
src/components/Geraete.jsx Normal file
View File

@@ -0,0 +1,151 @@
import { useEffect, useState } from 'react';
import { FaPlus, FaEdit, FaTrash, FaTimes } from 'react-icons/fa';
import { api } from '../api/client';
const STATUS = {
aktiv: 'badge-green',
in_reparatur: 'badge-amber',
lager: 'badge-slate',
ausgemustert: 'badge-rose',
};
const empty = { seriennummer: '', typ: '', bezeichnung: '', kunde_id: '', status: 'aktiv', notiz: '' };
export default function Geraete() {
const [list, setList] = useState([]);
const [kunden, setKunden] = useState([]);
const [form, setForm] = useState(empty);
const [editId, setEditId] = useState(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [filterTyp, setFilterTyp] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const load = async () => {
setLoading(true);
try { const [g, k] = await Promise.all([api.getGeraete(), api.getKunden()]); setList(g); setKunden(k); }
catch (e) { setError(e.message); } finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
const startEdit = (g) => {
setEditId(g.id);
setForm({
seriennummer: g.seriennummer, typ: g.typ, bezeichnung: g.bezeichnung || '',
kunde_id: g.kunde_id || '', status: g.status, notiz: g.notiz || ''
});
};
const cancel = () => { setEditId(null); setForm(empty); };
const save = async () => {
if (!form.seriennummer || !form.typ) return setError('Seriennummer + Typ erforderlich');
try {
if (editId) await api.updateGeraet(editId, form); else await api.createGeraet(form);
cancel(); setError(''); load();
} catch (e) { setError(e.message); }
};
const del = async (id) => { if (!confirm('Geraet loeschen?')) return; await api.deleteGeraet(id); load(); };
const typen = Array.from(new Set(list.map(g => g.typ)));
const view = list.filter(g =>
(!filterTyp || g.typ === filterTyp) && (!filterStatus || g.status === filterStatus)
);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-slate-900">Geraete</h2>
<div className="text-sm text-slate-500">{list.length} Geraete</div>
</div>
{error && <div className="rounded-md bg-rose-50 px-3 py-2 text-sm text-rose-700 ring-1 ring-rose-200">{error}</div>}
<div className="card">
<h3 className="font-medium text-slate-800 mb-3">{editId ? 'Geraet bearbeiten' : 'Neues Geraet'}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<Field label="Seriennummer *">
<input className="input" value={form.seriennummer} onChange={e => setForm(f => ({ ...f, seriennummer: e.target.value }))} />
</Field>
<Field label="Typ *">
<input className="input" placeholder="z. B. Ofen, Kuehlschrank" value={form.typ}
onChange={e => setForm(f => ({ ...f, typ: e.target.value }))} />
</Field>
<Field label="Bezeichnung">
<input className="input" placeholder="z. B. Miwe Condo 3-Etagen" value={form.bezeichnung}
onChange={e => setForm(f => ({ ...f, bezeichnung: e.target.value }))} />
</Field>
<Field label="Bei Kunde">
<select className="input" value={form.kunde_id} onChange={e => setForm(f => ({ ...f, kunde_id: e.target.value }))}>
<option value=""> keiner </option>
{kunden.map(k => <option key={k.id} value={k.id}>{k.name}</option>)}
</select>
</Field>
<Field label="Status">
<select className="input" value={form.status} onChange={e => setForm(f => ({ ...f, status: e.target.value }))}>
<option value="aktiv">aktiv</option>
<option value="in_reparatur">in Reparatur</option>
<option value="lager">im Lager</option>
<option value="ausgemustert">ausgemustert</option>
</select>
</Field>
<Field label="Notiz">
<input className="input" value={form.notiz} onChange={e => setForm(f => ({ ...f, notiz: e.target.value }))} />
</Field>
</div>
<div className="flex gap-2 mt-3">
<button onClick={save} className="btn-primary"><FaPlus /> {editId ? 'Speichern' : 'Anlegen'}</button>
{editId && <button onClick={cancel} className="btn-secondary"><FaTimes /> Abbrechen</button>}
</div>
</div>
<div className="flex gap-2 flex-wrap">
<select className="input flex-1 min-w-[160px]" value={filterTyp} onChange={e => setFilterTyp(e.target.value)}>
<option value="">Alle Typen</option>
{typen.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<select className="input flex-1 min-w-[160px]" value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
<option value="">Alle Status</option>
<option value="aktiv">aktiv</option>
<option value="in_reparatur">in Reparatur</option>
<option value="lager">im Lager</option>
<option value="ausgemustert">ausgemustert</option>
</select>
</div>
<div className="card overflow-x-auto p-0">
{loading ? <div className="text-center py-8 text-slate-500">Lade</div> : (
<table className="min-w-full text-sm">
<thead className="bg-slate-50 text-xs uppercase text-slate-600">
<tr>
<th className="text-left px-4 py-3">SN</th>
<th className="text-left px-4 py-3">Typ</th>
<th className="text-left px-4 py-3 hidden md:table-cell">Bezeichnung</th>
<th className="text-left px-4 py-3">Bei Kunde</th>
<th className="text-left px-4 py-3">Status</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{view.map(g => (
<tr key={g.id} className="hover:bg-slate-50">
<td className="px-4 py-3 font-mono text-xs">{g.seriennummer}</td>
<td className="px-4 py-3">{g.typ}</td>
<td className="px-4 py-3 text-slate-600 hidden md:table-cell">{g.bezeichnung || '—'}</td>
<td className="px-4 py-3">{g.kunde_name || <span className="text-slate-400"></span>}</td>
<td className="px-4 py-3"><span className={STATUS[g.status] || 'badge-slate'}>{g.status}</span></td>
<td className="px-4 py-3 text-right whitespace-nowrap">
<button onClick={() => startEdit(g)} className="btn-ghost px-2"><FaEdit /></button>
<button onClick={() => del(g.id)} className="btn-ghost px-2 text-rose-600"><FaTrash /></button>
</td>
</tr>
))}
{view.length === 0 && <tr><td colSpan={6} className="text-center py-8 text-slate-400">Keine Geraete.</td></tr>}
</tbody>
</table>
)}
</div>
</div>
);
}
function Field({ label, children }) {
return <div><label className="label">{label}</label>{children}</div>;
}

View File

@@ -1,168 +1,220 @@
import { useEffect, useState } from 'react';
import { FaEdit, FaTrash, FaCamera, FaTimes, FaPlus } from 'react-icons/fa';
import { api } from '../api/client';
const API_BASE = import.meta.env.VITE_API_URL || '/api';
const emptyForm = {
name: '', adresse: '', email: '', telefon: '', anrede: '', ansprechpartner: '',
notiz: '', anlieferungshinweis: '', lieferzeit_bis: ''
};
export default function KundenVerwaltung() {
const [kunden, setKunden] = useState([]);
const [editId, setEditId] = useState(null);
const [form, setForm] = useState(emptyForm);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [editId, setEditId] = useState(null);
const [form, setForm] = useState({ name: '', adresse: '', email: '', telefon: '', anrede: '', ansprechpartner: '', notiz: '' });
const [openBilder, setOpenBilder] = useState(null); // kunde-id mit bilder-modal
useEffect(() => { fetchKunden(); }, []);
async function fetchKunden() {
const load = async () => {
setLoading(true);
try {
const data = await api.getKunden();
setKunden(data || []);
setError('');
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
try { setKunden(await api.getKunden()); setError(''); }
catch (e) { setError(e.message); }
finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
function handleEdit(k) {
const startEdit = (k) => {
setEditId(k.id);
setForm({
name: k.name || '',
adresse: k.adresse || '',
email: k.email || '',
telefon: k.telefon || '',
anrede: k.anrede || '',
ansprechpartner: k.ansprechpartner || '',
notiz: k.notiz || ''
name: k.name || '', adresse: k.adresse || '', email: k.email || '',
telefon: k.telefon || '', anrede: k.anrede || '',
ansprechpartner: k.ansprechpartner || '', notiz: k.notiz || '',
anlieferungshinweis: k.anlieferungshinweis || '',
lieferzeit_bis: k.lieferzeit_bis || ''
});
}
};
const cancel = () => { setEditId(null); setForm(emptyForm); };
async function handleSave() {
if (!form.name) return setError('Name ist erforderlich');
const save = async () => {
if (!form.name) return setError('Name erforderlich');
try {
if (editId) {
await api.updateKunde(editId, form);
} else {
await api.createKunde(form);
}
setEditId(null);
setForm({ name: '', adresse: '', email: '', telefon: '', anrede: '', ansprechpartner: '', notiz: '' });
fetchKunden();
} catch (err) {
setError(err.message);
}
}
if (editId) await api.updateKunde(editId, form);
else await api.createKunde(form);
cancel(); load();
} catch (e) { setError(e.message); }
};
function handleCancel() {
setEditId(null);
setForm({ name: '', adresse: '', email: '', telefon: '', anrede: '', ansprechpartner: '', notiz: '' });
}
async function handleDelete(id) {
if (!confirm('Kunde wirklich löschen?')) return;
try {
await api.deleteKunde(id);
fetchKunden();
} catch (err) {
setError(err.message);
}
}
function handleChange(e) {
setForm(f => ({ ...f, [e.target.name]: e.target.value }));
}
const del = async (id) => {
if (!confirm('Kunde wirklich loeschen?')) return;
try { await api.deleteKunde(id); load(); } catch (e) { setError(e.message); }
};
return (
<div style={{ background: '#fff', borderRadius: 16, boxShadow: '0 2px 16px #0001', padding: 24, maxWidth: 1200, margin: '32px auto' }}>
<h2 style={{ textAlign: 'center', marginBottom: 16, color: '#1976d2', fontWeight: 700 }}>Kundenverwaltung</h2>
{loading && <div>Lade...</div>}
{error && <div style={{ color: 'red' }}>{error}</div>}
<div style={{ marginBottom: 18, display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<input name="name" value={form.name} onChange={handleChange} placeholder="Name" style={{ minWidth: 120, flex: 2 }} />
<input name="adresse" value={form.adresse} onChange={handleChange} placeholder="Adresse" style={{ minWidth: 120, flex: 2 }} />
<input name="email" value={form.email} onChange={handleChange} placeholder="E-Mail" style={{ minWidth: 120, flex: 2 }} />
<input name="telefon" value={form.telefon} onChange={handleChange} placeholder="Telefon" style={{ minWidth: 100, flex: 1 }} />
<select name="anrede" value={form.anrede} onChange={handleChange} style={{ minWidth: 80, flex: 1 }}>
<option value="">Anrede</option>
<option value="Herr">Herr</option>
<option value="Frau">Frau</option>
</select>
<input name="ansprechpartner" value={form.ansprechpartner} onChange={handleChange} placeholder="Ansprechpartner" style={{ minWidth: 100, flex: 1 }} />
<input name="notiz" value={form.notiz} onChange={handleChange} placeholder="Notiz" style={{ minWidth: 100, flex: 1 }} />
{editId ? (
<>
<button onClick={handleSave} style={{ marginLeft: 8 }}>Speichern</button>
<button onClick={handleCancel} style={{ marginLeft: 4 }}>Abbrechen</button>
</>
) : (
<button onClick={handleSave} style={{ marginLeft: 8 }}>Neu anlegen</button>
)}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-slate-900">Kunden</h2>
<div className="text-sm text-slate-500">{kunden.length} Kunden</div>
</div>
{/* Responsive Darstellung: Tabelle auf Desktop, Karten auf Mobil */}
<div className="kunden-tabelle-responsive" style={{ overflowX: 'auto' }}>
<div className="kunden-tabelle-desktop">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14, minWidth: 800 }}>
<thead>
<tr style={{ background: '#1976d2', color: '#fff' }}>
<th style={{ padding: '12px 8px', textAlign: 'left' }}>Name</th>
<th style={{ padding: '12px 8px', textAlign: 'left' }}>Adresse</th>
<th style={{ padding: '12px 8px', textAlign: 'left' }}>E-Mail</th>
<th style={{ padding: '12px 8px', textAlign: 'left' }}>Telefon</th>
<th style={{ padding: '12px 8px', textAlign: 'left' }}>Anrede</th>
<th style={{ padding: '12px 8px', textAlign: 'left' }}>Ansprechpartner</th>
<th style={{ padding: '12px 8px', textAlign: 'left' }}>Notiz</th>
<th style={{ padding: '12px 8px', textAlign: 'left' }}>Aktionen</th>
{error && <div className="rounded-md bg-rose-50 px-3 py-2 text-sm text-rose-700 ring-1 ring-rose-200">{error}</div>}
<div className="card">
<h3 className="font-medium text-slate-800 mb-3">{editId ? 'Kunde bearbeiten' : 'Neuer Kunde'}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Field label="Name *">
<input className="input" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} />
</Field>
<Field label="Adresse">
<input className="input" value={form.adresse} onChange={e => setForm(f => ({ ...f, adresse: e.target.value }))} />
</Field>
<Field label="E-Mail">
<input type="email" className="input" value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))} />
</Field>
<Field label="Telefon">
<input className="input" value={form.telefon} onChange={e => setForm(f => ({ ...f, telefon: e.target.value }))} />
</Field>
<Field label="Anrede">
<select className="input" value={form.anrede} onChange={e => setForm(f => ({ ...f, anrede: e.target.value }))}>
<option value=""></option><option>Herr</option><option>Frau</option><option>Firma</option>
</select>
</Field>
<Field label="Ansprechpartner">
<input className="input" value={form.ansprechpartner} onChange={e => setForm(f => ({ ...f, ansprechpartner: e.target.value }))} />
</Field>
<Field label="Lieferzeit bis (HH:MM)">
<input className="input" placeholder="05:15" value={form.lieferzeit_bis} onChange={e => setForm(f => ({ ...f, lieferzeit_bis: e.target.value }))} />
</Field>
<Field label="Notiz">
<input className="input" value={form.notiz} onChange={e => setForm(f => ({ ...f, notiz: e.target.value }))} />
</Field>
<div className="sm:col-span-2">
<label className="label">Anlieferungshinweis</label>
<textarea rows={3} className="input"
placeholder="z. B. Hintertuer, Code 1234#, Schluesselkasten links neben dem Eingang"
value={form.anlieferungshinweis}
onChange={e => setForm(f => ({ ...f, anlieferungshinweis: e.target.value }))} />
</div>
</div>
<div className="flex gap-2 mt-4">
<button onClick={save} className="btn-primary"><FaPlus /> {editId ? 'Speichern' : 'Anlegen'}</button>
{editId && <button onClick={cancel} className="btn-secondary"><FaTimes /> Abbrechen</button>}
</div>
</div>
{loading ? (
<div className="text-center text-slate-500 py-8">Lade</div>
) : (
<div className="card overflow-x-auto p-0">
<table className="min-w-full text-sm">
<thead className="bg-slate-50 text-slate-600 text-xs uppercase tracking-wide">
<tr>
<th className="text-left px-4 py-3">Name</th>
<th className="text-left px-4 py-3 hidden md:table-cell">Adresse</th>
<th className="text-left px-4 py-3 hidden lg:table-cell">Lieferzeit</th>
<th className="text-left px-4 py-3 hidden lg:table-cell">Hinweis</th>
<th className="text-right px-4 py-3"></th>
</tr>
</thead>
<tbody>
<tbody className="divide-y divide-slate-100">
{kunden.map(k => (
<tr key={k.id} style={{ background: editId === k.id ? '#fffde7' : '#fff', borderBottom: '1px solid #eee' }}>
<td style={{ padding: '10px 8px' }}>{k.name}</td>
<td style={{ padding: '10px 8px' }}>{k.adresse}</td>
<td style={{ padding: '10px 8px' }}>{k.email}</td>
<td style={{ padding: '10px 8px' }}>{k.telefon}</td>
<td style={{ padding: '10px 8px' }}>{k.anrede}</td>
<td style={{ padding: '10px 8px' }}>{k.ansprechpartner}</td>
<td style={{ padding: '10px 8px', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{k.notiz}</td>
<td style={{ whiteSpace: 'nowrap' }}>
<button onClick={() => handleEdit(k)} style={{ fontSize: 13, marginRight: 4, padding: '6px 12px', background: '#1976d2', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer' }}>Bearbeiten</button>
<button onClick={() => handleDelete(k.id)} style={{ fontSize: 13, padding: '6px 12px', background: '#d32f2f', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer' }}>Löschen</button>
<tr key={k.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<div className="font-medium text-slate-900">{k.name}</div>
{k.ansprechpartner && <div className="text-xs text-slate-500">{k.anrede} {k.ansprechpartner}</div>}
</td>
<td className="px-4 py-3 text-slate-600 hidden md:table-cell">{k.adresse || '—'}</td>
<td className="px-4 py-3 hidden lg:table-cell">
{k.lieferzeit_bis ? <span className="badge-amber">{k.lieferzeit_bis}</span> : '—'}
</td>
<td className="px-4 py-3 hidden lg:table-cell text-slate-600 max-w-xs truncate">{k.anlieferungshinweis || '—'}</td>
<td className="px-4 py-3 text-right whitespace-nowrap">
<button onClick={() => setOpenBilder(k.id)} className="btn-ghost px-2" title="Bilder verwalten"><FaCamera /></button>
<button onClick={() => startEdit(k)} className="btn-ghost px-2" title="Bearbeiten"><FaEdit /></button>
<button onClick={() => del(k.id)} className="btn-ghost px-2 text-rose-600" title="Loeschen"><FaTrash /></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="kunden-tabelle-mobile">
{kunden.map(k => (
<div key={k.id} className="kunden-card">
<div style={{ fontWeight: 700, fontSize: 17 }}>{k.name}</div>
<div><b>Anrede:</b> {k.anrede}</div>
<div><b>Ansprechpartner:</b> {k.ansprechpartner}</div>
<div><b>Telefon:</b> {k.telefon}</div>
<div><b>E-Mail:</b> {k.email}</div>
<div style={{ fontSize: 13, color: '#444', margin: '4px 0' }}>{k.notiz}</div>
<button onClick={() => handleEdit(k)} style={{ fontSize: 13, marginTop: 6 }}>Bearbeiten</button>
</div>
))}
</div>
</div>
<style>{`
@media (max-width: 600px) {
.kunden-tabelle-desktop { display: none; }
.kunden-tabelle-mobile { display: block; }
.kunden-card {
background: #f6faff;
border-radius: 12px;
box-shadow: 0 1px 8px #0001;
padding: 14px 13px 10px 13px;
margin-bottom: 14px;
}
}
@media (min-width: 601px) {
.kunden-tabelle-desktop { display: block; }
.kunden-tabelle-mobile { display: none; }
}
`}</style>
)}
{openBilder && (
<BilderModal kundeId={openBilder} onClose={() => setOpenBilder(null)} />
)}
</div>
);
}
function Field({ label, children }) {
return (<div><label className="label">{label}</label>{children}</div>);
}
function BilderModal({ kundeId, onClose }) {
const [data, setData] = useState(null);
const [busy, setBusy] = useState(false);
const [beschreibung, setBeschreibung] = useState('');
const load = async () => setData(await api.getKunde(kundeId));
useEffect(() => { load(); }, [kundeId]);
const upload = async (file) => {
if (!file) return;
setBusy(true);
try { await api.uploadKundenBild(kundeId, file, beschreibung); setBeschreibung(''); await load(); }
catch (e) { alert(e.message); } finally { setBusy(false); }
};
const del = async (id) => {
if (!confirm('Bild loeschen?')) return;
await api.deleteKundenBild(id); load();
};
if (!data) return null;
return (
<div onClick={onClose} className="fixed inset-0 z-50 bg-slate-900/60 flex items-center justify-center p-4">
<div onClick={e => e.stopPropagation()} className="bg-white rounded-xl max-w-2xl w-full p-6 max-h-[85vh] overflow-y-auto shadow-soft">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-semibold">Anlieferungs-Fotos</h3>
<p className="text-sm text-slate-500">{data.name}</p>
</div>
<button onClick={onClose} className="text-slate-400 hover:text-slate-600 text-xl">×</button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-5">
{(data.bilder || []).map(b => {
const url = b.dateiname.startsWith('http') ? b.dateiname
: `${API_BASE.replace('/api', '')}${b.dateiname}`;
return (
<div key={b.id} className="relative group">
<img src={url} className="aspect-square object-cover rounded-lg ring-1 ring-slate-200 w-full" />
{b.beschreibung && <div className="text-xs text-slate-500 mt-1">{b.beschreibung}</div>}
<button onClick={() => del(b.id)}
className="absolute top-1 right-1 h-7 w-7 bg-white/90 rounded-full text-rose-600 opacity-0 group-hover:opacity-100 transition">
×
</button>
</div>
);
})}
{(data.bilder?.length || 0) === 0 && (
<div className="col-span-full text-center text-slate-400 text-sm py-6">Noch keine Bilder.</div>
)}
</div>
{(data.bilder?.length || 0) < 5 && (
<div className="border-t border-slate-200 pt-4 space-y-3">
<input className="input" placeholder="Beschreibung (z. B. Hintertuer)" value={beschreibung}
onChange={e => setBeschreibung(e.target.value)} />
<input type="file" accept="image/*" id="bildup" className="sr-only"
onChange={e => upload(e.target.files?.[0])} disabled={busy} />
<label htmlFor="bildup" className="btn-primary w-full cursor-pointer">
<FaCamera /> {busy ? 'Lade hoch…' : 'Bild hochladen'}
</label>
<p className="text-xs text-slate-400 text-center">Max. 5 Bilder pro Kunde</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,116 +1,97 @@
import { useEffect, useState } from 'react';
import { FaPlus, FaTrash } from 'react-icons/fa';
import { api } from '../api/client';
export default function MitarbeiterVerwaltung({ user }) {
const [mitarbeiter, setMitarbeiter] = useState([]);
const [loading, setLoading] = useState(true);
const [list, setList] = useState([]);
const [form, setForm] = useState({ name: '', email: '', password: '', rolle: 'user' });
const [error, setError] = useState('');
const [newName, setNewName] = useState('');
const [newEmail, setNewEmail] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newRolle, setNewRolle] = useState('user');
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchMitarbeiter();
}, []);
async function fetchMitarbeiter() {
const load = async () => {
setLoading(true);
try {
const data = await api.getMitarbeiter();
setMitarbeiter(data || []);
setError('');
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
try { setList(await api.getMitarbeiter()); setError(''); }
catch (e) { setError(e.message); } finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
async function handleRolleChange(id, rolle) {
const add = async () => {
if (!form.name || !form.email || !form.password) return setError('Alle Felder ausfuellen');
try {
await api.updateMitarbeiter(id, { rolle });
fetchMitarbeiter();
} catch (err) {
setError(err.message);
}
}
await api.createMitarbeiter(form);
setForm({ name: '', email: '', password: '', rolle: 'user' }); setError(''); load();
} catch (e) { setError(e.message); }
};
async function handleNameChange(id, name) {
try {
await api.updateMitarbeiter(id, { name });
fetchMitarbeiter();
} catch (err) {
setError(err.message);
}
}
async function handleAdd() {
if (!newEmail || !newPassword || !newName) {
setError('Bitte alle Felder ausfüllen');
return;
}
try {
await api.register(newEmail, newPassword, newName, newRolle);
setNewName('');
setNewEmail('');
setNewPassword('');
setNewRolle('user');
setError('');
fetchMitarbeiter();
} catch (err) {
setError(err.message);
}
}
const updateRolle = async (id, rolle) => { await api.updateMitarbeiter(id, { rolle }); load(); };
const toggleAktiv = async (id, aktiv) => { await api.updateMitarbeiter(id, { aktiv: !aktiv }); load(); };
const del = async (id) => { if (!confirm('Wirklich loeschen?')) return; await api.deleteMitarbeiter(id); load(); };
return (
<div style={{ background: '#fff', borderRadius: 16, boxShadow: '0 2px 16px #0001', padding: 24, maxWidth: 480, margin: '32px auto' }}>
<h2 style={{ textAlign: 'center', marginBottom: 16 }}>Mitarbeiterverwaltung</h2>
{loading && <div>Lade...</div>}
{error && <div style={{ color: 'red' }}>{error}</div>}
<div className="mitarbeiter-list">
{mitarbeiter.map(m => (
<div key={m.id} className="mitarbeiter-card" style={{ background: '#f6f7fb', borderRadius: 12, boxShadow: '0 1px 6px #0001', padding: 14, marginBottom: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input value={m.name || ''} onChange={e => handleNameChange(m.id, e.target.value)} style={{ flex: 1, fontSize: 16, padding: 8, borderRadius: 8, border: '1.5px solid #dbeafe' }} />
<select value={m.rolle} onChange={e => handleRolleChange(m.id, e.target.value)} style={{ fontSize: 16, padding: 8, borderRadius: 8, border: '1.5px solid #dbeafe' }}>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
))}
<div className="space-y-4 max-w-3xl mx-auto">
<h2 className="text-xl font-semibold text-slate-900">Mitarbeiter</h2>
{error && <div className="rounded-md bg-rose-50 px-3 py-2 text-sm text-rose-700 ring-1 ring-rose-200">{error}</div>}
<div className="card">
<h3 className="font-medium text-slate-800 mb-3">Neuer Mitarbeiter</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<input className="input" placeholder="Name" value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))} />
<input className="input" type="email" placeholder="E-Mail" value={form.email}
onChange={e => setForm(f => ({ ...f, email: e.target.value }))} />
<input className="input" type="password" placeholder="Passwort (min. 6)" value={form.password}
onChange={e => setForm(f => ({ ...f, password: e.target.value }))} />
<select className="input" value={form.rolle} onChange={e => setForm(f => ({ ...f, rolle: e.target.value }))}>
<option value="user">User</option>
<option value="fahrer">Fahrer</option>
<option value="admin">Admin</option>
</select>
</div>
<button onClick={add} className="btn-primary mt-3"><FaPlus /> Hinzufuegen</button>
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
<input placeholder="Name" value={newName} onChange={e => setNewName(e.target.value)} style={{ flex: 2, minWidth: 120, fontSize: 16, padding: 10, borderRadius: 8, border: '1.5px solid #dbeafe' }} />
<input placeholder="E-Mail" type="email" value={newEmail} onChange={e => setNewEmail(e.target.value)} style={{ flex: 2, minWidth: 120, fontSize: 16, padding: 10, borderRadius: 8, border: '1.5px solid #dbeafe' }} />
<input placeholder="Passwort" type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} style={{ flex: 2, minWidth: 120, fontSize: 16, padding: 10, borderRadius: 8, border: '1.5px solid #dbeafe' }} />
<select value={newRolle} onChange={e => setNewRolle(e.target.value)} style={{ fontSize: 16, padding: 10, borderRadius: 8, border: '1.5px solid #dbeafe' }}>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<button onClick={handleAdd} style={{ fontSize: 16, padding: '10px 18px', borderRadius: 8, background: '#1976d2', color: '#fff', border: 'none', fontWeight: 700, cursor: 'pointer' }}>Hinzufügen</button>
<div className="card overflow-x-auto p-0">
{loading ? <div className="text-center py-8 text-slate-500">Lade</div> : (
<table className="min-w-full text-sm">
<thead className="bg-slate-50 text-xs uppercase text-slate-600">
<tr>
<th className="text-left px-4 py-3">Name</th>
<th className="text-left px-4 py-3">E-Mail</th>
<th className="text-left px-4 py-3">Rolle</th>
<th className="text-left px-4 py-3">Aktiv</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{list.map(m => (
<tr key={m.id} className={m.id === user.id ? 'bg-brand-50/40' : ''}>
<td className="px-4 py-3 font-medium">{m.name} {m.id === user.id && <span className="badge-brand ml-1">Ich</span>}</td>
<td className="px-4 py-3 text-slate-600">{m.email}</td>
<td className="px-4 py-3">
<select value={m.rolle} onChange={e => updateRolle(m.id, e.target.value)}
disabled={m.id === user.id} className="input py-1 px-2 text-xs">
<option value="user">User</option>
<option value="fahrer">Fahrer</option>
<option value="admin">Admin</option>
</select>
</td>
<td className="px-4 py-3">
<button onClick={() => toggleAktiv(m.id, m.aktiv)} disabled={m.id === user.id}
className={m.aktiv ? 'badge-green' : 'badge-slate'}>
{m.aktiv ? 'aktiv' : 'inaktiv'}
</button>
</td>
<td className="px-4 py-3 text-right">
{m.id !== user.id && (
<button onClick={() => del(m.id)} className="btn-ghost text-rose-600 px-2"><FaTrash /></button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div style={{ fontSize: 13, color: '#888', marginBottom: 8 }}>
Rolle "admin" sieht das Admin-Dashboard.<br />
<b>Hinweis:</b> Neue Mitarbeiter benötigen E-Mail und Passwort für den Login.
</div>
<style>{`
@media (max-width: 600px) {
.mitarbeiter-list {
gap: 8px;
}
.mitarbeiter-card {
padding: 10px 6px;
font-size: 15px;
}
input, select, button {
font-size: 16px !important;
padding: 10px !important;
}
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,190 @@
import { useEffect, useState } from 'react';
import { FaPlus, FaSignOutAlt, FaTrash, FaClock, FaUsers, FaUser } from 'react-icons/fa';
import { api } from '../api/client';
export default function SuperAdmin({ user, onLogout }) {
const [tenants, setTenants] = useState([]);
const [loading, setLoading] = useState(true);
const [showNew, setShowNew] = useState(false);
const [createdInfo, setCreatedInfo] = useState(null);
const load = async () => {
setLoading(true);
try { setTenants(await api.getTenants()); } finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
const extend = async (id, days) => {
await api.updateTenant(id, { extend_days: days });
load();
};
const setStatus = async (id, status) => { await api.updateTenant(id, { status }); load(); };
const del = async (id) => { if (!confirm('Tenant inkl. aller Daten loeschen?')) return; await api.deleteTenant(id); load(); };
return (
<div className="min-h-screen bg-slate-50">
<header className="bg-white border-b border-slate-200 sticky top-0 z-30">
<div className="max-w-6xl mx-auto px-4 sm:px-6 h-14 flex items-center">
<div className="flex items-center gap-2 flex-1">
<div className="h-8 w-8 rounded-lg bg-slate-900 text-white grid place-items-center font-bold">P</div>
<span className="font-semibold">Pfandsystem · SuperAdmin</span>
</div>
<div className="text-sm text-slate-500 mr-3 hidden sm:block">{user.email}</div>
<button onClick={onLogout} className="btn-ghost"><FaSignOutAlt /> Abmelden</button>
</div>
</header>
<main className="max-w-6xl mx-auto px-4 sm:px-6 py-6 sm:py-8 space-y-5">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-slate-900">Tenants</h1>
<p className="text-sm text-slate-500">{tenants.length} Tenants · {tenants.filter(t => t.typ === 'demo').length} Demos</p>
</div>
<button onClick={() => setShowNew(true)} className="btn-primary">
<FaPlus /> Neuer Demo-Account
</button>
</div>
{loading ? <div className="text-center text-slate-500 py-8">Lade</div> : (
<div className="card overflow-x-auto p-0">
<table className="min-w-full text-sm">
<thead className="bg-slate-50 text-xs uppercase text-slate-600">
<tr>
<th className="text-left px-4 py-3">Name</th>
<th className="text-left px-4 py-3 hidden md:table-cell">Slug</th>
<th className="text-left px-4 py-3">Typ</th>
<th className="text-left px-4 py-3">Status</th>
<th className="text-left px-4 py-3 hidden lg:table-cell">User / Kunden</th>
<th className="text-left px-4 py-3">Ablauf</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{tenants.map(t => {
const expired = t.demo_expires_at && new Date(t.demo_expires_at) < new Date();
return (
<tr key={t.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<div className="font-medium text-slate-900">{t.name}</div>
<div className="text-xs text-slate-500">{t.kontakt_email}</div>
</td>
<td className="px-4 py-3 font-mono text-xs hidden md:table-cell">{t.slug}</td>
<td className="px-4 py-3">
<span className={t.typ === 'demo' ? 'badge-amber' : 'badge-brand'}>{t.typ}</span>
</td>
<td className="px-4 py-3">
<select value={t.status} onChange={e => setStatus(t.id, e.target.value)}
className="input py-1 px-2 text-xs">
<option value="aktiv">aktiv</option>
<option value="abgelaufen">abgelaufen</option>
<option value="gesperrt">gesperrt</option>
</select>
</td>
<td className="px-4 py-3 text-xs text-slate-600 hidden lg:table-cell">
<span className="inline-flex items-center gap-1"><FaUser size={10} /> {t.user_count}</span>
<span className="inline-flex items-center gap-1 ml-2"><FaUsers size={10} /> {t.kunden_count}</span>
</td>
<td className="px-4 py-3">
{t.demo_expires_at ? (
<span className={expired ? 'badge-rose' : 'badge-slate'}>
<FaClock size={10} className="mr-1" />
{new Date(t.demo_expires_at).toLocaleDateString('de-DE')}
</span>
) : <span className="text-slate-400"></span>}
</td>
<td className="px-4 py-3 text-right whitespace-nowrap">
{t.typ === 'demo' && (
<button onClick={() => extend(t.id, 30)} className="btn-ghost text-xs">+30 Tage</button>
)}
<button onClick={() => del(t.id)} className="btn-ghost px-2 text-rose-600"><FaTrash /></button>
</td>
</tr>
);
})}
{tenants.length === 0 && (
<tr><td colSpan={7} className="text-center py-8 text-slate-400">Noch keine Tenants.</td></tr>
)}
</tbody>
</table>
</div>
)}
</main>
{showNew && <NewTenantModal onClose={() => setShowNew(false)} onCreated={(info) => { setCreatedInfo(info); setShowNew(false); load(); }} />}
{createdInfo && <CreatedInfoModal info={createdInfo} onClose={() => setCreatedInfo(null)} />}
</div>
);
}
function NewTenantModal({ onClose, onCreated }) {
const [form, setForm] = useState({ name: '', kontakt_email: '', firma: '', days: 30, seed: true });
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const submit = async (e) => {
e.preventDefault();
setBusy(true); setError('');
try { onCreated(await api.createTenant(form)); }
catch (e) { setError(e.message); } finally { setBusy(false); }
};
return (
<div onClick={onClose} className="fixed inset-0 z-50 bg-slate-900/60 flex items-center justify-center p-4">
<form onSubmit={submit} onClick={e => e.stopPropagation()}
className="bg-white rounded-xl max-w-md w-full p-6 shadow-soft space-y-4">
<h3 className="text-lg font-semibold">Neuer Demo-Account</h3>
{error && <div className="rounded-md bg-rose-50 px-3 py-2 text-sm text-rose-700 ring-1 ring-rose-200">{error}</div>}
<Field label="Ansprechpartner / Name *">
<input required className="input" value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))} />
</Field>
<Field label="E-Mail *">
<input required type="email" className="input" value={form.kontakt_email}
onChange={e => setForm(f => ({ ...f, kontakt_email: e.target.value }))} />
</Field>
<Field label="Firma">
<input className="input" value={form.firma}
onChange={e => setForm(f => ({ ...f, firma: e.target.value }))} />
</Field>
<Field label="Demo-Dauer (Tage)">
<input type="number" min="1" max="365" className="input" value={form.days}
onChange={e => setForm(f => ({ ...f, days: parseInt(e.target.value) || 30 }))} />
</Field>
<label className="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" checked={form.seed}
onChange={e => setForm(f => ({ ...f, seed: e.target.checked }))} />
Mit Beispieldaten befuellen (5 Kunden, 3 Artikel, 2 Geraete)
</label>
<div className="flex gap-2 pt-2">
<button type="button" onClick={onClose} className="btn-secondary flex-1">Abbrechen</button>
<button disabled={busy} className="btn-primary flex-1">{busy ? 'Lege an…' : 'Anlegen + Mail senden'}</button>
</div>
</form>
</div>
);
}
function CreatedInfoModal({ info, onClose }) {
return (
<div onClick={onClose} className="fixed inset-0 z-50 bg-slate-900/60 flex items-center justify-center p-4">
<div onClick={e => e.stopPropagation()} className="bg-white rounded-xl max-w-md w-full p-6 shadow-soft space-y-3">
<h3 className="text-lg font-semibold text-emerald-700">Demo-Account angelegt</h3>
<p className="text-sm text-slate-600">Der Interessent erhaelt eine Einladungs-Mail. Hier zur Sicherheit die Daten:</p>
<dl className="bg-slate-50 rounded-lg p-3 text-sm space-y-1.5">
<div><dt className="inline text-slate-500">Slug: </dt><dd className="inline font-mono">{info.slug}</dd></div>
<div><dt className="inline text-slate-500">E-Mail: </dt><dd className="inline">{info.admin_email}</dd></div>
<div><dt className="inline text-slate-500">Passwort: </dt><dd className="inline font-mono">{info.admin_password_oneshot}</dd></div>
<div><dt className="inline text-slate-500">Gueltig: </dt><dd className="inline">{info.demo_expires_in_days} Tage</dd></div>
</dl>
<p className="text-xs text-slate-400">Das Passwort wird nur einmal angezeigt.</p>
<button onClick={onClose} className="btn-primary w-full">OK</button>
</div>
</div>
);
}
function Field({ label, children }) {
return <div><label className="label">{label}</label>{children}</div>;
}

View File

@@ -0,0 +1,102 @@
import { useEffect, useState } from 'react';
import { FaMapMarkedAlt, FaCheckCircle, FaClock, FaInfoCircle } from 'react-icons/fa';
import { api } from '../api/client';
const API_BASE = import.meta.env.VITE_API_URL || '/api';
export default function TourPlanung() {
const [kunden, setKunden] = useState([]);
const [done, setDone] = useState(() => {
try { return new Set(JSON.parse(localStorage.getItem('tour_done') || '[]')); } catch { return new Set(); }
});
const [loading, setLoading] = useState(true);
const [showInfo, setShowInfo] = useState(null);
useEffect(() => {
api.getKunden().then(d => setKunden(d || [])).finally(() => setLoading(false));
}, []);
const toggle = (id) => {
const next = new Set(done);
if (next.has(id)) next.delete(id); else next.add(id);
setDone(next);
localStorage.setItem('tour_done', JSON.stringify([...next]));
};
const sorted = [...kunden].sort((a, b) => {
if (a.lieferzeit_bis && b.lieferzeit_bis) return a.lieferzeit_bis.localeCompare(b.lieferzeit_bis);
if (a.lieferzeit_bis) return -1;
if (b.lieferzeit_bis) return 1;
return a.name.localeCompare(b.name);
});
const openMaps = (adresse) => {
window.open(`https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(adresse)}`, '_blank');
};
const resetTour = () => { setDone(new Set()); localStorage.removeItem('tour_done'); };
return (
<div className="space-y-4 max-w-3xl mx-auto">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-slate-900">Meine Tour</h2>
<p className="text-sm text-slate-500">{done.size} / {sorted.length} erledigt</p>
</div>
{done.size > 0 && (
<button onClick={resetTour} className="btn-ghost text-xs">Tour zuruecksetzen</button>
)}
</div>
{loading ? <div className="text-center py-8 text-slate-500">Lade</div> : (
<ul className="space-y-2">
{sorted.map((k, idx) => {
const erledigt = done.has(k.id);
return (
<li key={k.id} className={`card flex items-center gap-3 ${erledigt ? 'opacity-50' : ''}`}>
<button onClick={() => toggle(k.id)}
className={`h-9 w-9 rounded-full grid place-items-center text-sm font-semibold ring-2 transition
${erledigt ? 'bg-emerald-500 text-white ring-emerald-500' : 'bg-white text-slate-500 ring-slate-300 hover:ring-brand-500'}`}>
{erledigt ? <FaCheckCircle /> : idx + 1}
</button>
<div className="flex-1 min-w-0">
<div className="font-medium text-slate-900 truncate">{k.name}</div>
<div className="text-xs text-slate-500 truncate">{k.adresse || '— keine Adresse —'}</div>
<div className="flex gap-2 mt-1.5 flex-wrap">
{k.lieferzeit_bis && (
<span className="badge-amber gap-1"><FaClock size={10} /> bis {k.lieferzeit_bis}</span>
)}
{k.anlieferungshinweis && (
<button onClick={() => setShowInfo(k)} className="badge-brand gap-1 hover:bg-brand-100">
<FaInfoCircle size={10} /> Hinweis
</button>
)}
</div>
</div>
<button onClick={() => k.adresse && openMaps(k.adresse)} disabled={!k.adresse}
className="btn-primary px-3 py-2" title="Navigieren">
<FaMapMarkedAlt />
</button>
</li>
);
})}
{sorted.length === 0 && <li className="card text-center text-slate-500">Keine Kunden in der Tour.</li>}
</ul>
)}
{showInfo && (
<div onClick={() => setShowInfo(null)} className="fixed inset-0 z-50 bg-slate-900/60 flex items-center justify-center p-4">
<div onClick={e => e.stopPropagation()} className="bg-white rounded-xl max-w-md w-full p-6 shadow-soft">
<div className="flex justify-between items-start mb-3">
<h3 className="font-semibold">{showInfo.name}</h3>
<button onClick={() => setShowInfo(null)} className="text-slate-400 hover:text-slate-600 text-xl">×</button>
</div>
<div className="rounded-lg bg-amber-50 ring-1 ring-amber-200 p-3 text-sm text-amber-900 whitespace-pre-line">
{showInfo.anlieferungshinweis}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,14 +0,0 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;700&family=Work+Sans:wght@400;700&display=swap');
body, input, button, select, textarea {
font-family: 'DM Sans', 'Work Sans', Arial, Helvetica, sans-serif;
letter-spacing: 0.01em;
font-smooth: always;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3, h4, h5, h6 {
font-family: 'DM Sans', 'Work Sans', Arial, Helvetica, sans-serif;
font-weight: 700;
}

43
src/index.css Normal file
View File

@@ -0,0 +1,43 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html { font-family: 'Inter', system-ui, sans-serif; }
body { @apply text-slate-800; }
}
@layer components {
.btn {
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5
text-sm font-semibold shadow-sm transition
focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2
focus-visible:ring-brand-500 disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-primary { @apply btn bg-brand-600 text-white hover:bg-brand-700; }
.btn-secondary { @apply btn bg-white text-slate-700 ring-1 ring-slate-300 hover:bg-slate-50; }
.btn-danger { @apply btn bg-rose-600 text-white hover:bg-rose-700; }
.btn-ghost { @apply btn bg-transparent text-slate-600 hover:bg-slate-100 shadow-none; }
.input {
@apply block w-full rounded-lg border-0 bg-white px-3.5 py-2.5
text-slate-900 shadow-sm ring-1 ring-inset ring-slate-300
placeholder:text-slate-400 focus:ring-2 focus:ring-inset focus:ring-brand-600
sm:text-sm sm:leading-6 disabled:bg-slate-50 disabled:text-slate-500;
}
.label { @apply block text-sm font-medium text-slate-700 mb-1.5; }
.card {
@apply bg-white rounded-xl shadow-card ring-1 ring-slate-200/60 p-5 sm:p-6;
}
.card-title { @apply text-lg font-semibold text-slate-900 mb-4; }
.badge {
@apply inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium ring-1 ring-inset;
}
.badge-green { @apply badge bg-emerald-50 text-emerald-700 ring-emerald-200; }
.badge-amber { @apply badge bg-amber-50 text-amber-800 ring-amber-200; }
.badge-rose { @apply badge bg-rose-50 text-rose-700 ring-rose-200; }
.badge-slate { @apply badge bg-slate-100 text-slate-700 ring-slate-200; }
.badge-brand { @apply badge bg-brand-50 text-brand-700 ring-brand-200; }
}

View File

@@ -1,4 +1,4 @@
import './fonts.css';
import './index.css';
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';

View File

@@ -1,6 +0,0 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
export const supabase = createClient(supabaseUrl, supabaseAnonKey);

30
tailwind.config.js Normal file
View File

@@ -0,0 +1,30 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,jsx}"],
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'system-ui', 'Segoe UI', 'Helvetica', 'Arial', 'sans-serif'],
},
colors: {
brand: {
50: '#eef2ff',
100: '#e0e7ff',
200: '#c7d2fe',
300: '#a5b4fc',
400: '#818cf8',
500: '#6366f1',
600: '#4f46e5',
700: '#4338ca',
800: '#3730a3',
900: '#312e81',
}
},
boxShadow: {
card: '0 1px 3px 0 rgb(15 23 42 / 0.06), 0 1px 2px -1px rgb(15 23 42 / 0.04)',
soft: '0 4px 16px -2px rgb(15 23 42 / 0.08)',
}
},
},
plugins: [],
}

View File

@@ -1,20 +0,0 @@
#!/bin/bash
cd /root/backend
echo "Starte Backend..."
node server.js > /tmp/backend.log 2>&1 &
BACKEND_PID=$!
echo "Backend PID: $BACKEND_PID"
sleep 3
echo ""
echo "Teste Login..."
curl -X POST http://localhost:3001/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@pfandsystem.de","password":"admin123"}'
echo ""
echo ""
echo "Backend Logs:"
cat /tmp/backend.log
kill $BACKEND_PID 2>/dev/null

View File

@@ -1,42 +0,0 @@
#!/bin/bash
# System-Test-Script
echo "🧪 Pfandsystem Test-Suite"
echo "=========================="
echo ""
# 1. Container-Status
echo "1⃣ Container-Status:"
docker-compose ps
echo ""
# 2. Backend Health Check
echo "2⃣ Backend Health Check:"
curl -s http://localhost:3001/health || echo "❌ Backend nicht erreichbar"
echo ""
echo ""
# 3. MySQL-Verbindung
echo "3⃣ MySQL-Verbindung:"
docker exec pfandsystem-mysql mysql -u pfandsystem -ppfandsystem_secure_password -e "SELECT 'MySQL OK' as status;" 2>/dev/null || echo "❌ MySQL nicht erreichbar"
echo ""
# 4. Backend-Logs (letzte 10 Zeilen)
echo "4⃣ Backend-Logs (letzte 10 Zeilen):"
docker-compose logs --tail=10 backend
echo ""
# 5. Frontend erreichbar
echo "5⃣ Frontend Check:"
curl -s -o /dev/null -w "HTTP Status: %{http_code}\n" http://localhost:80
echo ""
# 6. API-Endpunkte testen
echo "6⃣ API-Endpunkte:"
echo " /api/auth/login:"
curl -s -o /dev/null -w " HTTP Status: %{http_code}\n" -X POST http://localhost:3001/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"test","password":"test"}'
echo ""
echo "✅ Test abgeschlossen"

View File

@@ -1,30 +0,0 @@
http:
routers:
# Backend API Router (zeigt auf lokales Backend)
pfandsystem-api-local:
rule: "Host(`pfandsystem.backdigital.de`) && PathPrefix(`/api`)"
service: pfandsystem-api-local
entryPoints:
- web
- websecure
tls:
certResolver: letsencrypt
priority: 100
# Backend Uploads Router
pfandsystem-uploads-local:
rule: "Host(`pfandsystem.backdigital.de`) && PathPrefix(`/uploads`)"
service: pfandsystem-api-local
entryPoints:
- web
- websecure
tls:
certResolver: letsencrypt
priority: 100
services:
# Backend Service (zeigt auf Docker-Host via Bridge)
pfandsystem-api-local:
loadBalancer:
servers:
- url: "http://172.17.0.1:3001"

File diff suppressed because one or more lines are too long

View File

@@ -1,32 +0,0 @@
api:
dashboard: true
insecure: true
entryPoints:
web:
address: ":80"
websecure:
address: ":443"
certificatesResolvers:
letsencrypt:
acme:
email: vonperfall@r83.io
storage: /letsencrypt/acme.json
httpChallenge:
entryPoint: web
providers:
docker:
endpoint: "unix:///var/run/docker.sock"
exposedByDefault: false
network: root_pfandsystem-network
file:
directory: /etc/traefik/dynamic
watch: true
log:
level: INFO
accessLog:
filePath: "/var/log/access.log"

View File

@@ -8,24 +8,16 @@ export default defineConfig({
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'Pfandsystem PWA',
short_name: 'PfandPWA',
name: 'Pfandsystem',
short_name: 'Pfandsystem',
start_url: '.',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#1976d2',
description: 'Digitale Pfandverwaltung für Bäckereien',
background_color: '#f8fafc',
theme_color: '#4338ca',
description: 'Digitale Pfand- und Tourenverwaltung',
icons: [
{
src: 'icon-192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: 'icon-512.png',
sizes: '512x512',
type: 'image/png'
}
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png' }
]
},
includeAssets: ['icon-192.png', 'icon-512.png'],
@@ -33,10 +25,6 @@ export default defineConfig({
injectRegister: 'auto',
})
],
build: {
outDir: 'dist',
},
server: {
port: 5173,
},
build: { outDir: 'dist' },
server: { port: 5173 },
});