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:
BIN
._index.html
Normal file
BIN
._index.html
Normal file
Binary file not shown.
BIN
._package.json
Normal file
BIN
._package.json
Normal file
Binary file not shown.
BIN
._postcss.config.js
Normal file
BIN
._postcss.config.js
Normal file
Binary file not shown.
BIN
._tailwind.config.js
Normal file
BIN
._tailwind.config.js
Normal file
Binary file not shown.
44
backup.sh
44
backup.sh
@@ -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!"
|
||||
@@ -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"
|
||||
11
certbot.sh
11
certbot.sh
@@ -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
|
||||
@@ -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."
|
||||
50
deploy.sh
50
deploy.sh
@@ -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 ""
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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!** 🚀
|
||||
@@ -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!
|
||||
@@ -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:**
|
||||
@@ -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!**
|
||||
@@ -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!**
|
||||
@@ -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!
|
||||
@@ -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**
|
||||
@@ -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
|
||||
@@ -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!** 🚀
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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!**
|
||||
@@ -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!**
|
||||
@@ -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.
|
||||
@@ -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!**
|
||||
@@ -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!**
|
||||
@@ -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.');
|
||||
@@ -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.');
|
||||
11
index.html
11
index.html
@@ -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>
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
|
||||
98
nginx.conf
98
nginx.conf
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
13
public/manifest.json
Normal file
13
public/manifest.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
64
restore.sh
64
restore.sh
@@ -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"
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
BIN
src/._App.jsx
Normal file
Binary file not shown.
BIN
src/._components
Executable file
BIN
src/._components
Executable file
Binary file not shown.
BIN
src/._index.css
Normal file
BIN
src/._index.css
Normal file
Binary file not shown.
BIN
src/._main.jsx
Normal file
BIN
src/._main.jsx
Normal file
Binary file not shown.
56
src/App.jsx
56
src/App.jsx
@@ -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;
|
||||
|
||||
@@ -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
BIN
src/api/._client.js
Normal file
Binary file not shown.
@@ -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
BIN
src/components/._Auth
Executable file
Binary file not shown.
BIN
src/components/._Dashboard
Executable file
BIN
src/components/._Dashboard
Executable file
Binary file not shown.
BIN
src/components/._Eingabe
Executable file
BIN
src/components/._Eingabe
Executable file
Binary file not shown.
BIN
src/components/._Geraete.jsx
Normal file
BIN
src/components/._Geraete.jsx
Normal file
Binary file not shown.
BIN
src/components/._KundenVerwaltung.jsx
Normal file
BIN
src/components/._KundenVerwaltung.jsx
Normal file
Binary file not shown.
BIN
src/components/._MitarbeiterVerwaltung.jsx
Normal file
BIN
src/components/._MitarbeiterVerwaltung.jsx
Normal file
Binary file not shown.
BIN
src/components/._SuperAdmin.jsx
Normal file
BIN
src/components/._SuperAdmin.jsx
Normal file
Binary file not shown.
BIN
src/components/._TourPlanung.jsx
Normal file
BIN
src/components/._TourPlanung.jsx
Normal file
Binary file not shown.
BIN
src/components/Auth/._Login.jsx
Normal file
BIN
src/components/Auth/._Login.jsx
Normal file
Binary file not shown.
BIN
src/components/Auth/._PasswordChange.jsx
Normal file
BIN
src/components/Auth/._PasswordChange.jsx
Normal file
Binary file not shown.
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
BIN
src/components/Dashboard/._Account.jsx
Normal file
BIN
src/components/Dashboard/._Account.jsx
Normal file
Binary file not shown.
BIN
src/components/Dashboard/._AdminDashboard.jsx
Normal file
BIN
src/components/Dashboard/._AdminDashboard.jsx
Normal file
Binary file not shown.
BIN
src/components/Dashboard/._BildModal.jsx
Normal file
BIN
src/components/Dashboard/._BildModal.jsx
Normal file
Binary file not shown.
BIN
src/components/Dashboard/._Dashboard.jsx
Normal file
BIN
src/components/Dashboard/._Dashboard.jsx
Normal file
Binary file not shown.
BIN
src/components/Dashboard/._TopNav.jsx
Normal file
BIN
src/components/Dashboard/._TopNav.jsx
Normal file
Binary file not shown.
BIN
src/components/Dashboard/._VorgangsListe.jsx
Normal file
BIN
src/components/Dashboard/._VorgangsListe.jsx
Normal file
Binary file not shown.
32
src/components/Dashboard/Account.jsx
Normal file
32
src/components/Dashboard/Account.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} <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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
BIN
src/components/Eingabe/._VorgangForm.jsx
Normal file
BIN
src/components/Eingabe/._VorgangForm.jsx
Normal file
Binary file not shown.
@@ -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
151
src/components/Geraete.jsx
Normal 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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
190
src/components/SuperAdmin.jsx
Normal file
190
src/components/SuperAdmin.jsx
Normal 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>;
|
||||
}
|
||||
102
src/components/TourPlanung.jsx
Normal file
102
src/components/TourPlanung.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
43
src/index.css
Normal 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; }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import './fonts.css';
|
||||
import './index.css';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
@@ -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
30
tailwind.config.js
Normal 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: [],
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
@@ -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"
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user