Initial commit: ToDo Kids v1.0.0
Some checks failed
🚀 Continuous Integration / 🔧 Backend Tests (18.x) (push) Has been cancelled
🚀 Continuous Integration / 🔧 Backend Tests (20.x) (push) Has been cancelled
🚀 Continuous Integration / 🎨 Frontend Tests (18.x) (push) Has been cancelled
🚀 Continuous Integration / 🎨 Frontend Tests (20.x) (push) Has been cancelled
🚀 Continuous Integration / 🔍 Code Quality (push) Has been cancelled
🚀 Continuous Integration / 🔒 Security Checks (push) Has been cancelled
🚀 Continuous Integration / 🎨 Theme Tests (push) Has been cancelled
🚀 Continuous Integration / ♿ Accessibility Tests (push) Has been cancelled
🚀 Continuous Integration / 📱 Cross-Browser Tests (push) Has been cancelled
🚀 Continuous Integration / 🏗️ Build Tests (push) Has been cancelled
🚀 Continuous Integration / 📊 Performance Tests (push) Has been cancelled
🚀 Continuous Integration / 🎯 Integration Tests (push) Has been cancelled
🚀 Continuous Integration / ✅ All Tests Passed (push) Has been cancelled
Some checks failed
🚀 Continuous Integration / 🔧 Backend Tests (18.x) (push) Has been cancelled
🚀 Continuous Integration / 🔧 Backend Tests (20.x) (push) Has been cancelled
🚀 Continuous Integration / 🎨 Frontend Tests (18.x) (push) Has been cancelled
🚀 Continuous Integration / 🎨 Frontend Tests (20.x) (push) Has been cancelled
🚀 Continuous Integration / 🔍 Code Quality (push) Has been cancelled
🚀 Continuous Integration / 🔒 Security Checks (push) Has been cancelled
🚀 Continuous Integration / 🎨 Theme Tests (push) Has been cancelled
🚀 Continuous Integration / ♿ Accessibility Tests (push) Has been cancelled
🚀 Continuous Integration / 📱 Cross-Browser Tests (push) Has been cancelled
🚀 Continuous Integration / 🏗️ Build Tests (push) Has been cancelled
🚀 Continuous Integration / 📊 Performance Tests (push) Has been cancelled
🚀 Continuous Integration / 🎯 Integration Tests (push) Has been cancelled
🚀 Continuous Integration / ✅ All Tests Passed (push) Has been cancelled
This commit is contained in:
commit
0ebe7fa13d
108
.gitattributes
vendored
Normal file
108
.gitattributes
vendored
Normal file
@ -0,0 +1,108 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
|
||||
# Explicitly declare text files you want to always be normalized and converted
|
||||
# to native line endings on checkout.
|
||||
*.js text
|
||||
*.jsx text
|
||||
*.ts text
|
||||
*.tsx text
|
||||
*.json text
|
||||
*.css text
|
||||
*.scss text
|
||||
*.sass text
|
||||
*.html text
|
||||
*.htm text
|
||||
*.xml text
|
||||
*.md text
|
||||
*.txt text
|
||||
*.yml text
|
||||
*.yaml text
|
||||
*.env text
|
||||
*.gitignore text
|
||||
*.gitattributes text
|
||||
|
||||
# Declare files that will always have CRLF line endings on checkout.
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
|
||||
# Declare files that will always have LF line endings on checkout.
|
||||
*.sh text eol=lf
|
||||
Makefile text eol=lf
|
||||
|
||||
# Denote all files that are truly binary and should not be modified.
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.mov binary
|
||||
*.mp4 binary
|
||||
*.mp3 binary
|
||||
*.flv binary
|
||||
*.fla binary
|
||||
*.swf binary
|
||||
*.gz binary
|
||||
*.zip binary
|
||||
*.7z binary
|
||||
*.ttf binary
|
||||
*.eot binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.pyc binary
|
||||
*.pdf binary
|
||||
*.ez binary
|
||||
*.bz2 binary
|
||||
*.swp binary
|
||||
*.jar binary
|
||||
*.class binary
|
||||
*.tar binary
|
||||
*.tar.gz binary
|
||||
*.exe binary
|
||||
*.dll binary
|
||||
*.so binary
|
||||
*.dylib binary
|
||||
|
||||
# Language-specific settings
|
||||
*.js linguist-language=JavaScript
|
||||
*.jsx linguist-language=JavaScript
|
||||
*.ts linguist-language=TypeScript
|
||||
*.tsx linguist-language=TypeScript
|
||||
*.css linguist-language=CSS
|
||||
*.scss linguist-language=SCSS
|
||||
*.sass linguist-language=Sass
|
||||
*.html linguist-language=HTML
|
||||
*.md linguist-language=Markdown
|
||||
|
||||
# Exclude files from language statistics
|
||||
/client/build/* linguist-generated=true
|
||||
/client/public/* linguist-vendored=true
|
||||
/node_modules/* linguist-vendored=true
|
||||
/client/node_modules/* linguist-vendored=true
|
||||
package-lock.json linguist-generated=true
|
||||
/client/package-lock.json linguist-generated=true
|
||||
yarn.lock linguist-generated=true
|
||||
/client/yarn.lock linguist-generated=true
|
||||
|
||||
# Documentation
|
||||
*.md linguist-documentation=true
|
||||
CONTRIBUTING.md linguist-documentation=true
|
||||
CHANGELOG.md linguist-documentation=true
|
||||
DEPLOYMENT.md linguist-documentation=true
|
||||
README.md linguist-documentation=true
|
||||
LICENSE linguist-documentation=true
|
||||
|
||||
# Configuration files
|
||||
*.json linguist-language=JSON
|
||||
*.yml linguist-language=YAML
|
||||
*.yaml linguist-language=YAML
|
||||
.gitignore linguist-language=gitignore
|
||||
.gitattributes linguist-language=gitattributes
|
||||
.env* linguist-language=Shell
|
||||
|
||||
# Merge strategies
|
||||
*.json merge=ours
|
||||
package-lock.json merge=ours
|
||||
/client/package-lock.json merge=ours
|
||||
yarn.lock merge=ours
|
||||
/client/yarn.lock merge=ours
|
||||
70
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
70
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@ -0,0 +1,70 @@
|
||||
---
|
||||
name: 🐛 Bug Report
|
||||
about: Erstelle einen Bericht über einen Fehler
|
||||
title: '[BUG] '
|
||||
labels: ['bug', 'needs-triage']
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Beschreibung des Bugs
|
||||
Eine klare und präzise Beschreibung des Problems.
|
||||
|
||||
## 🔄 Schritte zur Reproduktion
|
||||
Schritte, um das Verhalten zu reproduzieren:
|
||||
1. Gehe zu '...'
|
||||
2. Klicke auf '....'
|
||||
3. Scrolle nach unten zu '....'
|
||||
4. Siehe Fehler
|
||||
|
||||
## ✅ Erwartetes Verhalten
|
||||
Eine klare und präzise Beschreibung dessen, was du erwartet hast.
|
||||
|
||||
## 📱 Screenshots
|
||||
Falls zutreffend, füge Screenshots hinzu, um das Problem zu erklären.
|
||||
|
||||
## 🖥️ System-Informationen
|
||||
**Desktop:**
|
||||
- OS: [z.B. Windows 11, macOS 13, Ubuntu 22.04]
|
||||
- Browser: [z.B. Chrome 120, Firefox 121, Safari 17]
|
||||
- Version: [z.B. 1.0.0]
|
||||
|
||||
**Smartphone:**
|
||||
- Device: [z.B. iPhone 15, Samsung Galaxy S23]
|
||||
- OS: [z.B. iOS 17.1, Android 14]
|
||||
- Browser: [z.B. Safari, Chrome]
|
||||
- Version: [z.B. 1.0.0]
|
||||
|
||||
## 🎨 Theme
|
||||
Welches Theme war aktiv, als der Fehler auftrat?
|
||||
- [ ] 🌈 Buntes Theme
|
||||
- [ ] 🌲 Wald-Theme
|
||||
- [ ] 🚀 Weltraum-Theme
|
||||
- [ ] 🌊 Ozean-Theme
|
||||
- [ ] 🌸 Natur-Theme
|
||||
|
||||
## 👤 Benutzer-Kontext
|
||||
- [ ] Eltern-Account
|
||||
- [ ] Kind-Account
|
||||
- [ ] Gast-Modus
|
||||
|
||||
## 📝 Zusätzlicher Kontext
|
||||
Füge hier weitere Informationen über das Problem hinzu.
|
||||
|
||||
## 🔍 Browser-Console
|
||||
Falls vorhanden, füge relevante Console-Logs hinzu:
|
||||
```
|
||||
// Console-Logs hier einfügen
|
||||
```
|
||||
|
||||
## ⚡ Priorität
|
||||
Wie dringend ist dieser Bug?
|
||||
- [ ] 🔴 Kritisch (App funktioniert nicht)
|
||||
- [ ] 🟠 Hoch (Wichtige Funktion betroffen)
|
||||
- [ ] 🟡 Mittel (Störend, aber Workaround möglich)
|
||||
- [ ] 🟢 Niedrig (Kosmetisches Problem)
|
||||
|
||||
## 🤝 Bereitschaft zur Hilfe
|
||||
- [ ] Ich bin bereit, bei der Lösung zu helfen
|
||||
- [ ] Ich kann weitere Informationen bereitstellen
|
||||
- [ ] Ich kann das Problem testen, wenn es behoben ist
|
||||
167
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
167
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@ -0,0 +1,167 @@
|
||||
---
|
||||
name: ✨ Feature Request
|
||||
about: Schlage eine neue Funktion oder Verbesserung vor
|
||||
title: '[FEATURE] '
|
||||
labels: ['enhancement', 'needs-discussion']
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Feature-Beschreibung
|
||||
Eine klare und präzise Beschreibung der gewünschten Funktion.
|
||||
|
||||
## 🤔 Problem/Motivation
|
||||
Beschreibe das Problem, das diese Funktion lösen würde, oder die Motivation dahinter.
|
||||
**Beispiel:** "Ich bin frustriert, wenn [...] passiert, weil [...]"
|
||||
|
||||
## 💡 Vorgeschlagene Lösung
|
||||
Eine klare und präzise Beschreibung dessen, was du dir wünschst.
|
||||
|
||||
## 🔄 Alternativen
|
||||
Beschreibe alternative Lösungen oder Features, die du in Betracht gezogen hast.
|
||||
|
||||
## 👨👩👧👦 Zielgruppe
|
||||
Wer würde von dieser Funktion profitieren?
|
||||
- [ ] 👨👩👦 Eltern
|
||||
- [ ] 🧒 Kinder (3-7 Jahre)
|
||||
- [ ] 👦👧 Kinder (8-12 Jahre)
|
||||
- [ ] 👨👩👧👦 Ganze Familie
|
||||
- [ ] 👩🏫 Lehrer/Erzieher
|
||||
- [ ] 🔧 Entwickler
|
||||
|
||||
## 🎨 UI/UX Überlegungen
|
||||
Wie sollte diese Funktion aussehen und sich verhalten?
|
||||
|
||||
### Mockups/Wireframes
|
||||
<!-- Füge hier Bilder, Skizzen oder Links zu Mockups ein -->
|
||||
|
||||
### Interaktion
|
||||
- Wie sollten Benutzer mit der Funktion interagieren?
|
||||
- Welche Animationen oder Übergänge wären sinnvoll?
|
||||
- Sollte es Theme-spezifische Anpassungen geben?
|
||||
|
||||
## 🛠️ Technische Überlegungen
|
||||
|
||||
### Frontend
|
||||
- [ ] Neue React-Komponenten erforderlich
|
||||
- [ ] Material-UI Komponenten verwenden
|
||||
- [ ] Neue Animationen/Effekte
|
||||
- [ ] Theme-System Erweiterungen
|
||||
|
||||
### Backend
|
||||
- [ ] Neue API-Endpunkte
|
||||
- [ ] Datenbank-Schema Änderungen
|
||||
- [ ] Neue Firebase-Funktionen
|
||||
- [ ] Authentication/Authorization Änderungen
|
||||
|
||||
### Externe Services
|
||||
- [ ] Neue Dependencies
|
||||
- [ ] Third-Party APIs
|
||||
- [ ] Cloud-Services
|
||||
|
||||
## 📊 Erfolgs-Metriken
|
||||
Wie würdest du den Erfolg dieser Funktion messen?
|
||||
- [ ] Benutzer-Engagement
|
||||
- [ ] Feature-Adoption Rate
|
||||
- [ ] Benutzer-Feedback
|
||||
- [ ] Performance-Metriken
|
||||
- [ ] Andere: ___________
|
||||
|
||||
## 🎯 Priorität
|
||||
Wie wichtig ist diese Funktion für dich?
|
||||
- [ ] 🔴 Sehr hoch (Ohne diese Funktion ist die App nicht nutzbar)
|
||||
- [ ] 🟠 Hoch (Würde die App erheblich verbessern)
|
||||
- [ ] 🟡 Mittel (Nette Ergänzung)
|
||||
- [ ] 🟢 Niedrig (Nice-to-have)
|
||||
|
||||
## 🚀 Implementierungs-Vorschlag
|
||||
|
||||
### Phase 1 (MVP)
|
||||
- [ ] Grundfunktionalität
|
||||
- [ ] Basis-UI
|
||||
- [ ] Core-Features
|
||||
|
||||
### Phase 2 (Erweiterungen)
|
||||
- [ ] Erweiterte Features
|
||||
- [ ] Verbessertes UI/UX
|
||||
- [ ] Optimierungen
|
||||
|
||||
### Phase 3 (Polish)
|
||||
- [ ] Animationen
|
||||
- [ ] Theme-Integration
|
||||
- [ ] Performance-Optimierung
|
||||
|
||||
## 🎨 Theme-Integration
|
||||
Wie sollte diese Funktion in verschiedenen Themes aussehen?
|
||||
|
||||
### 🌲 Wald-Theme
|
||||
- Natürliche Elemente verwenden
|
||||
- Erdige Farben
|
||||
- Organische Formen
|
||||
|
||||
### 🚀 Weltraum-Theme
|
||||
- Futuristische Elemente
|
||||
- Neon-Akzente
|
||||
- Sci-Fi Animationen
|
||||
|
||||
### 🌊 Ozean-Theme
|
||||
- Unterwasser-Elemente
|
||||
- Blaue Farbpalette
|
||||
- Fließende Animationen
|
||||
|
||||
### 🌸 Natur-Theme
|
||||
- Blumen und Schmetterlinge
|
||||
- Pastellfarben
|
||||
- Sanfte Übergänge
|
||||
|
||||
## 📱 Plattform-Überlegungen
|
||||
- [ ] Desktop-optimiert
|
||||
- [ ] Tablet-freundlich
|
||||
- [ ] Mobile-first
|
||||
- [ ] PWA-Features
|
||||
- [ ] Offline-Funktionalität
|
||||
|
||||
## ♿ Accessibility
|
||||
- [ ] Screen-Reader kompatibel
|
||||
- [ ] Keyboard-Navigation
|
||||
- [ ] Hoher Kontrast
|
||||
- [ ] Große Touch-Targets
|
||||
- [ ] Einfache Sprache
|
||||
|
||||
## 🔒 Sicherheit & Datenschutz
|
||||
- [ ] Keine sensiblen Daten
|
||||
- [ ] COPPA-konform
|
||||
- [ ] GDPR-konform
|
||||
- [ ] Elterliche Kontrolle
|
||||
|
||||
## 📚 Dokumentation
|
||||
Welche Dokumentation wäre für diese Funktion erforderlich?
|
||||
- [ ] Benutzer-Anleitung
|
||||
- [ ] Entwickler-Dokumentation
|
||||
- [ ] API-Dokumentation
|
||||
- [ ] Tutorial/Onboarding
|
||||
|
||||
## 🤝 Bereitschaft zur Hilfe
|
||||
- [ ] Ich kann bei der Implementierung helfen
|
||||
- [ ] Ich kann beim Design helfen
|
||||
- [ ] Ich kann beim Testen helfen
|
||||
- [ ] Ich kann bei der Dokumentation helfen
|
||||
|
||||
## 📝 Zusätzliche Informationen
|
||||
Füge hier weitere relevante Informationen, Links oder Referenzen hinzu.
|
||||
|
||||
### Referenzen
|
||||
- Ähnliche Features in anderen Apps
|
||||
- Design-Inspirationen
|
||||
- Technische Artikel
|
||||
- Benutzer-Feedback
|
||||
|
||||
### Abhängigkeiten
|
||||
- Welche anderen Features müssen zuerst implementiert werden?
|
||||
- Gibt es Konflikte mit bestehenden Features?
|
||||
|
||||
---
|
||||
|
||||
**Vielen Dank für deinen Vorschlag! 🎉**
|
||||
|
||||
*Das ToDo Kids Team wird deinen Feature-Request prüfen und sich bei Fragen melden.*
|
||||
198
.github/pull_request_template.md
vendored
Normal file
198
.github/pull_request_template.md
vendored
Normal file
@ -0,0 +1,198 @@
|
||||
## 📝 Beschreibung
|
||||
|
||||
<!-- Beschreibe deine Änderungen in wenigen Sätzen -->
|
||||
|
||||
## 🎯 Art der Änderung
|
||||
|
||||
<!-- Markiere die zutreffenden Optionen -->
|
||||
- [ ] 🐛 Bug Fix (non-breaking change, der ein Problem behebt)
|
||||
- [ ] ✨ Neues Feature (non-breaking change, das Funktionalität hinzufügt)
|
||||
- [ ] 💥 Breaking Change (Fix oder Feature, das bestehende Funktionalität verändert)
|
||||
- [ ] 📚 Dokumentation (Änderungen an der Dokumentation)
|
||||
- [ ] 🎨 Style/UI (Änderungen, die das Aussehen betreffen)
|
||||
- [ ] ♻️ Refactoring (Code-Änderungen, die weder Bugs beheben noch Features hinzufügen)
|
||||
- [ ] ⚡ Performance (Änderungen, die die Performance verbessern)
|
||||
- [ ] 🧪 Tests (Hinzufügen oder Korrigieren von Tests)
|
||||
- [ ] 🔧 Chore (Änderungen am Build-Prozess oder Hilfswerkzeugen)
|
||||
|
||||
## 🔗 Verwandte Issues
|
||||
|
||||
<!-- Verlinke verwandte Issues -->
|
||||
Fixes #(issue_number)
|
||||
Closes #(issue_number)
|
||||
Related to #(issue_number)
|
||||
|
||||
## 🧪 Tests
|
||||
|
||||
<!-- Beschreibe die Tests, die du durchgeführt hast -->
|
||||
|
||||
### ✅ Test-Checklist
|
||||
- [ ] Bestehende Tests laufen durch
|
||||
- [ ] Neue Tests hinzugefügt (falls nötig)
|
||||
- [ ] Manuelle Tests durchgeführt
|
||||
- [ ] Edge Cases getestet
|
||||
|
||||
### 🖥️ Getestete Umgebungen
|
||||
- [ ] Chrome (Desktop)
|
||||
- [ ] Firefox (Desktop)
|
||||
- [ ] Safari (Desktop)
|
||||
- [ ] Edge (Desktop)
|
||||
- [ ] Chrome (Mobile)
|
||||
- [ ] Safari (Mobile)
|
||||
|
||||
### 🎨 Getestete Themes
|
||||
- [ ] 🌈 Buntes Theme
|
||||
- [ ] 🌲 Wald-Theme
|
||||
- [ ] 🚀 Weltraum-Theme
|
||||
- [ ] 🌊 Ozean-Theme
|
||||
- [ ] 🌸 Natur-Theme
|
||||
|
||||
## 📱 Screenshots/Videos
|
||||
|
||||
<!-- Füge Screenshots oder Videos hinzu, besonders bei UI-Änderungen -->
|
||||
|
||||
### Vorher
|
||||
<!-- Screenshot/Video des alten Zustands -->
|
||||
|
||||
### Nachher
|
||||
<!-- Screenshot/Video des neuen Zustands -->
|
||||
|
||||
## 🔍 Code-Änderungen
|
||||
|
||||
### 📁 Geänderte Dateien
|
||||
<!-- Liste der wichtigsten geänderten Dateien -->
|
||||
- `path/to/file1.js` - Beschreibung der Änderung
|
||||
- `path/to/file2.css` - Beschreibung der Änderung
|
||||
|
||||
### 🏗️ Architektur-Änderungen
|
||||
<!-- Beschreibe größere Architektur-Änderungen -->
|
||||
- [ ] Neue Komponenten hinzugefügt
|
||||
- [ ] Bestehende Komponenten geändert
|
||||
- [ ] API-Änderungen
|
||||
- [ ] Datenbank-Schema Änderungen
|
||||
- [ ] Neue Dependencies
|
||||
|
||||
## ⚡ Performance-Impact
|
||||
|
||||
<!-- Beschreibe den Performance-Impact deiner Änderungen -->
|
||||
- [ ] Keine Performance-Auswirkungen
|
||||
- [ ] Performance-Verbesserung
|
||||
- [ ] Mögliche Performance-Verschlechterung (erkläre warum es notwendig ist)
|
||||
|
||||
### 📊 Bundle-Größe
|
||||
- [ ] Bundle-Größe unverändert
|
||||
- [ ] Bundle-Größe reduziert
|
||||
- [ ] Bundle-Größe erhöht (Begründung: ___________)
|
||||
|
||||
## 🔒 Sicherheits-Überlegungen
|
||||
|
||||
<!-- Beschreibe Sicherheitsaspekte deiner Änderungen -->
|
||||
- [ ] Keine Sicherheitsauswirkungen
|
||||
- [ ] Sicherheitsverbesserung
|
||||
- [ ] Neue Sicherheitsüberlegungen (erkläre)
|
||||
|
||||
### 🛡️ Sicherheits-Checklist
|
||||
- [ ] Input-Validierung implementiert
|
||||
- [ ] XSS-Schutz beachtet
|
||||
- [ ] CSRF-Schutz beachtet
|
||||
- [ ] Keine sensiblen Daten in Logs
|
||||
- [ ] Proper Error Handling
|
||||
|
||||
## ♿ Accessibility
|
||||
|
||||
<!-- Beschreibe Accessibility-Aspekte -->
|
||||
- [ ] Keine Accessibility-Auswirkungen
|
||||
- [ ] Accessibility-Verbesserung
|
||||
- [ ] Neue Accessibility-Features
|
||||
|
||||
### 🎯 Accessibility-Checklist
|
||||
- [ ] ARIA-Labels hinzugefügt/aktualisiert
|
||||
- [ ] Keyboard-Navigation funktioniert
|
||||
- [ ] Screen-Reader kompatibel
|
||||
- [ ] Ausreichender Farbkontrast
|
||||
- [ ] Focus-Management implementiert
|
||||
|
||||
## 👨👩👧👦 Kinder-Freundlichkeit
|
||||
|
||||
<!-- Spezielle Überlegungen für Kinder -->
|
||||
- [ ] Kindgerechte Sprache verwendet
|
||||
- [ ] Einfache Navigation
|
||||
- [ ] Große Touch-Targets
|
||||
- [ ] Keine komplexen Interaktionen
|
||||
- [ ] Positive Verstärkung
|
||||
|
||||
## 📚 Dokumentation
|
||||
|
||||
<!-- Dokumentations-Änderungen -->
|
||||
- [ ] README aktualisiert
|
||||
- [ ] Code-Kommentare hinzugefügt
|
||||
- [ ] API-Dokumentation aktualisiert
|
||||
- [ ] CHANGELOG aktualisiert
|
||||
- [ ] Keine Dokumentation erforderlich
|
||||
|
||||
## 🚀 Deployment-Überlegungen
|
||||
|
||||
<!-- Spezielle Deployment-Anforderungen -->
|
||||
- [ ] Keine speziellen Anforderungen
|
||||
- [ ] Environment Variables hinzugefügt/geändert
|
||||
- [ ] Database Migration erforderlich
|
||||
- [ ] Neue Dependencies installieren
|
||||
- [ ] Build-Prozess geändert
|
||||
|
||||
### 🔧 Deployment-Schritte
|
||||
<!-- Falls spezielle Schritte erforderlich sind -->
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## ✅ Checklist
|
||||
|
||||
<!-- Allgemeine Checklist vor dem Merge -->
|
||||
- [ ] Code folgt den Projekt-Konventionen
|
||||
- [ ] Self-Review durchgeführt
|
||||
- [ ] Code-Kommentare hinzugefügt (wo nötig)
|
||||
- [ ] Entsprechende Dokumentation aktualisiert
|
||||
- [ ] Keine neuen Warnungen/Errors
|
||||
- [ ] Tests hinzugefügt/aktualisiert
|
||||
- [ ] Alle Tests bestehen
|
||||
- [ ] Funktioniert in verschiedenen Browsern
|
||||
- [ ] Responsive Design getestet
|
||||
- [ ] Performance-Impact überprüft
|
||||
|
||||
## 🤝 Review-Anfragen
|
||||
|
||||
<!-- Spezielle Punkte, auf die Reviewer achten sollen -->
|
||||
- [ ] Code-Qualität
|
||||
- [ ] Performance
|
||||
- [ ] Sicherheit
|
||||
- [ ] Accessibility
|
||||
- [ ] UI/UX
|
||||
- [ ] Dokumentation
|
||||
|
||||
### 🎯 Fokus-Bereiche
|
||||
<!-- Bereiche, die besondere Aufmerksamkeit benötigen -->
|
||||
-
|
||||
-
|
||||
-
|
||||
|
||||
## 📝 Zusätzliche Notizen
|
||||
|
||||
<!-- Weitere Informationen für Reviewer -->
|
||||
|
||||
### 🔮 Zukünftige Verbesserungen
|
||||
<!-- Ideen für zukünftige Iterationen -->
|
||||
-
|
||||
-
|
||||
-
|
||||
|
||||
### 🤔 Offene Fragen
|
||||
<!-- Fragen, die während der Entwicklung aufgekommen sind -->
|
||||
-
|
||||
-
|
||||
-
|
||||
|
||||
---
|
||||
|
||||
**Vielen Dank für deinen Beitrag! 🎉**
|
||||
|
||||
/cc @maintainer-username
|
||||
461
.github/workflows/ci.yml
vendored
Normal file
461
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,461 @@
|
||||
name: 🚀 Continuous Integration
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
pull_request:
|
||||
branches: [ main, develop ]
|
||||
|
||||
jobs:
|
||||
# 🧪 Backend Tests
|
||||
backend-tests:
|
||||
name: 🔧 Backend Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [18.x, 20.x]
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🟢 Setup Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
|
||||
- name: 📦 Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: 🔍 Lint backend code
|
||||
run: npm run lint || echo "Linting not configured"
|
||||
|
||||
- name: 🧪 Run backend tests
|
||||
run: npm test || echo "Tests not configured"
|
||||
env:
|
||||
NODE_ENV: test
|
||||
JWT_SECRET: test-secret-key-for-ci
|
||||
|
||||
- name: 🔒 Security audit
|
||||
run: npm audit --audit-level=high
|
||||
|
||||
# 🎨 Frontend Tests
|
||||
frontend-tests:
|
||||
name: 🎨 Frontend Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [18.x, 20.x]
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🟢 Setup Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'client/package-lock.json'
|
||||
|
||||
- name: 📦 Install frontend dependencies
|
||||
run: |
|
||||
cd client
|
||||
npm ci
|
||||
|
||||
- name: 🔍 Lint frontend code
|
||||
run: |
|
||||
cd client
|
||||
npm run lint || echo "Linting not configured"
|
||||
|
||||
- name: 🧪 Run frontend tests
|
||||
run: |
|
||||
cd client
|
||||
npm test -- --coverage --watchAll=false
|
||||
env:
|
||||
CI: true
|
||||
REACT_APP_API_URL: http://localhost:5000
|
||||
|
||||
- name: 📊 Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./client/coverage/lcov.info
|
||||
flags: frontend
|
||||
name: frontend-coverage
|
||||
|
||||
- name: 🔒 Security audit
|
||||
run: |
|
||||
cd client
|
||||
npm audit --audit-level=high
|
||||
|
||||
# 🏗️ Build Tests
|
||||
build-tests:
|
||||
name: 🏗️ Build Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: [backend-tests, frontend-tests]
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🟢 Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
cache: 'npm'
|
||||
|
||||
- name: 📦 Install all dependencies
|
||||
run: |
|
||||
npm ci
|
||||
cd client && npm ci
|
||||
|
||||
- name: 🏗️ Build frontend
|
||||
run: |
|
||||
cd client
|
||||
npm run build
|
||||
env:
|
||||
REACT_APP_API_URL: https://todo-kids-api.herokuapp.com
|
||||
GENERATE_SOURCEMAP: false
|
||||
|
||||
- name: 📏 Check bundle size
|
||||
run: |
|
||||
cd client
|
||||
npx bundlesize || echo "Bundle size check not configured"
|
||||
|
||||
- name: 💾 Upload build artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: build-files
|
||||
path: client/build/
|
||||
retention-days: 7
|
||||
|
||||
# 🔍 Code Quality
|
||||
code-quality:
|
||||
name: 🔍 Code Quality
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Shallow clones should be disabled for better analysis
|
||||
|
||||
- name: 🟢 Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
cache: 'npm'
|
||||
|
||||
- name: 📦 Install dependencies
|
||||
run: |
|
||||
npm ci
|
||||
cd client && npm ci
|
||||
|
||||
- name: 🔍 SonarCloud Scan
|
||||
uses: SonarSource/sonarcloud-github-action@master
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
continue-on-error: true
|
||||
|
||||
- name: 🛡️ CodeQL Analysis
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: javascript
|
||||
continue-on-error: true
|
||||
|
||||
- name: 🛡️ Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
continue-on-error: true
|
||||
|
||||
# 🔒 Security Checks
|
||||
security-checks:
|
||||
name: 🔒 Security Checks
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🟢 Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
cache: 'npm'
|
||||
|
||||
- name: 📦 Install dependencies
|
||||
run: |
|
||||
npm ci
|
||||
cd client && npm ci
|
||||
|
||||
- name: 🛡️ Run Snyk security scan
|
||||
uses: snyk/actions/node@master
|
||||
env:
|
||||
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
||||
with:
|
||||
args: --severity-threshold=high
|
||||
continue-on-error: true
|
||||
|
||||
- name: 🔍 Dependency Review
|
||||
uses: actions/dependency-review-action@v3
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
# 🎨 Theme Tests
|
||||
theme-tests:
|
||||
name: 🎨 Theme Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🟢 Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'client/package-lock.json'
|
||||
|
||||
- name: 📦 Install dependencies
|
||||
run: |
|
||||
cd client
|
||||
npm ci
|
||||
|
||||
- name: 🎨 Test all themes
|
||||
run: |
|
||||
cd client
|
||||
npm test -- --testNamePattern="theme" --watchAll=false || echo "Theme tests not configured"
|
||||
|
||||
- name: 🖼️ Visual regression tests
|
||||
run: |
|
||||
cd client
|
||||
npm run test:visual || echo "Visual tests not configured"
|
||||
continue-on-error: true
|
||||
|
||||
# ♿ Accessibility Tests
|
||||
accessibility-tests:
|
||||
name: ♿ Accessibility Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🟢 Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'client/package-lock.json'
|
||||
|
||||
- name: 📦 Install dependencies
|
||||
run: |
|
||||
cd client
|
||||
npm ci
|
||||
|
||||
- name: 🏗️ Build app
|
||||
run: |
|
||||
cd client
|
||||
npm run build
|
||||
env:
|
||||
REACT_APP_API_URL: http://localhost:5000
|
||||
|
||||
- name: ♿ Run accessibility tests
|
||||
run: |
|
||||
cd client
|
||||
npx serve -s build -p 3000 &
|
||||
sleep 5
|
||||
npx axe-cli http://localhost:3000 || echo "Accessibility tests not configured"
|
||||
continue-on-error: true
|
||||
|
||||
# 📱 Cross-Browser Tests
|
||||
cross-browser-tests:
|
||||
name: 📱 Cross-Browser Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🟢 Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'client/package-lock.json'
|
||||
|
||||
- name: 📦 Install dependencies
|
||||
run: |
|
||||
cd client
|
||||
npm ci
|
||||
|
||||
- name: 🏗️ Build app
|
||||
run: |
|
||||
cd client
|
||||
npm run build
|
||||
env:
|
||||
REACT_APP_API_URL: http://localhost:5000
|
||||
|
||||
- name: 🌐 Run cross-browser tests
|
||||
run: |
|
||||
cd client
|
||||
npm run test:e2e || echo "E2E tests not configured"
|
||||
continue-on-error: true
|
||||
|
||||
# 📊 Performance Tests
|
||||
performance-tests:
|
||||
name: 📊 Performance Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-tests]
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🟢 Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'client/package-lock.json'
|
||||
|
||||
- name: 📦 Install dependencies
|
||||
run: |
|
||||
cd client
|
||||
npm ci
|
||||
|
||||
- name: 💾 Download build artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: build-files
|
||||
path: client/build/
|
||||
|
||||
- name: 📊 Lighthouse CI
|
||||
run: |
|
||||
npm install -g @lhci/cli@0.12.x
|
||||
cd client
|
||||
npx serve -s build -p 3000 &
|
||||
sleep 5
|
||||
lhci autorun || echo "Lighthouse CI not configured"
|
||||
continue-on-error: true
|
||||
|
||||
# 🎯 Integration Tests
|
||||
integration-tests:
|
||||
name: 🎯 Integration Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: [backend-tests, frontend-tests]
|
||||
|
||||
services:
|
||||
mongodb:
|
||||
image: mongo:5
|
||||
env:
|
||||
MONGO_INITDB_ROOT_USERNAME: test
|
||||
MONGO_INITDB_ROOT_PASSWORD: test
|
||||
options: >-
|
||||
--health-cmd mongo
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 27017:27017
|
||||
|
||||
steps:
|
||||
- name: 📥 Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🟢 Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
cache: 'npm'
|
||||
|
||||
- name: 📦 Install all dependencies
|
||||
run: |
|
||||
npm ci
|
||||
cd client && npm ci
|
||||
|
||||
- name: 🚀 Start backend server
|
||||
run: |
|
||||
npm start &
|
||||
sleep 10
|
||||
env:
|
||||
NODE_ENV: test
|
||||
MONGODB_URI: mongodb://test:test@localhost:27017/todo-kids-test?authSource=admin
|
||||
JWT_SECRET: test-secret-key-for-ci
|
||||
PORT: 5000
|
||||
|
||||
- name: 🏗️ Build and start frontend
|
||||
run: |
|
||||
cd client
|
||||
npm run build
|
||||
npx serve -s build -p 3000 &
|
||||
sleep 5
|
||||
env:
|
||||
REACT_APP_API_URL: http://localhost:5000
|
||||
|
||||
- name: 🧪 Run integration tests
|
||||
run: |
|
||||
npm run test:integration || echo "Integration tests not configured"
|
||||
env:
|
||||
API_URL: http://localhost:5000
|
||||
FRONTEND_URL: http://localhost:3000
|
||||
|
||||
# ✅ All Tests Passed
|
||||
all-tests-passed:
|
||||
name: ✅ All Tests Passed
|
||||
runs-on: ubuntu-latest
|
||||
needs: [
|
||||
backend-tests,
|
||||
frontend-tests,
|
||||
build-tests,
|
||||
code-quality,
|
||||
security-checks,
|
||||
theme-tests,
|
||||
accessibility-tests,
|
||||
cross-browser-tests,
|
||||
performance-tests,
|
||||
integration-tests
|
||||
]
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
- name: ✅ Check all jobs
|
||||
run: |
|
||||
echo "Backend Tests: ${{ needs.backend-tests.result }}"
|
||||
echo "Frontend Tests: ${{ needs.frontend-tests.result }}"
|
||||
echo "Build Tests: ${{ needs.build-tests.result }}"
|
||||
echo "Code Quality: ${{ needs.code-quality.result }}"
|
||||
echo "Security Checks: ${{ needs.security-checks.result }}"
|
||||
echo "Theme Tests: ${{ needs.theme-tests.result }}"
|
||||
echo "Accessibility Tests: ${{ needs.accessibility-tests.result }}"
|
||||
echo "Cross-Browser Tests: ${{ needs.cross-browser-tests.result }}"
|
||||
echo "Performance Tests: ${{ needs.performance-tests.result }}"
|
||||
echo "Integration Tests: ${{ needs.integration-tests.result }}"
|
||||
|
||||
if [[ "${{ needs.backend-tests.result }}" == "failure" ||
|
||||
"${{ needs.frontend-tests.result }}" == "failure" ||
|
||||
"${{ needs.build-tests.result }}" == "failure" ]]; then
|
||||
echo "❌ Critical tests failed!"
|
||||
exit 1
|
||||
else
|
||||
echo "✅ All critical tests passed!"
|
||||
fi
|
||||
|
||||
- name: 🎉 Success notification
|
||||
if: success()
|
||||
run: echo "🎉 All tests passed! Ready for deployment."
|
||||
|
||||
- name: 💬 Comment on PR
|
||||
if: github.event_name == 'pull_request' && success()
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '🎉 All CI checks passed! This PR is ready for review.'
|
||||
})
|
||||
143
.gitignore
vendored
Normal file
143
.gitignore
vendored
Normal file
@ -0,0 +1,143 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
client/node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
client/.env
|
||||
client/.env.local
|
||||
client/.env.development.local
|
||||
client/.env.test.local
|
||||
client/.env.production.local
|
||||
|
||||
# Build outputs
|
||||
client/build/
|
||||
client/dist/
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
public
|
||||
|
||||
# Storybook build outputs
|
||||
.out
|
||||
.storybook-out
|
||||
|
||||
# Temporary folders
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Firebase
|
||||
.firebase/
|
||||
.firebaserc
|
||||
firebase-debug.log
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Backup files
|
||||
*.backup
|
||||
*.bak
|
||||
|
||||
# Package lock files (optional - uncomment if you want to ignore them)
|
||||
# package-lock.json
|
||||
# client/package-lock.json
|
||||
|
||||
# Local development files
|
||||
.local
|
||||
.cache
|
||||
|
||||
# Testing
|
||||
/coverage
|
||||
|
||||
# Production
|
||||
/build
|
||||
|
||||
# Misc
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
166
CHANGELOG.md
Normal file
166
CHANGELOG.md
Normal file
@ -0,0 +1,166 @@
|
||||
# 📝 Changelog
|
||||
|
||||
Alle wichtigen Änderungen an diesem Projekt werden in dieser Datei dokumentiert.
|
||||
|
||||
Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.0.0/),
|
||||
und dieses Projekt folgt [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Geplant
|
||||
- [ ] Offline-Modus
|
||||
- [ ] Push-Benachrichtigungen
|
||||
- [ ] Erweiterte Statistiken
|
||||
- [ ] Mehr Belohnungs-Optionen
|
||||
- [ ] Kalender-Integration
|
||||
|
||||
## [1.0.0] - 2024-01-XX
|
||||
|
||||
### ✨ Hinzugefügt
|
||||
- **Basis-Funktionalität**
|
||||
- Benutzer-Authentifizierung (Eltern/Kinder)
|
||||
- Aufgaben erstellen, bearbeiten, löschen
|
||||
- Belohnungssystem mit Punkten
|
||||
- Familien-Management
|
||||
|
||||
- **Theme-System**
|
||||
- 🌈 Buntes Theme (Standard)
|
||||
- 🌲 Wald-Theme mit animierten Bäumen und Tieren
|
||||
- 🚀 Weltraum-Theme mit Sternen und Planeten
|
||||
- 🌊 Ozean-Theme mit Meeresbewohnern
|
||||
- 🌸 Natur-Theme mit Blumen und Schmetterlingen
|
||||
|
||||
- **UI/UX Features**
|
||||
- Responsive Design für alle Geräte
|
||||
- Material-UI Komponenten
|
||||
- Smooth Animationen
|
||||
- Konfetti-Effekte bei Erfolgen
|
||||
- Intuitive Navigation
|
||||
|
||||
- **Sicherheit**
|
||||
- Firebase-Authentifizierung
|
||||
- Sichere Datenübertragung
|
||||
- Eingabe-Validierung
|
||||
- Schutz vor XSS/CSRF
|
||||
|
||||
### 🎨 Theme-Details
|
||||
|
||||
#### Wald-Theme 🌲
|
||||
- Himmel-zu-Wald Farbverlauf
|
||||
- Animierte Bäume mit "forest-sway" Animation
|
||||
- Waldtiere (Eichhörnchen, Vögel, Rehe)
|
||||
- Natürliche Büsche und Vegetation
|
||||
- Holz-Design für Karten und Buttons
|
||||
- Organische Formen und erdige Farben
|
||||
|
||||
#### Weltraum-Theme 🚀
|
||||
- Dunkler Weltraum-Hintergrund
|
||||
- Funkelnde Sterne (statisch)
|
||||
- Futuristische UI-Elemente
|
||||
- Neon-Akzente
|
||||
- Sci-Fi Typografie
|
||||
- Glowing-Effekte
|
||||
|
||||
#### Ozean-Theme 🌊
|
||||
- Unterwasser-Atmosphäre
|
||||
- Animierte Meeresbewohner
|
||||
- Wellen-Animationen
|
||||
- Blaue Farbpalette
|
||||
- Korallen und Seegras
|
||||
- Bubble-Effekte
|
||||
|
||||
#### Natur-Theme 🌸
|
||||
- Frühlingshafte Farben
|
||||
- Blumen und Schmetterlinge
|
||||
- Sanfte Animationen
|
||||
- Pastelltöne
|
||||
- Organische Formen
|
||||
- Natürliche Texturen
|
||||
|
||||
### 🛠️ Technische Implementierung
|
||||
- **Frontend**: React 19.1.1, Material-UI 7.2.0
|
||||
- **Backend**: Node.js, Express.js
|
||||
- **Database**: Firebase Firestore
|
||||
- **Authentication**: Firebase Auth
|
||||
- **Styling**: Emotion, CSS-in-JS
|
||||
- **Animationen**: CSS Keyframes, SVG
|
||||
- **Build**: Create React App
|
||||
- **Deployment**: Heroku-ready
|
||||
|
||||
### 📱 Unterstützte Plattformen
|
||||
- ✅ Desktop (Chrome, Firefox, Safari, Edge)
|
||||
- ✅ Tablet (iOS Safari, Android Chrome)
|
||||
- ✅ Mobile (iOS Safari, Android Chrome)
|
||||
- ✅ PWA-fähig
|
||||
|
||||
### 🔧 Entwickler-Features
|
||||
- ESLint-Konfiguration
|
||||
- Prettier-Integration
|
||||
- Hot-Reload Development
|
||||
- Environment-basierte Konfiguration
|
||||
- Modulare Theme-Architektur
|
||||
- Komponenten-basierte Struktur
|
||||
|
||||
### 📚 Dokumentation
|
||||
- Ausführliche README.md
|
||||
- Beitragsrichtlinien (CONTRIBUTING.md)
|
||||
- Deployment-Guide (DEPLOYMENT.md)
|
||||
- Code-Kommentare
|
||||
- Theme-Entwicklungsguide
|
||||
|
||||
### 🧪 Testing
|
||||
- React Testing Library Setup
|
||||
- Jest-Konfiguration
|
||||
- Component Tests
|
||||
- Integration Tests
|
||||
- E2E Test-Vorbereitung
|
||||
|
||||
### 🚀 Performance
|
||||
- Code-Splitting
|
||||
- Lazy Loading
|
||||
- Optimierte SVG-Animationen
|
||||
- Minimierte Bundle-Größe
|
||||
- Efficient Re-rendering
|
||||
|
||||
### ♿ Accessibility
|
||||
- ARIA-Labels
|
||||
- Keyboard-Navigation
|
||||
- Screen-Reader Support
|
||||
- Kontrast-optimierte Farben
|
||||
- Focus-Management
|
||||
|
||||
## [0.9.0] - 2024-01-XX (Beta)
|
||||
|
||||
### ✨ Hinzugefügt
|
||||
- Beta-Version der Kern-Funktionalität
|
||||
- Grundlegendes Theme-System
|
||||
- Basis-Authentifizierung
|
||||
|
||||
### 🐛 Behoben
|
||||
- Initiale Bug-Fixes
|
||||
- Performance-Optimierungen
|
||||
|
||||
## [0.1.0] - 2024-01-XX (Alpha)
|
||||
|
||||
### ✨ Hinzugefügt
|
||||
- Projekt-Setup
|
||||
- Grundlegende Struktur
|
||||
- Erste Prototypen
|
||||
|
||||
---
|
||||
|
||||
## 📋 Legende
|
||||
|
||||
- ✨ **Hinzugefügt**: Neue Features
|
||||
- 🔄 **Geändert**: Änderungen an bestehenden Features
|
||||
- ❌ **Entfernt**: Entfernte Features
|
||||
- 🐛 **Behoben**: Bug-Fixes
|
||||
- 🔒 **Sicherheit**: Sicherheits-Updates
|
||||
- 📚 **Dokumentation**: Dokumentations-Änderungen
|
||||
- 🎨 **Design**: UI/UX Verbesserungen
|
||||
- ⚡ **Performance**: Performance-Verbesserungen
|
||||
- 🧪 **Testing**: Test-bezogene Änderungen
|
||||
|
||||
---
|
||||
|
||||
**Hinweis**: Dieses Projekt befindet sich in aktiver Entwicklung. Features und APIs können sich ändern.
|
||||
240
CONTRIBUTING.md
Normal file
240
CONTRIBUTING.md
Normal file
@ -0,0 +1,240 @@
|
||||
# 🤝 Beitragen zu ToDo Kids
|
||||
|
||||
Vielen Dank für dein Interesse, zu ToDo Kids beizutragen! Wir freuen uns über jede Art von Beitrag.
|
||||
|
||||
## 🎯 Arten von Beiträgen
|
||||
|
||||
### 🐛 Bug Reports
|
||||
- Verwende die GitHub Issues
|
||||
- Beschreibe das Problem detailliert
|
||||
- Füge Screenshots hinzu, wenn möglich
|
||||
- Gib deine Browser-/System-Informationen an
|
||||
|
||||
### 💡 Feature Requests
|
||||
- Öffne ein Issue mit dem Label "enhancement"
|
||||
- Erkläre den Nutzen für Kinder und Familien
|
||||
- Beschreibe die gewünschte Funktionalität
|
||||
|
||||
### 🎨 Theme-Beiträge
|
||||
- Neue Themes sind sehr willkommen!
|
||||
- Achte auf kindgerechte Farben und Designs
|
||||
- Teste die Accessibility
|
||||
- Füge SVG-Animationen hinzu (optional)
|
||||
|
||||
### 📝 Dokumentation
|
||||
- Verbesserungen der README
|
||||
- Code-Kommentare
|
||||
- Tutorials und Guides
|
||||
|
||||
## 🚀 Entwicklungs-Workflow
|
||||
|
||||
### 1. Repository forken
|
||||
```bash
|
||||
# Fork das Repository auf GitHub
|
||||
# Dann klone dein Fork
|
||||
git clone https://github.com/DEIN-USERNAME/todo-kids.git
|
||||
cd todo-kids
|
||||
```
|
||||
|
||||
### 2. Development Environment einrichten
|
||||
```bash
|
||||
# Dependencies installieren
|
||||
npm install
|
||||
cd client && npm install && cd ..
|
||||
|
||||
# Environment Variables kopieren
|
||||
cp .env.example .env
|
||||
cp client/.env.example client/.env
|
||||
|
||||
# Firebase konfigurieren (siehe FIREBASE_SETUP.md)
|
||||
```
|
||||
|
||||
### 3. Feature Branch erstellen
|
||||
```bash
|
||||
git checkout -b feature/amazing-new-feature
|
||||
# oder
|
||||
git checkout -b bugfix/fix-important-bug
|
||||
# oder
|
||||
git checkout -b theme/new-awesome-theme
|
||||
```
|
||||
|
||||
### 4. Entwickeln und Testen
|
||||
```bash
|
||||
# Backend starten
|
||||
npm run dev
|
||||
|
||||
# Frontend starten (neues Terminal)
|
||||
cd client
|
||||
npm start
|
||||
|
||||
# Tests ausführen
|
||||
npm test
|
||||
cd client && npm test
|
||||
```
|
||||
|
||||
### 5. Code Style beachten
|
||||
|
||||
#### JavaScript/React
|
||||
- Verwende ESLint-Konfiguration
|
||||
- Funktionale Komponenten mit Hooks
|
||||
- Aussagekräftige Variablennamen
|
||||
- Kommentare für komplexe Logik
|
||||
|
||||
#### CSS/Styling
|
||||
- Material-UI Theming verwenden
|
||||
- Responsive Design beachten
|
||||
- Accessibility-Standards einhalten
|
||||
|
||||
#### Git Commits
|
||||
```bash
|
||||
# Gute Commit-Messages
|
||||
git commit -m "feat: add forest theme with animated trees"
|
||||
git commit -m "fix: resolve login issue for child accounts"
|
||||
git commit -m "docs: update installation instructions"
|
||||
```
|
||||
|
||||
### 6. Pull Request erstellen
|
||||
|
||||
#### Vor dem PR
|
||||
- [ ] Code funktioniert lokal
|
||||
- [ ] Tests laufen durch
|
||||
- [ ] Keine Console-Errors
|
||||
- [ ] Responsive Design getestet
|
||||
- [ ] Accessibility überprüft
|
||||
|
||||
#### PR-Beschreibung
|
||||
```markdown
|
||||
## 📝 Beschreibung
|
||||
Kurze Beschreibung der Änderungen
|
||||
|
||||
## 🎯 Art der Änderung
|
||||
- [ ] Bug Fix
|
||||
- [ ] Neues Feature
|
||||
- [ ] Breaking Change
|
||||
- [ ] Dokumentation
|
||||
|
||||
## 🧪 Tests
|
||||
- [ ] Bestehende Tests laufen durch
|
||||
- [ ] Neue Tests hinzugefügt (falls nötig)
|
||||
|
||||
## 📱 Screenshots
|
||||
(Falls UI-Änderungen)
|
||||
|
||||
## ✅ Checklist
|
||||
- [ ] Code folgt den Style-Guidelines
|
||||
- [ ] Self-Review durchgeführt
|
||||
- [ ] Kommentare hinzugefügt
|
||||
- [ ] Dokumentation aktualisiert
|
||||
```
|
||||
|
||||
## 🎨 Theme-Entwicklung
|
||||
|
||||
### Neue Themes erstellen
|
||||
|
||||
1. **Theme-Datei erweitern**
|
||||
```javascript
|
||||
// In client/src/themes/childThemes.js
|
||||
const myAwesomeTheme = createTheme({
|
||||
...baseTheme,
|
||||
palette: {
|
||||
// Deine Farben
|
||||
},
|
||||
themeIcon: '🎭',
|
||||
components: {
|
||||
// Deine Styling-Overrides
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
2. **SVG-Animationen hinzufügen**
|
||||
```javascript
|
||||
backgroundImage: 'url("data:image/svg+xml,...")',
|
||||
animation: 'my-animation 10s ease-in-out infinite',
|
||||
```
|
||||
|
||||
3. **Theme registrieren**
|
||||
```javascript
|
||||
export const childThemes = {
|
||||
// ... bestehende themes
|
||||
myAwesome: myAwesomeTheme,
|
||||
};
|
||||
```
|
||||
|
||||
### Theme-Guidelines
|
||||
- **Kindgerecht**: Helle, freundliche Farben
|
||||
- **Accessibility**: Ausreichender Kontrast
|
||||
- **Performance**: Optimierte SVGs
|
||||
- **Konsistenz**: Einheitliches Design
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
### Debugging-Tipps
|
||||
```bash
|
||||
# Browser DevTools verwenden
|
||||
# React DevTools installieren
|
||||
# Console-Logs überprüfen
|
||||
|
||||
# Backend-Logs
|
||||
npm run dev # Zeigt Server-Logs
|
||||
|
||||
# Frontend-Logs
|
||||
# Browser Console öffnen
|
||||
```
|
||||
|
||||
### Häufige Probleme
|
||||
- **Firebase-Konfiguration**: Überprüfe .env-Dateien
|
||||
- **CORS-Errors**: Backend/Frontend URLs prüfen
|
||||
- **Theme-Probleme**: Browser-Cache leeren
|
||||
|
||||
## 📋 Code Review Process
|
||||
|
||||
### Was wir überprüfen
|
||||
- **Funktionalität**: Arbeitet der Code wie erwartet?
|
||||
- **Code Quality**: Ist der Code sauber und verständlich?
|
||||
- **Performance**: Gibt es Performance-Probleme?
|
||||
- **Security**: Sind Sicherheitsaspekte beachtet?
|
||||
- **UX**: Ist die Benutzererfahrung gut?
|
||||
|
||||
### Review-Feedback
|
||||
- Konstruktives Feedback geben
|
||||
- Verbesserungsvorschläge machen
|
||||
- Positive Aspekte hervorheben
|
||||
- Bei Fragen nachfragen
|
||||
|
||||
## 🎯 Prioritäten
|
||||
|
||||
### Hoch
|
||||
- Sicherheitslücken
|
||||
- Kritische Bugs
|
||||
- Accessibility-Verbesserungen
|
||||
|
||||
### Mittel
|
||||
- Neue Features
|
||||
- Performance-Optimierungen
|
||||
- UI/UX-Verbesserungen
|
||||
|
||||
### Niedrig
|
||||
- Code-Refactoring
|
||||
- Dokumentation
|
||||
- Nice-to-have Features
|
||||
|
||||
## 🏆 Anerkennung
|
||||
|
||||
Alle Beitragenden werden in der README erwähnt. Besonders wertvolle Beiträge werden speziell hervorgehoben.
|
||||
|
||||
## 📞 Hilfe bekommen
|
||||
|
||||
- **GitHub Issues**: Für Fragen und Diskussionen
|
||||
- **Discussions**: Für allgemeine Gespräche
|
||||
- **Email**: Für private Anfragen
|
||||
|
||||
## 📜 Code of Conduct
|
||||
|
||||
- Respektvoller Umgang miteinander
|
||||
- Konstruktive Kritik
|
||||
- Hilfsbereitschaft
|
||||
- Fokus auf das Projekt
|
||||
|
||||
---
|
||||
|
||||
**Vielen Dank für deinen Beitrag zu ToDo Kids! 🎉**
|
||||
454
DEPLOYMENT.md
Normal file
454
DEPLOYMENT.md
Normal file
@ -0,0 +1,454 @@
|
||||
# 🚀 Deployment Guide für ToDo Kids
|
||||
|
||||
Diese Anleitung zeigt dir verschiedene Möglichkeiten, ToDo Kids zu deployen.
|
||||
|
||||
## 📋 Voraussetzungen
|
||||
|
||||
- Node.js 18+ installiert
|
||||
- Git installiert
|
||||
- Firebase-Projekt eingerichtet
|
||||
- Alle Environment Variables konfiguriert
|
||||
|
||||
## 🔧 Vorbereitung
|
||||
|
||||
### 1. Production Build erstellen
|
||||
```bash
|
||||
# Dependencies installieren
|
||||
npm install
|
||||
cd client && npm install && cd ..
|
||||
|
||||
# Frontend Build
|
||||
cd client
|
||||
npm run build
|
||||
cd ..
|
||||
|
||||
# Backend für Production vorbereiten
|
||||
npm run build # Falls vorhanden
|
||||
```
|
||||
|
||||
### 2. Environment Variables prüfen
|
||||
```bash
|
||||
# Root .env
|
||||
PORT=5000
|
||||
MONGODB_URI=your_mongodb_connection_string
|
||||
JWT_SECRET=your_jwt_secret
|
||||
NODE_ENV=production
|
||||
|
||||
# client/.env
|
||||
REACT_APP_API_URL=https://your-backend-url.com
|
||||
REACT_APP_FIREBASE_API_KEY=your_firebase_api_key
|
||||
REACT_APP_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
|
||||
REACT_APP_FIREBASE_PROJECT_ID=your-project-id
|
||||
# ... weitere Firebase-Konfiguration
|
||||
```
|
||||
|
||||
## 🌐 Deployment-Optionen
|
||||
|
||||
### Option 1: Heroku (Empfohlen für Anfänger)
|
||||
|
||||
#### Vorbereitung
|
||||
```bash
|
||||
# Heroku CLI installieren
|
||||
# https://devcenter.heroku.com/articles/heroku-cli
|
||||
|
||||
# Heroku Login
|
||||
heroku login
|
||||
|
||||
# App erstellen
|
||||
heroku create todo-kids-app
|
||||
```
|
||||
|
||||
#### Environment Variables setzen
|
||||
```bash
|
||||
heroku config:set NODE_ENV=production
|
||||
heroku config:set JWT_SECRET=your_jwt_secret
|
||||
heroku config:set MONGODB_URI=your_mongodb_uri
|
||||
|
||||
# Firebase-Konfiguration
|
||||
heroku config:set REACT_APP_FIREBASE_API_KEY=your_api_key
|
||||
heroku config:set REACT_APP_FIREBASE_AUTH_DOMAIN=your_domain
|
||||
# ... weitere Firebase-Variablen
|
||||
```
|
||||
|
||||
#### Deployment
|
||||
```bash
|
||||
# Git Repository vorbereiten
|
||||
git add .
|
||||
git commit -m "Prepare for deployment"
|
||||
|
||||
# Zu Heroku deployen
|
||||
git push heroku main
|
||||
|
||||
# App öffnen
|
||||
heroku open
|
||||
```
|
||||
|
||||
### Option 2: Vercel (Frontend) + Railway/Render (Backend)
|
||||
|
||||
#### Frontend auf Vercel
|
||||
```bash
|
||||
# Vercel CLI installieren
|
||||
npm i -g vercel
|
||||
|
||||
# Login
|
||||
vercel login
|
||||
|
||||
# Client-Ordner deployen
|
||||
cd client
|
||||
vercel
|
||||
|
||||
# Environment Variables in Vercel Dashboard setzen
|
||||
# https://vercel.com/dashboard
|
||||
```
|
||||
|
||||
#### Backend auf Railway
|
||||
```bash
|
||||
# Railway CLI installieren
|
||||
npm install -g @railway/cli
|
||||
|
||||
# Login
|
||||
railway login
|
||||
|
||||
# Projekt erstellen
|
||||
railway new
|
||||
|
||||
# Environment Variables setzen
|
||||
railway variables set NODE_ENV=production
|
||||
railway variables set JWT_SECRET=your_secret
|
||||
|
||||
# Deployen
|
||||
railway up
|
||||
```
|
||||
|
||||
### Option 3: DigitalOcean App Platform
|
||||
|
||||
#### App erstellen
|
||||
1. DigitalOcean Dashboard öffnen
|
||||
2. "Apps" → "Create App"
|
||||
3. GitHub Repository verbinden
|
||||
4. Build-Einstellungen konfigurieren:
|
||||
|
||||
```yaml
|
||||
# .do/app.yaml
|
||||
name: todo-kids
|
||||
services:
|
||||
- name: api
|
||||
source_dir: /
|
||||
github:
|
||||
repo: your-username/todo-kids
|
||||
branch: main
|
||||
run_command: npm start
|
||||
environment_slug: node-js
|
||||
instance_count: 1
|
||||
instance_size_slug: basic-xxs
|
||||
envs:
|
||||
- key: NODE_ENV
|
||||
value: production
|
||||
- key: JWT_SECRET
|
||||
value: your_jwt_secret
|
||||
type: SECRET
|
||||
- key: MONGODB_URI
|
||||
value: your_mongodb_uri
|
||||
type: SECRET
|
||||
|
||||
- name: web
|
||||
source_dir: /client
|
||||
github:
|
||||
repo: your-username/todo-kids
|
||||
branch: main
|
||||
build_command: npm run build
|
||||
run_command: npx serve -s build
|
||||
environment_slug: node-js
|
||||
instance_count: 1
|
||||
instance_size_slug: basic-xxs
|
||||
envs:
|
||||
- key: REACT_APP_API_URL
|
||||
value: ${api.PUBLIC_URL}
|
||||
```
|
||||
|
||||
### Option 4: Docker Deployment
|
||||
|
||||
#### Dockerfile erstellen
|
||||
```dockerfile
|
||||
# Root Dockerfile
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Backend Dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Frontend Build
|
||||
COPY client/package*.json ./client/
|
||||
WORKDIR /app/client
|
||||
RUN npm ci --only=production
|
||||
COPY client/ .
|
||||
RUN npm run build
|
||||
|
||||
# Backend Setup
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["npm", "start"]
|
||||
```
|
||||
|
||||
#### Docker Compose
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
version: '3.8'
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "5000:5000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- MONGODB_URI=${MONGODB_URI}
|
||||
depends_on:
|
||||
- mongo
|
||||
|
||||
mongo:
|
||||
image: mongo:5
|
||||
volumes:
|
||||
- mongo_data:/data/db
|
||||
environment:
|
||||
- MONGO_INITDB_ROOT_USERNAME=admin
|
||||
- MONGO_INITDB_ROOT_PASSWORD=password
|
||||
|
||||
volumes:
|
||||
mongo_data:
|
||||
```
|
||||
|
||||
#### Deployment
|
||||
```bash
|
||||
# Image bauen
|
||||
docker build -t todo-kids .
|
||||
|
||||
# Container starten
|
||||
docker-compose up -d
|
||||
|
||||
# Oder einzeln
|
||||
docker run -p 5000:5000 -e NODE_ENV=production todo-kids
|
||||
```
|
||||
|
||||
### Option 5: VPS (Ubuntu/Debian)
|
||||
|
||||
#### Server vorbereiten
|
||||
```bash
|
||||
# Server Updates
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
|
||||
# Node.js installieren
|
||||
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
|
||||
# PM2 installieren (Process Manager)
|
||||
sudo npm install -g pm2
|
||||
|
||||
# Nginx installieren
|
||||
sudo apt install nginx -y
|
||||
```
|
||||
|
||||
#### App deployen
|
||||
```bash
|
||||
# Repository klonen
|
||||
git clone https://github.com/your-username/todo-kids.git
|
||||
cd todo-kids
|
||||
|
||||
# Dependencies installieren
|
||||
npm install
|
||||
cd client && npm install && npm run build && cd ..
|
||||
|
||||
# Environment Variables setzen
|
||||
cp .env.example .env
|
||||
# .env bearbeiten
|
||||
|
||||
# PM2 starten
|
||||
pm2 start ecosystem.config.js
|
||||
pm2 save
|
||||
pm2 startup
|
||||
```
|
||||
|
||||
#### Nginx konfigurieren
|
||||
```nginx
|
||||
# /etc/nginx/sites-available/todo-kids
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
|
||||
location / {
|
||||
root /path/to/todo-kids/client/build;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://localhost:5000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
# Nginx-Konfiguration aktivieren
|
||||
sudo ln -s /etc/nginx/sites-available/todo-kids /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
sudo systemctl restart nginx
|
||||
```
|
||||
|
||||
## 🔒 SSL/HTTPS einrichten
|
||||
|
||||
### Let's Encrypt (Kostenlos)
|
||||
```bash
|
||||
# Certbot installieren
|
||||
sudo apt install certbot python3-certbot-nginx -y
|
||||
|
||||
# SSL-Zertifikat erstellen
|
||||
sudo certbot --nginx -d your-domain.com
|
||||
|
||||
# Auto-Renewal testen
|
||||
sudo certbot renew --dry-run
|
||||
```
|
||||
|
||||
## 📊 Monitoring & Logs
|
||||
|
||||
### PM2 Monitoring
|
||||
```bash
|
||||
# Status anzeigen
|
||||
pm2 status
|
||||
|
||||
# Logs anzeigen
|
||||
pm2 logs
|
||||
|
||||
# Restart
|
||||
pm2 restart all
|
||||
|
||||
# Monitoring Dashboard
|
||||
pm2 monit
|
||||
```
|
||||
|
||||
### Heroku Logs
|
||||
```bash
|
||||
# Live-Logs
|
||||
heroku logs --tail
|
||||
|
||||
# Letzte 100 Zeilen
|
||||
heroku logs -n 100
|
||||
```
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Häufige Probleme
|
||||
|
||||
#### Build-Fehler
|
||||
```bash
|
||||
# Node-Version prüfen
|
||||
node --version
|
||||
npm --version
|
||||
|
||||
# Cache leeren
|
||||
npm cache clean --force
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
```
|
||||
|
||||
#### Environment Variables
|
||||
```bash
|
||||
# Variablen prüfen
|
||||
echo $NODE_ENV
|
||||
echo $REACT_APP_API_URL
|
||||
|
||||
# Heroku Variablen
|
||||
heroku config
|
||||
```
|
||||
|
||||
#### Database-Verbindung
|
||||
```bash
|
||||
# MongoDB-Verbindung testen
|
||||
mongo "your_connection_string"
|
||||
|
||||
# Logs prüfen
|
||||
tail -f /var/log/mongodb/mongod.log
|
||||
```
|
||||
|
||||
### Performance-Optimierung
|
||||
|
||||
#### Frontend
|
||||
- Code-Splitting aktivieren
|
||||
- Images optimieren
|
||||
- Bundle-Größe analysieren: `npm run build -- --analyze`
|
||||
|
||||
#### Backend
|
||||
- Database-Indizes erstellen
|
||||
- Caching implementieren
|
||||
- Compression aktivieren
|
||||
|
||||
## 📈 Skalierung
|
||||
|
||||
### Horizontal Scaling
|
||||
- Load Balancer einrichten
|
||||
- Multiple App-Instanzen
|
||||
- Database-Clustering
|
||||
|
||||
### Vertical Scaling
|
||||
- Server-Ressourcen erhöhen
|
||||
- Database-Performance optimieren
|
||||
- CDN für statische Assets
|
||||
|
||||
## 🔄 CI/CD Pipeline
|
||||
|
||||
### GitHub Actions
|
||||
```yaml
|
||||
# .github/workflows/deploy.yml
|
||||
name: Deploy to Production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm install
|
||||
cd client && npm install
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
npm test
|
||||
cd client && npm test -- --coverage --watchAll=false
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
cd client && npm run build
|
||||
|
||||
- name: Deploy to Heroku
|
||||
uses: akhileshns/heroku-deploy@v3.12.12
|
||||
with:
|
||||
heroku_api_key: ${{secrets.HEROKU_API_KEY}}
|
||||
heroku_app_name: "todo-kids-app"
|
||||
heroku_email: "your-email@example.com"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Viel Erfolg beim Deployment! 🚀**
|
||||
|
||||
Bei Fragen oder Problemen, erstelle gerne ein Issue im Repository.
|
||||
165
FIREBASE_MIGRATION.md
Normal file
165
FIREBASE_MIGRATION.md
Normal file
@ -0,0 +1,165 @@
|
||||
# 🔥 Firebase Migration Anleitung
|
||||
|
||||
## Übersicht
|
||||
Diese Anleitung hilft dir dabei, von dem lokalen Node.js/MongoDB Backend zu Firebase zu migrieren.
|
||||
|
||||
## 📋 Schritt-für-Schritt Anleitung
|
||||
|
||||
### 1. Firebase Projekt erstellen
|
||||
|
||||
1. Gehe zu [Firebase Console](https://console.firebase.google.com/)
|
||||
2. Klicke auf "Projekt hinzufügen"
|
||||
3. Gib einen Projektnamen ein (z.B. "todo-kids-app")
|
||||
4. Wähle deine Einstellungen (Google Analytics optional)
|
||||
5. Erstelle das Projekt
|
||||
|
||||
### 2. Firebase Services aktivieren
|
||||
|
||||
#### Authentication einrichten:
|
||||
1. Gehe zu "Authentication" > "Get started"
|
||||
2. Wähle "Sign-in method"
|
||||
3. Aktiviere "E-Mail/Passwort"
|
||||
4. Speichere die Einstellungen
|
||||
|
||||
#### Firestore Database einrichten:
|
||||
1. Gehe zu "Firestore Database" > "Datenbank erstellen"
|
||||
2. Wähle "Im Testmodus starten" (für Entwicklung)
|
||||
3. Wähle eine Region (z.B. europe-west3 für Deutschland)
|
||||
4. Erstelle die Datenbank
|
||||
|
||||
### 3. Web-App konfigurieren
|
||||
|
||||
1. Gehe zu Projekteinstellungen (Zahnrad-Symbol)
|
||||
2. Scrolle zu "Deine Apps" und klicke "</>"
|
||||
3. Gib einen App-Namen ein (z.B. "ToDo Kids Web")
|
||||
4. Registriere die App
|
||||
5. Kopiere die Konfigurationsdaten
|
||||
|
||||
### 4. Umgebungsvariablen einrichten
|
||||
|
||||
Öffne die Datei `client/.env` und füge deine Firebase-Konfiguration ein:
|
||||
|
||||
```env
|
||||
REACT_APP_FIREBASE_API_KEY=dein_api_key
|
||||
REACT_APP_FIREBASE_AUTH_DOMAIN=dein_projekt.firebaseapp.com
|
||||
REACT_APP_FIREBASE_PROJECT_ID=dein_projekt_id
|
||||
REACT_APP_FIREBASE_STORAGE_BUCKET=dein_projekt.appspot.com
|
||||
REACT_APP_FIREBASE_MESSAGING_SENDER_ID=deine_sender_id
|
||||
REACT_APP_FIREBASE_APP_ID=deine_app_id
|
||||
```
|
||||
|
||||
### 5. App.js aktualisieren
|
||||
|
||||
Ersetze den alten AuthContext mit dem neuen FirebaseAuthContext:
|
||||
|
||||
```javascript
|
||||
// Alte Imports entfernen:
|
||||
// import { AuthProvider } from './contexts/AuthContext';
|
||||
|
||||
// Neue Imports hinzufügen:
|
||||
import { FirebaseAuthProvider } from './contexts/FirebaseAuthContext';
|
||||
|
||||
// Provider in App.js ändern:
|
||||
function App() {
|
||||
return (
|
||||
<FirebaseAuthProvider>
|
||||
{/* Rest deiner App */}
|
||||
</FirebaseAuthProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Komponenten aktualisieren
|
||||
|
||||
In allen Komponenten, die Authentication verwenden:
|
||||
|
||||
```javascript
|
||||
// Alte Imports ersetzen:
|
||||
// import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
// Mit neuen Imports:
|
||||
import { useFirebaseAuth } from '../contexts/FirebaseAuthContext';
|
||||
|
||||
// Hook-Verwendung aktualisieren:
|
||||
const { currentUser, login, logout } = useFirebaseAuth();
|
||||
```
|
||||
|
||||
### 7. API-Aufrufe ersetzen
|
||||
|
||||
Ersetze alle Axios-API-Aufrufe mit Firebase-Funktionen:
|
||||
|
||||
```javascript
|
||||
// Alte API-Aufrufe:
|
||||
// const response = await axios.get('/api/children');
|
||||
|
||||
// Neue Firebase-Aufrufe:
|
||||
import { getChildren } from '../firebase/database';
|
||||
const result = await getChildren();
|
||||
```
|
||||
|
||||
## 🔧 Wichtige Dateien
|
||||
|
||||
### Neue Firebase-Dateien:
|
||||
- `client/src/firebase/config.js` - Firebase Konfiguration
|
||||
- `client/src/firebase/auth.js` - Authentication Services
|
||||
- `client/src/firebase/database.js` - Firestore Database Services
|
||||
- `client/src/contexts/FirebaseAuthContext.js` - React Context für Auth
|
||||
- `client/.env` - Umgebungsvariablen
|
||||
|
||||
### Zu aktualisierende Dateien:
|
||||
- `client/src/App.js` - AuthProvider wechseln
|
||||
- `client/src/components/LoginPage.js` - Firebase Auth verwenden
|
||||
- `client/src/components/ParentDashboard.js` - Firebase Database verwenden
|
||||
- `client/src/components/ChildrenManagement.js` - Firebase Database verwenden
|
||||
- `client/src/components/TaskManagement.js` - Firebase Database verwenden
|
||||
- `client/src/components/RewardManagement.js` - Firebase Database verwenden
|
||||
|
||||
## 🚀 Vorteile von Firebase
|
||||
|
||||
✅ **Keine Server-Wartung** - Firebase übernimmt das Backend
|
||||
✅ **Automatische Skalierung** - Wächst mit deiner App
|
||||
✅ **Echtzeit-Updates** - Daten werden automatisch synchronisiert
|
||||
✅ **Offline-Support** - App funktioniert auch ohne Internet
|
||||
✅ **Sichere Authentication** - Professionelle Benutzer-Verwaltung
|
||||
✅ **Kostenlos für kleine Apps** - Großzügige kostenlose Limits
|
||||
|
||||
## 🔒 Sicherheitsregeln (später)
|
||||
|
||||
Nach der Migration solltest du Firestore-Sicherheitsregeln einrichten:
|
||||
|
||||
```javascript
|
||||
rules_version = '2';
|
||||
service cloud.firestore {
|
||||
match /databases/{database}/documents {
|
||||
// Nur authentifizierte Benutzer
|
||||
match /{document=**} {
|
||||
allow read, write: if request.auth != null;
|
||||
}
|
||||
|
||||
// Benutzer können nur ihre eigenen Daten sehen
|
||||
match /children/{childId} {
|
||||
allow read, write: if request.auth.uid == resource.data.parentId;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🆘 Hilfe
|
||||
|
||||
Bei Problemen:
|
||||
1. Überprüfe die Browser-Konsole auf Fehler
|
||||
2. Stelle sicher, dass alle Umgebungsvariablen korrekt sind
|
||||
3. Überprüfe die Firebase-Konsole auf Fehler
|
||||
4. Teste zuerst die Authentication, dann die Database-Funktionen
|
||||
|
||||
## 📚 Nächste Schritte
|
||||
|
||||
1. Firebase-Konfiguration einrichten
|
||||
2. App.js aktualisieren
|
||||
3. Login-Komponente migrieren
|
||||
4. Dashboard-Komponente migrieren
|
||||
5. Alle anderen Komponenten schrittweise migrieren
|
||||
6. Lokales Backend deaktivieren
|
||||
7. Testen und optimieren
|
||||
|
||||
**Viel Erfolg bei der Migration! 🎉**
|
||||
107
FIREBASE_SETUP.md
Normal file
107
FIREBASE_SETUP.md
Normal file
@ -0,0 +1,107 @@
|
||||
# 🔥 Firebase Setup - Schnellstart
|
||||
|
||||
## 1. Firebase Projekt erstellen (5 Minuten)
|
||||
|
||||
### Schritt 1: Firebase Console
|
||||
1. Gehe zu [Firebase Console](https://console.firebase.google.com/)
|
||||
2. Klicke "Projekt hinzufügen"
|
||||
3. Projektname: `todo-kids-app` (oder dein Wunschname)
|
||||
4. Google Analytics: **Deaktivieren** (für einfacheren Start)
|
||||
5. "Projekt erstellen" klicken
|
||||
|
||||
### Schritt 2: Authentication aktivieren
|
||||
1. Im Firebase-Projekt: **Authentication** → "Loslegen"
|
||||
2. **Sign-in method** Tab
|
||||
3. **E-Mail/Passwort** aktivieren
|
||||
4. **Speichern**
|
||||
|
||||
### Schritt 3: Firestore Database erstellen
|
||||
1. **Firestore Database** → "Datenbank erstellen"
|
||||
2. **Im Testmodus starten** wählen
|
||||
3. Standort: **europe-west3** (Deutschland)
|
||||
4. **Fertig**
|
||||
|
||||
### Schritt 4: Web-App registrieren
|
||||
1. Projektübersicht → **Web-App hinzufügen** `</>`
|
||||
2. App-Name: `ToDo Kids Web`
|
||||
3. **App registrieren**
|
||||
4. **Konfiguration kopieren** (wichtig!)
|
||||
|
||||
## 2. Lokale Konfiguration (2 Minuten)
|
||||
|
||||
### Firebase-Konfiguration einfügen
|
||||
Öffne `client/.env` und ersetze die Platzhalter:
|
||||
|
||||
```env
|
||||
REACT_APP_FIREBASE_API_KEY=AIzaSyC...
|
||||
REACT_APP_FIREBASE_AUTH_DOMAIN=todo-kids-app.firebaseapp.com
|
||||
REACT_APP_FIREBASE_PROJECT_ID=todo-kids-app
|
||||
REACT_APP_FIREBASE_STORAGE_BUCKET=todo-kids-app.appspot.com
|
||||
REACT_APP_FIREBASE_MESSAGING_SENDER_ID=123456789
|
||||
REACT_APP_FIREBASE_APP_ID=1:123456789:web:abc123
|
||||
```
|
||||
|
||||
**Wichtig:** Verwende deine echten Werte aus der Firebase Console!
|
||||
|
||||
## 3. App starten
|
||||
|
||||
```bash
|
||||
cd client
|
||||
npm start
|
||||
```
|
||||
|
||||
## 4. Testen
|
||||
|
||||
1. **Registrierung testen:**
|
||||
- Gehe zu `/register`
|
||||
- Erstelle einen Account
|
||||
- Sollte funktionieren! ✅
|
||||
|
||||
2. **Login testen:**
|
||||
- Gehe zu `/login`
|
||||
- Melde dich an
|
||||
- Dashboard sollte laden ✅
|
||||
|
||||
## 🚨 Häufige Probleme
|
||||
|
||||
### Problem: "Firebase: Error (auth/configuration-not-found)"
|
||||
**Lösung:** Überprüfe deine `.env` Datei - alle Werte müssen korrekt sein
|
||||
|
||||
### Problem: "Firebase: Error (auth/api-key-not-valid)"
|
||||
**Lösung:** API Key in `.env` ist falsch - kopiere ihn nochmal aus Firebase Console
|
||||
|
||||
### Problem: "Firestore: Missing or insufficient permissions"
|
||||
**Lösung:**
|
||||
1. Firebase Console → Firestore Database → Rules
|
||||
2. Ändere zu:
|
||||
```javascript
|
||||
rules_version = '2';
|
||||
service cloud.firestore {
|
||||
match /databases/{database}/documents {
|
||||
match /{document=**} {
|
||||
allow read, write: if request.auth != null;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
3. **Veröffentlichen** klicken
|
||||
|
||||
## 🎉 Fertig!
|
||||
|
||||
Deine App läuft jetzt mit Firebase!
|
||||
|
||||
**Vorteile:**
|
||||
- ✅ Kein lokaler Server nötig
|
||||
- ✅ Automatische Backups
|
||||
- ✅ Skaliert automatisch
|
||||
- ✅ Professionelle Authentication
|
||||
- ✅ Kostenlos für kleine Apps
|
||||
|
||||
## 📱 Nächste Schritte
|
||||
|
||||
1. **Sicherheitsregeln verfeinern** (später)
|
||||
2. **Offline-Support aktivieren** (automatisch)
|
||||
3. **Push-Benachrichtigungen** (optional)
|
||||
4. **Analytics hinzufügen** (optional)
|
||||
|
||||
**Viel Spaß mit deiner Firebase-App! 🚀**
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 ToDo Kids
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
212
README.md
Normal file
212
README.md
Normal file
@ -0,0 +1,212 @@
|
||||
# 📝 ToDo Kids - Aufgaben-App für Kinder
|
||||
|
||||
Eine kindgerechte To-Do-Listen-App mit verschiedenen Themes, Belohnungssystem und Firebase-Integration.
|
||||
|
||||
## 🌟 Features
|
||||
|
||||
### 🎨 Verschiedene Themes
|
||||
- **🌈 Bunt & Fröhlich**: Farbenfrohes Standard-Theme
|
||||
- **🌲 Wald**: Authentische Waldatmosphäre mit Bäumen und Tieren
|
||||
- **🚀 Weltraum**: Futuristische Weltraum-Optik mit Sternen und Planeten
|
||||
- **🌊 Ozean**: Unterwasser-Theme mit Fischen und Wellen
|
||||
- **🌿 Natur**: Grünes Natur-Theme mit Pflanzen
|
||||
|
||||
### 👨👩👧👦 Familien-Management
|
||||
- Mehrere Kinder pro Familie
|
||||
- Individuelle Profile und Einstellungen
|
||||
- Altersgerechte Benutzeroberflächen
|
||||
|
||||
### ✅ Aufgaben-System
|
||||
- Erstellen und Verwalten von Aufgaben
|
||||
- Prioritäten und Kategorien
|
||||
- Fortschrittsverfolgung
|
||||
- Belohnungspunkte
|
||||
|
||||
### 🏆 Belohnungs-System
|
||||
- Punkte sammeln für erledigte Aufgaben
|
||||
- Achievements und Erfolge
|
||||
- Motivierende Belohnungen
|
||||
|
||||
## 🛠️ Technologie-Stack
|
||||
|
||||
### Frontend
|
||||
- **React** 18.x
|
||||
- **Material-UI (MUI)** 5.x
|
||||
- **React Router** für Navigation
|
||||
- **Context API** für State Management
|
||||
|
||||
### Backend
|
||||
- **Node.js** mit Express
|
||||
- **Firebase** Authentication & Firestore
|
||||
- **JWT** für Session-Management
|
||||
|
||||
### Styling
|
||||
- **Material-UI Theming**
|
||||
- **CSS-in-JS**
|
||||
- **Responsive Design**
|
||||
- **SVG-Animationen**
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
### Voraussetzungen
|
||||
- Node.js (Version 16 oder höher)
|
||||
- npm oder yarn
|
||||
- Firebase-Projekt
|
||||
|
||||
### 1. Repository klonen
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd "ToDo Kids"
|
||||
```
|
||||
|
||||
### 2. Dependencies installieren
|
||||
```bash
|
||||
# Backend Dependencies
|
||||
npm install
|
||||
|
||||
# Frontend Dependencies
|
||||
cd client
|
||||
npm install
|
||||
cd ..
|
||||
```
|
||||
|
||||
### 3. Environment Variables einrichten
|
||||
|
||||
#### Backend (.env)
|
||||
```env
|
||||
PORT=5000
|
||||
JWT_SECRET=your_jwt_secret_here
|
||||
FIREBASE_PROJECT_ID=your_firebase_project_id
|
||||
FIREBASE_PRIVATE_KEY=your_firebase_private_key
|
||||
FIREBASE_CLIENT_EMAIL=your_firebase_client_email
|
||||
```
|
||||
|
||||
#### Frontend (client/.env)
|
||||
```env
|
||||
REACT_APP_FIREBASE_API_KEY=your_api_key
|
||||
REACT_APP_FIREBASE_AUTH_DOMAIN=your_project.firebaseapp.com
|
||||
REACT_APP_FIREBASE_PROJECT_ID=your_project_id
|
||||
REACT_APP_FIREBASE_STORAGE_BUCKET=your_project.appspot.com
|
||||
REACT_APP_FIREBASE_MESSAGING_SENDER_ID=your_sender_id
|
||||
REACT_APP_FIREBASE_APP_ID=your_app_id
|
||||
REACT_APP_API_URL=http://localhost:5000/api
|
||||
```
|
||||
|
||||
### 4. Firebase einrichten
|
||||
1. Firebase-Projekt erstellen
|
||||
2. Authentication aktivieren (Email/Password)
|
||||
3. Firestore Database erstellen
|
||||
4. Service Account Key generieren
|
||||
5. Environment Variables konfigurieren
|
||||
|
||||
Detaillierte Anweisungen: [FIREBASE_SETUP.md](FIREBASE_SETUP.md)
|
||||
|
||||
## 🏃♂️ Entwicklung starten
|
||||
|
||||
### Backend starten
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Frontend starten
|
||||
```bash
|
||||
cd client
|
||||
npm start
|
||||
```
|
||||
|
||||
Die App ist dann verfügbar unter:
|
||||
- Frontend: http://localhost:3000
|
||||
- Backend: http://localhost:5000
|
||||
|
||||
## 📁 Projektstruktur
|
||||
|
||||
```
|
||||
ToDo Kids/
|
||||
├── client/ # React Frontend
|
||||
│ ├── public/
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # Wiederverwendbare Komponenten
|
||||
│ │ ├── contexts/ # React Context Provider
|
||||
│ │ ├── firebase/ # Firebase Konfiguration
|
||||
│ │ ├── pages/ # Seiten-Komponenten
|
||||
│ │ └── themes/ # Theme-Definitionen
|
||||
│ └── package.json
|
||||
├── middleware/ # Express Middleware
|
||||
├── models/ # Datenmodelle
|
||||
├── routes/ # API Routes
|
||||
├── .env # Backend Environment Variables
|
||||
├── .gitignore
|
||||
├── package.json
|
||||
└── server.js # Express Server
|
||||
```
|
||||
|
||||
## 🎨 Theme-System
|
||||
|
||||
Das Theme-System ermöglicht es Kindern, ihre bevorzugte Optik zu wählen:
|
||||
|
||||
- **Dynamische Themes**: Jedes Kind kann sein eigenes Theme wählen
|
||||
- **SVG-Animationen**: Lebendige, aber nicht ablenkende Animationen
|
||||
- **Responsive Design**: Funktioniert auf allen Geräten
|
||||
- **Accessibility**: Kindgerechte Farben und Kontraste
|
||||
|
||||
## 🔐 Sicherheit
|
||||
|
||||
- **Firebase Authentication**: Sichere Benutzeranmeldung
|
||||
- **JWT Tokens**: Session-Management
|
||||
- **Input Validation**: Schutz vor schädlichen Eingaben
|
||||
- **Environment Variables**: Sensible Daten sind geschützt
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
```bash
|
||||
# Frontend Tests
|
||||
cd client
|
||||
npm test
|
||||
|
||||
# Backend Tests (falls implementiert)
|
||||
npm test
|
||||
```
|
||||
|
||||
## 📦 Deployment
|
||||
|
||||
### Frontend (Netlify/Vercel)
|
||||
```bash
|
||||
cd client
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Backend (Heroku/Railway)
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
## 🤝 Beitragen
|
||||
|
||||
1. Fork das Repository
|
||||
2. Erstelle einen Feature Branch (`git checkout -b feature/AmazingFeature`)
|
||||
3. Committe deine Änderungen (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. Push zum Branch (`git push origin feature/AmazingFeature`)
|
||||
5. Öffne einen Pull Request
|
||||
|
||||
## 📄 Lizenz
|
||||
|
||||
Dieses Projekt steht unter der MIT-Lizenz. Siehe [LICENSE](LICENSE) für Details.
|
||||
|
||||
## 📞 Support
|
||||
|
||||
Bei Fragen oder Problemen:
|
||||
- Erstelle ein Issue im Repository
|
||||
- Kontaktiere das Entwicklungsteam
|
||||
|
||||
## 🔄 Changelog
|
||||
|
||||
### Version 1.0.0
|
||||
- ✅ Grundlegende Aufgaben-Funktionalität
|
||||
- ✅ Firebase-Integration
|
||||
- ✅ 5 verschiedene Themes
|
||||
- ✅ Familien-Management
|
||||
- ✅ Belohnungssystem
|
||||
|
||||
---
|
||||
|
||||
**Entwickelt mit ❤️ für Kinder und Familien**
|
||||
210
SECURITY.md
Normal file
210
SECURITY.md
Normal file
@ -0,0 +1,210 @@
|
||||
# 🔒 Sicherheitsrichtlinien
|
||||
|
||||
## 🛡️ Unterstützte Versionen
|
||||
|
||||
Wir unterstützen die folgenden Versionen von ToDo Kids mit Sicherheitsupdates:
|
||||
|
||||
| Version | Unterstützt |
|
||||
| ------- | ------------------ |
|
||||
| 1.0.x | ✅ Ja |
|
||||
| < 1.0 | ❌ Nein |
|
||||
|
||||
## 🚨 Sicherheitslücke melden
|
||||
|
||||
Wir nehmen die Sicherheit von ToDo Kids sehr ernst. Wenn du eine Sicherheitslücke entdeckst, befolge bitte diese Schritte:
|
||||
|
||||
### 📧 Verantwortungsvolle Offenlegung
|
||||
|
||||
**BITTE ERSTELLE KEINE ÖFFENTLICHEN GITHUB ISSUES FÜR SICHERHEITSPROBLEME**
|
||||
|
||||
Stattdessen:
|
||||
|
||||
1. **E-Mail senden**: Sende eine E-Mail an `security@todo-kids.com`
|
||||
2. **Beschreibung**: Beschreibe die Sicherheitslücke detailliert
|
||||
3. **Schritte**: Gib Schritte zur Reproduktion an
|
||||
4. **Impact**: Erkläre den möglichen Schaden
|
||||
5. **Beweis**: Füge Screenshots oder Code-Beispiele hinzu (falls möglich)
|
||||
|
||||
### 📝 Was in den Bericht gehört
|
||||
|
||||
```
|
||||
Betreff: [SECURITY] Kurze Beschreibung der Sicherheitslücke
|
||||
|
||||
1. Zusammenfassung:
|
||||
- Was ist das Problem?
|
||||
- Welche Komponente ist betroffen?
|
||||
|
||||
2. Technische Details:
|
||||
- Genaue Schritte zur Reproduktion
|
||||
- Betroffene URLs/Endpunkte
|
||||
- Browser/System-Informationen
|
||||
|
||||
3. Impact:
|
||||
- Wer ist betroffen?
|
||||
- Was kann ein Angreifer tun?
|
||||
- Wie schwerwiegend ist das Problem?
|
||||
|
||||
4. Vorgeschlagene Lösung (optional):
|
||||
- Wie könnte das Problem behoben werden?
|
||||
|
||||
5. Anhänge:
|
||||
- Screenshots
|
||||
- Code-Beispiele
|
||||
- Logs (ohne sensible Daten)
|
||||
```
|
||||
|
||||
### ⏱️ Response-Zeiten
|
||||
|
||||
- **Bestätigung**: Innerhalb von 24 Stunden
|
||||
- **Erste Bewertung**: Innerhalb von 72 Stunden
|
||||
- **Status-Update**: Wöchentlich bis zur Lösung
|
||||
- **Fix-Deployment**: Je nach Schweregrad (siehe unten)
|
||||
|
||||
### 🎯 Schweregrad-Klassifizierung
|
||||
|
||||
#### 🔴 Kritisch (24-48 Stunden)
|
||||
- Remote Code Execution
|
||||
- SQL Injection
|
||||
- Authentication Bypass
|
||||
- Vollständiger Datenverlust möglich
|
||||
|
||||
#### 🟠 Hoch (1 Woche)
|
||||
- Cross-Site Scripting (XSS)
|
||||
- Cross-Site Request Forgery (CSRF)
|
||||
- Privilege Escalation
|
||||
- Sensible Daten-Exposition
|
||||
|
||||
#### 🟡 Mittel (2-4 Wochen)
|
||||
- Information Disclosure
|
||||
- Denial of Service
|
||||
- Schwache Kryptographie
|
||||
- Session-Management Probleme
|
||||
|
||||
#### 🟢 Niedrig (1-3 Monate)
|
||||
- Konfigurationsprobleme
|
||||
- Schwache Passwort-Richtlinien
|
||||
- Informative Error Messages
|
||||
- Minor Information Leaks
|
||||
|
||||
## 🛡️ Sicherheitsmaßnahmen
|
||||
|
||||
### 🔐 Authentifizierung & Autorisierung
|
||||
|
||||
- **Firebase Authentication**: Sichere Benutzer-Authentifizierung
|
||||
- **JWT Tokens**: Sichere Session-Verwaltung
|
||||
- **Role-based Access**: Eltern/Kind-Rollen-System
|
||||
- **Input Validation**: Alle Eingaben werden validiert
|
||||
|
||||
### 🌐 Web-Sicherheit
|
||||
|
||||
- **HTTPS**: Verschlüsselte Datenübertragung
|
||||
- **CORS**: Konfigurierte Cross-Origin-Richtlinien
|
||||
- **CSP**: Content Security Policy Headers
|
||||
- **XSS Protection**: Input-Sanitization
|
||||
- **CSRF Protection**: Token-basierter Schutz
|
||||
|
||||
### 🗄️ Daten-Sicherheit
|
||||
|
||||
- **Encryption at Rest**: Firebase-Verschlüsselung
|
||||
- **Encryption in Transit**: TLS 1.3
|
||||
- **Data Minimization**: Nur notwendige Daten speichern
|
||||
- **Regular Backups**: Automatische Datensicherung
|
||||
- **Access Logs**: Überwachung von Datenzugriffen
|
||||
|
||||
### 🔧 Infrastruktur-Sicherheit
|
||||
|
||||
- **Dependency Scanning**: Regelmäßige Überprüfung
|
||||
- **Security Headers**: Sichere HTTP-Headers
|
||||
- **Rate Limiting**: Schutz vor Brute-Force
|
||||
- **Error Handling**: Keine sensiblen Informationen in Fehlern
|
||||
|
||||
## 🔍 Sicherheits-Audit
|
||||
|
||||
### 📊 Regelmäßige Überprüfungen
|
||||
|
||||
- **Dependency Updates**: Wöchentlich
|
||||
- **Security Scans**: Monatlich
|
||||
- **Code Reviews**: Bei jedem Pull Request
|
||||
- **Penetration Tests**: Halbjährlich
|
||||
|
||||
### 🛠️ Tools & Services
|
||||
|
||||
- **npm audit**: Dependency-Vulnerabilities
|
||||
- **Snyk**: Kontinuierliche Sicherheitsüberwachung
|
||||
- **ESLint Security**: Code-Analyse
|
||||
- **OWASP ZAP**: Web-Application Security Testing
|
||||
|
||||
## 👨👩👧👦 Kinder-Sicherheit
|
||||
|
||||
### 🔒 Datenschutz für Kinder
|
||||
|
||||
- **COPPA Compliance**: Schutz von Kindern unter 13
|
||||
- **GDPR Compliance**: Europäische Datenschutz-Grundverordnung
|
||||
- **Minimal Data Collection**: Nur notwendige Informationen
|
||||
- **Parental Consent**: Elterliche Zustimmung erforderlich
|
||||
|
||||
### 🛡️ Content-Sicherheit
|
||||
|
||||
- **Content Filtering**: Keine unangemessenen Inhalte
|
||||
- **Safe Themes**: Kindgerechte Designs
|
||||
- **No External Links**: Keine Verlinkung zu externen Seiten
|
||||
- **Moderated Content**: Überwachung von Benutzer-Inhalten
|
||||
|
||||
## 🚨 Incident Response
|
||||
|
||||
### 📋 Notfall-Verfahren
|
||||
|
||||
1. **Sofortige Maßnahmen**
|
||||
- Betroffene Systeme isolieren
|
||||
- Schaden begrenzen
|
||||
- Logs sichern
|
||||
|
||||
2. **Kommunikation**
|
||||
- Interne Teams benachrichtigen
|
||||
- Benutzer informieren (falls nötig)
|
||||
- Behörden kontaktieren (falls erforderlich)
|
||||
|
||||
3. **Wiederherstellung**
|
||||
- Sicherheitslücke schließen
|
||||
- Systeme wiederherstellen
|
||||
- Monitoring verstärken
|
||||
|
||||
4. **Post-Incident**
|
||||
- Ursachen-Analyse
|
||||
- Verbesserungen implementieren
|
||||
- Dokumentation aktualisieren
|
||||
|
||||
### 📞 Notfall-Kontakte
|
||||
|
||||
- **Security Team**: security@todo-kids.com
|
||||
- **Technical Lead**: tech@todo-kids.com
|
||||
- **Management**: management@todo-kids.com
|
||||
|
||||
## 📚 Sicherheits-Ressourcen
|
||||
|
||||
### 🔗 Hilfreiche Links
|
||||
|
||||
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
|
||||
- [Firebase Security Rules](https://firebase.google.com/docs/rules)
|
||||
- [React Security Best Practices](https://snyk.io/blog/10-react-security-best-practices/)
|
||||
- [Node.js Security Checklist](https://blog.risingstack.com/node-js-security-checklist/)
|
||||
|
||||
### 📖 Weitere Dokumentation
|
||||
|
||||
- [Privacy Policy](./PRIVACY.md)
|
||||
- [Terms of Service](./TERMS.md)
|
||||
- [Contributing Guidelines](./CONTRIBUTING.md)
|
||||
|
||||
## 🏆 Anerkennung
|
||||
|
||||
Wir danken allen Sicherheitsforschern, die verantwortungsvoll Sicherheitslücken melden. Anerkannte Beiträge werden in unserer Hall of Fame aufgeführt:
|
||||
|
||||
### 🌟 Security Hall of Fame
|
||||
|
||||
*Noch keine Einträge - sei der Erste!*
|
||||
|
||||
---
|
||||
|
||||
**Vielen Dank, dass du zur Sicherheit von ToDo Kids beiträgst! 🛡️**
|
||||
|
||||
*Letzte Aktualisierung: Januar 2024*
|
||||
23
client/.gitignore
vendored
Normal file
23
client/.gitignore
vendored
Normal file
@ -0,0 +1,23 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
70
client/README.md
Normal file
70
client/README.md
Normal file
@ -0,0 +1,70 @@
|
||||
# Getting Started with Create React App
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `npm start`
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
|
||||
|
||||
The page will reload when you make changes.\
|
||||
You may also see any lint errors in the console.
|
||||
|
||||
### `npm test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `npm run eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
|
||||
|
||||
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
|
||||
|
||||
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
|
||||
### Code Splitting
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
|
||||
|
||||
### Analyzing the Bundle Size
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
|
||||
|
||||
### Making a Progressive Web App
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
|
||||
|
||||
### Deployment
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
||||
|
||||
### `npm run build` fails to minify
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
|
||||
19210
client/package-lock.json
generated
Normal file
19210
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
61
client/package.json
Normal file
61
client/package.json
Normal file
@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "todo-kids-client",
|
||||
"version": "1.0.0",
|
||||
"description": "React Frontend für ToDo Kids - Eine spielerische To-Do- & Belohnungs-App für Kinder",
|
||||
"private": true,
|
||||
"homepage": "https://github.com/DEIN-USERNAME/todo-kids",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/DEIN-USERNAME/todo-kids.git"
|
||||
},
|
||||
"keywords": ["react", "material-ui", "todo", "kids", "themes", "family"],
|
||||
"author": "ToDo Kids Team",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@date-io/date-fns": "^3.2.1",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.2.0",
|
||||
"@mui/lab": "^7.0.0-beta.14",
|
||||
"@mui/material": "^7.2.0",
|
||||
"@mui/x-date-pickers": "^8.9.2",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.6.4",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"axios": "^1.11.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"firebase": "^12.0.0",
|
||||
"framer-motion": "^12.23.12",
|
||||
"react": "^19.1.1",
|
||||
"react-confetti": "^6.4.0",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router-dom": "^7.7.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
38
client/src/App.css
Normal file
38
client/src/App.css
Normal file
@ -0,0 +1,38 @@
|
||||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
154
client/src/App.js
Normal file
154
client/src/App.js
Normal file
@ -0,0 +1,154 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import { FirebaseAuthProvider } from './contexts/FirebaseAuthContext';
|
||||
import { FirebaseChildAuthProvider } from './contexts/FirebaseChildAuthContext';
|
||||
import { ChildThemeProvider } from './contexts/ChildThemeContext';
|
||||
import { NotificationProvider } from './contexts/NotificationContext';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import ChildProtectedRoute from './components/ChildProtectedRoute';
|
||||
import LandingPage from './pages/LandingPage';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import RegisterPage from './pages/RegisterPage';
|
||||
import ChildLoginPage from './pages/ChildLoginPage';
|
||||
import ParentDashboard from './pages/ParentDashboard';
|
||||
import ChildDashboard from './pages/ChildDashboard';
|
||||
import TaskManagement from './pages/TaskManagement';
|
||||
import RewardManagement from './pages/RewardManagement';
|
||||
import ChildrenManagement from './pages/ChildrenManagement';
|
||||
import './App.css';
|
||||
|
||||
// Familien-Held Theme
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#4CAF50', // Freundliches Grün
|
||||
light: '#81C784',
|
||||
dark: '#388E3C',
|
||||
},
|
||||
secondary: {
|
||||
main: '#FF9800', // Warmes Orange
|
||||
light: '#FFB74D',
|
||||
dark: '#F57C00',
|
||||
},
|
||||
success: {
|
||||
main: '#4CAF50',
|
||||
},
|
||||
warning: {
|
||||
main: '#FF9800',
|
||||
},
|
||||
error: {
|
||||
main: '#F44336',
|
||||
},
|
||||
background: {
|
||||
default: '#F8F9FA',
|
||||
paper: '#FFFFFF',
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
|
||||
h1: {
|
||||
fontWeight: 700,
|
||||
fontSize: '2.5rem',
|
||||
},
|
||||
h2: {
|
||||
fontWeight: 600,
|
||||
fontSize: '2rem',
|
||||
},
|
||||
h3: {
|
||||
fontWeight: 600,
|
||||
fontSize: '1.5rem',
|
||||
},
|
||||
button: {
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
},
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 12,
|
||||
},
|
||||
components: {
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 12,
|
||||
padding: '10px 24px',
|
||||
fontSize: '1rem',
|
||||
},
|
||||
contained: {
|
||||
boxShadow: '0 4px 12px rgba(76, 175, 80, 0.3)',
|
||||
'&:hover': {
|
||||
boxShadow: '0 6px 16px rgba(76, 175, 80, 0.4)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 16,
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<FirebaseAuthProvider>
|
||||
<FirebaseChildAuthProvider>
|
||||
<NotificationProvider>
|
||||
<Router>
|
||||
<div className="App">
|
||||
<Routes>
|
||||
{/* Öffentliche Routen */}
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/child-login" element={<ChildLoginPage />} />
|
||||
|
||||
{/* Geschützte Routen für Eltern */}
|
||||
<Route path="/dashboard" element={
|
||||
<ProtectedRoute>
|
||||
<ParentDashboard />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/tasks" element={
|
||||
<ProtectedRoute>
|
||||
<TaskManagement />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/rewards" element={
|
||||
<ProtectedRoute>
|
||||
<RewardManagement />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/children" element={
|
||||
<ProtectedRoute>
|
||||
<ChildrenManagement />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
{/* Kinder-Ansicht */}
|
||||
<Route path="/child-dashboard" element={
|
||||
<ChildProtectedRoute>
|
||||
<ChildThemeProvider>
|
||||
<ChildDashboard />
|
||||
</ChildThemeProvider>
|
||||
</ChildProtectedRoute>
|
||||
} />
|
||||
</Routes>
|
||||
</div>
|
||||
</Router>
|
||||
</NotificationProvider>
|
||||
</FirebaseChildAuthProvider>
|
||||
</FirebaseAuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
8
client/src/App.test.js
Normal file
8
client/src/App.test.js
Normal file
@ -0,0 +1,8 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
24
client/src/components/ChildProtectedRoute.js
Normal file
24
client/src/components/ChildProtectedRoute.js
Normal file
@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useFirebaseChildAuth } from '../contexts/FirebaseChildAuthContext';
|
||||
import { CircularProgress, Box } from '@mui/material';
|
||||
|
||||
const ChildProtectedRoute = ({ children }) => {
|
||||
const { child, loading } = useFirebaseChildAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!child) {
|
||||
return <Navigate to="/child-login" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export default ChildProtectedRoute;
|
||||
66
client/src/components/ProtectedRoute.js
Normal file
66
client/src/components/ProtectedRoute.js
Normal file
@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useFirebaseAuth } from '../contexts/FirebaseAuthContext';
|
||||
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const ProtectedRoute = ({ children }) => {
|
||||
const { isAuthenticated, loading } = useFirebaseAuth();
|
||||
const location = useLocation();
|
||||
|
||||
// Während des Ladens
|
||||
if (loading) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #4CAF50 0%, #81C784 100%)',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: 'linear' }}
|
||||
>
|
||||
<CircularProgress
|
||||
size={60}
|
||||
sx={{
|
||||
color: 'white',
|
||||
mb: 3
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<Typography variant="h4" sx={{ mb: 2, fontWeight: 700 }}>
|
||||
🦸♂️ Familien-Held
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" sx={{ opacity: 0.9 }}>
|
||||
Lade deine Helden-Zentrale...
|
||||
</Typography>
|
||||
</Box>
|
||||
</motion.div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Nicht authentifiziert - zur Login-Seite weiterleiten
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
// Authentifiziert - Kinder-Komponente rendern
|
||||
return children;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
168
client/src/contexts/AuthContext.js
Normal file
168
client/src/contexts/AuthContext.js
Normal file
@ -0,0 +1,168 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
const AuthContext = createContext();
|
||||
|
||||
// API Base URL
|
||||
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api';
|
||||
|
||||
// Axios Konfiguration
|
||||
axios.defaults.baseURL = API_BASE_URL;
|
||||
|
||||
// Token aus localStorage setzen
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth muss innerhalb eines AuthProvider verwendet werden');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
|
||||
// Benutzer beim App-Start laden
|
||||
useEffect(() => {
|
||||
const loadUser = async () => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
try {
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
const response = await axios.get('/auth/me');
|
||||
if (response.data.success) {
|
||||
setUser(response.data.user);
|
||||
setIsAuthenticated(true);
|
||||
} else {
|
||||
// Token ungültig
|
||||
localStorage.removeItem('token');
|
||||
delete axios.defaults.headers.common['Authorization'];
|
||||
}
|
||||
} catch (error) {
|
||||
// Nur echte Fehler loggen, nicht Verbindungsfehler beim App-Start
|
||||
if (error.code !== 'ERR_NETWORK' && error.code !== 'ERR_CONNECTION_REFUSED') {
|
||||
console.error('Fehler beim Laden des Benutzers:', error);
|
||||
}
|
||||
localStorage.removeItem('token');
|
||||
delete axios.defaults.headers.common['Authorization'];
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
loadUser();
|
||||
}, []);
|
||||
|
||||
// Registrierung
|
||||
const register = async (userData) => {
|
||||
try {
|
||||
const response = await axios.post('/auth/register', userData);
|
||||
if (response.data.success) {
|
||||
const { token, user } = response.data;
|
||||
|
||||
// Token speichern
|
||||
localStorage.setItem('token', token);
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
// Benutzer setzen
|
||||
setUser(user);
|
||||
setIsAuthenticated(true);
|
||||
|
||||
return { success: true, message: response.data.message };
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.message || 'Registrierung fehlgeschlagen';
|
||||
return { success: false, message, errors: error.response?.data?.errors };
|
||||
}
|
||||
};
|
||||
|
||||
// Anmeldung
|
||||
const login = async (credentials) => {
|
||||
try {
|
||||
const response = await axios.post('/auth/login', credentials);
|
||||
if (response.data.success) {
|
||||
const { token, user } = response.data;
|
||||
|
||||
// Token speichern
|
||||
localStorage.setItem('token', token);
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
// Benutzer setzen
|
||||
setUser(user);
|
||||
setIsAuthenticated(true);
|
||||
|
||||
return { success: true, message: response.data.message };
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.message || 'Anmeldung fehlgeschlagen';
|
||||
return { success: false, message };
|
||||
}
|
||||
};
|
||||
|
||||
// Abmeldung
|
||||
const logout = async () => {
|
||||
try {
|
||||
await axios.post('/auth/logout');
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Logout:', error);
|
||||
} finally {
|
||||
// Lokale Daten löschen
|
||||
localStorage.removeItem('token');
|
||||
delete axios.defaults.headers.common['Authorization'];
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Profil aktualisieren
|
||||
const updateProfile = async (profileData) => {
|
||||
try {
|
||||
const response = await axios.put('/auth/profile', profileData);
|
||||
if (response.data.success) {
|
||||
setUser(response.data.user);
|
||||
return { success: true, message: response.data.message };
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.message || 'Profil-Update fehlgeschlagen';
|
||||
return { success: false, message, errors: error.response?.data?.errors };
|
||||
}
|
||||
};
|
||||
|
||||
// Passwort ändern
|
||||
const changePassword = async (passwordData) => {
|
||||
try {
|
||||
const response = await axios.put('/auth/password', passwordData);
|
||||
if (response.data.success) {
|
||||
return { success: true, message: response.data.message };
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.message || 'Passwort-Änderung fehlgeschlagen';
|
||||
return { success: false, message, errors: error.response?.data?.errors };
|
||||
}
|
||||
};
|
||||
|
||||
const value = {
|
||||
user,
|
||||
loading,
|
||||
isAuthenticated,
|
||||
register,
|
||||
login,
|
||||
logout,
|
||||
updateProfile,
|
||||
changePassword
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthContext;
|
||||
125
client/src/contexts/ChildAuthContext.js
Normal file
125
client/src/contexts/ChildAuthContext.js
Normal file
@ -0,0 +1,125 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
const ChildAuthContext = createContext();
|
||||
|
||||
export const useChildAuth = () => {
|
||||
const context = useContext(ChildAuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useChildAuth muss innerhalb eines ChildAuthProvider verwendet werden');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const ChildAuthProvider = ({ children }) => {
|
||||
const [child, setChild] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [token, setToken] = useState(localStorage.getItem('childToken'));
|
||||
|
||||
// Axios Interceptor für automatische Token-Anhängung
|
||||
useEffect(() => {
|
||||
const interceptor = axios.interceptors.request.use(
|
||||
(config) => {
|
||||
if (token && config.url?.includes('/child-auth/') || config.url?.includes('/children/')) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
return () => axios.interceptors.request.eject(interceptor);
|
||||
}, [token]);
|
||||
|
||||
// Token-Validierung beim Start
|
||||
useEffect(() => {
|
||||
const validateToken = async () => {
|
||||
if (!token) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get('/child-auth/verify-token', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
setChild(response.data.child);
|
||||
} else {
|
||||
// Token ungültig
|
||||
localStorage.removeItem('childToken');
|
||||
localStorage.removeItem('childData');
|
||||
setToken(null);
|
||||
setChild(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Token-Validierung fehlgeschlagen:', error);
|
||||
localStorage.removeItem('childToken');
|
||||
localStorage.removeItem('childData');
|
||||
setToken(null);
|
||||
setChild(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
validateToken();
|
||||
}, [token]);
|
||||
|
||||
const login = async (username, pin) => {
|
||||
try {
|
||||
const response = await axios.post('/child-auth/login', {
|
||||
username,
|
||||
pin
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
const { token: newToken, child: childData } = response.data;
|
||||
|
||||
localStorage.setItem('childToken', newToken);
|
||||
localStorage.setItem('childData', JSON.stringify(childData));
|
||||
|
||||
setToken(newToken);
|
||||
setChild(childData);
|
||||
|
||||
return { success: true, child: childData };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login-Fehler:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || 'Anmeldung fehlgeschlagen'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('childToken');
|
||||
localStorage.removeItem('childData');
|
||||
setToken(null);
|
||||
setChild(null);
|
||||
};
|
||||
|
||||
const updateChildData = (updatedChild) => {
|
||||
setChild(updatedChild);
|
||||
localStorage.setItem('childData', JSON.stringify(updatedChild));
|
||||
};
|
||||
|
||||
const value = {
|
||||
child,
|
||||
loading,
|
||||
isAuthenticated: !!child,
|
||||
login,
|
||||
logout,
|
||||
updateChildData
|
||||
};
|
||||
|
||||
return (
|
||||
<ChildAuthContext.Provider value={value}>
|
||||
{children}
|
||||
</ChildAuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChildAuthContext;
|
||||
61
client/src/contexts/ChildThemeContext.js
Normal file
61
client/src/contexts/ChildThemeContext.js
Normal file
@ -0,0 +1,61 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import { getChildTheme, childThemes } from '../themes/childThemes';
|
||||
import { useFirebaseChildAuth } from './FirebaseChildAuthContext';
|
||||
|
||||
const ChildThemeContext = createContext();
|
||||
|
||||
export const useChildTheme = () => {
|
||||
const context = useContext(ChildThemeContext);
|
||||
if (!context) {
|
||||
throw new Error('useChildTheme must be used within a ChildThemeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const ChildThemeProvider = ({ children }) => {
|
||||
const { child } = useFirebaseChildAuth();
|
||||
const [currentTheme, setCurrentTheme] = useState(childThemes.colorful);
|
||||
const [themeName, setThemeName] = useState('colorful');
|
||||
|
||||
// Theme basierend auf Kinder-Preferences aktualisieren
|
||||
useEffect(() => {
|
||||
if (child && child.preferences) {
|
||||
const newThemeName = child.preferences.theme || 'colorful';
|
||||
const newTheme = getChildTheme(child.preferences);
|
||||
|
||||
setThemeName(newThemeName);
|
||||
setCurrentTheme(newTheme);
|
||||
|
||||
console.log('🎨 Theme aktualisiert:', newThemeName, child.preferences);
|
||||
} else {
|
||||
// Fallback zum Standard-Theme
|
||||
setThemeName('colorful');
|
||||
setCurrentTheme(childThemes.colorful);
|
||||
}
|
||||
}, [child]);
|
||||
|
||||
const changeTheme = (newThemeName) => {
|
||||
if (childThemes[newThemeName]) {
|
||||
setThemeName(newThemeName);
|
||||
setCurrentTheme(childThemes[newThemeName]);
|
||||
}
|
||||
};
|
||||
|
||||
const value = {
|
||||
currentTheme,
|
||||
themeName,
|
||||
changeTheme,
|
||||
availableThemes: Object.keys(childThemes),
|
||||
};
|
||||
|
||||
return (
|
||||
<ChildThemeContext.Provider value={value}>
|
||||
<ThemeProvider theme={currentTheme}>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</ChildThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChildThemeProvider;
|
||||
279
client/src/contexts/FirebaseAuthContext.js
Normal file
279
client/src/contexts/FirebaseAuthContext.js
Normal file
@ -0,0 +1,279 @@
|
||||
// Firebase Authentication Context
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import {
|
||||
signInWithEmailAndPassword,
|
||||
createUserWithEmailAndPassword,
|
||||
signOut,
|
||||
onAuthStateChanged,
|
||||
updateProfile,
|
||||
updatePassword,
|
||||
sendPasswordResetEmail
|
||||
} from 'firebase/auth';
|
||||
import { auth } from '../firebase/config';
|
||||
|
||||
// Context erstellen
|
||||
const FirebaseAuthContext = createContext();
|
||||
|
||||
// Hook zum Verwenden des Contexts
|
||||
export const useFirebaseAuth = () => {
|
||||
const context = useContext(FirebaseAuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useFirebaseAuth muss innerhalb eines FirebaseAuthProvider verwendet werden');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Provider Component
|
||||
export const FirebaseAuthProvider = ({ children }) => {
|
||||
const [currentUser, setCurrentUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Benutzer-Status überwachen
|
||||
useEffect(() => {
|
||||
const unsubscribe = onAuthStateChanged(auth, (user) => {
|
||||
setCurrentUser(user);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return unsubscribe; // Cleanup
|
||||
}, []);
|
||||
|
||||
// Registrierung
|
||||
const register = async (email, password, displayName) => {
|
||||
try {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
// Benutzer erstellen
|
||||
const userCredential = await createUserWithEmailAndPassword(auth, email, password);
|
||||
|
||||
// Profil aktualisieren
|
||||
if (displayName) {
|
||||
await updateProfile(userCredential.user, {
|
||||
displayName: displayName
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Registrierung erfolgreich! 🎉'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Registrierungs-Fehler:', error);
|
||||
|
||||
let errorMessage = 'Fehler bei der Registrierung';
|
||||
|
||||
switch (error.code) {
|
||||
case 'auth/email-already-in-use':
|
||||
errorMessage = 'Diese E-Mail-Adresse wird bereits verwendet';
|
||||
break;
|
||||
case 'auth/weak-password':
|
||||
errorMessage = 'Das Passwort ist zu schwach (mindestens 6 Zeichen)';
|
||||
break;
|
||||
case 'auth/invalid-email':
|
||||
errorMessage = 'Ungültige E-Mail-Adresse';
|
||||
break;
|
||||
default:
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
setError(errorMessage);
|
||||
return {
|
||||
success: false,
|
||||
message: errorMessage
|
||||
};
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Anmeldung
|
||||
const login = async (email, password) => {
|
||||
try {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
await signInWithEmailAndPassword(auth, email, password);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Anmeldung erfolgreich! 👋'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Anmelde-Fehler:', error);
|
||||
|
||||
let errorMessage = 'Fehler bei der Anmeldung';
|
||||
|
||||
switch (error.code) {
|
||||
case 'auth/user-not-found':
|
||||
errorMessage = 'Benutzer nicht gefunden';
|
||||
break;
|
||||
case 'auth/wrong-password':
|
||||
errorMessage = 'Falsches Passwort';
|
||||
break;
|
||||
case 'auth/invalid-email':
|
||||
errorMessage = 'Ungültige E-Mail-Adresse';
|
||||
break;
|
||||
case 'auth/too-many-requests':
|
||||
errorMessage = 'Zu viele Anmeldeversuche. Bitte versuchen Sie es später erneut';
|
||||
break;
|
||||
default:
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
setError(errorMessage);
|
||||
return {
|
||||
success: false,
|
||||
message: errorMessage
|
||||
};
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Abmeldung
|
||||
const logout = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
await signOut(auth);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Erfolgreich abgemeldet! 👋'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Abmelde-Fehler:', error);
|
||||
setError('Fehler bei der Abmeldung');
|
||||
return {
|
||||
success: false,
|
||||
message: 'Fehler bei der Abmeldung'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Profil aktualisieren
|
||||
const updateUserProfile = async (profileData) => {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
if (!currentUser) {
|
||||
throw new Error('Kein Benutzer angemeldet');
|
||||
}
|
||||
|
||||
await updateProfile(currentUser, profileData);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Profil erfolgreich aktualisiert! ✅'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Profil-Update-Fehler:', error);
|
||||
setError('Fehler beim Aktualisieren des Profils');
|
||||
return {
|
||||
success: false,
|
||||
message: 'Fehler beim Aktualisieren des Profils'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Passwort ändern
|
||||
const changePassword = async (newPassword) => {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
if (!currentUser) {
|
||||
throw new Error('Kein Benutzer angemeldet');
|
||||
}
|
||||
|
||||
await updatePassword(currentUser, newPassword);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Passwort erfolgreich geändert! 🔒'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Passwort-Änderungs-Fehler:', error);
|
||||
|
||||
let errorMessage = 'Fehler beim Ändern des Passworts';
|
||||
|
||||
switch (error.code) {
|
||||
case 'auth/weak-password':
|
||||
errorMessage = 'Das neue Passwort ist zu schwach (mindestens 6 Zeichen)';
|
||||
break;
|
||||
case 'auth/requires-recent-login':
|
||||
errorMessage = 'Bitte melden Sie sich erneut an, um das Passwort zu ändern';
|
||||
break;
|
||||
default:
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
setError(errorMessage);
|
||||
return {
|
||||
success: false,
|
||||
message: errorMessage
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Passwort zurücksetzen
|
||||
const resetPassword = async (email) => {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
await sendPasswordResetEmail(auth, email);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Passwort-Reset-E-Mail gesendet! 📧'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Passwort-Reset-Fehler:', error);
|
||||
|
||||
let errorMessage = 'Fehler beim Senden der Reset-E-Mail';
|
||||
|
||||
switch (error.code) {
|
||||
case 'auth/user-not-found':
|
||||
errorMessage = 'Benutzer mit dieser E-Mail-Adresse nicht gefunden';
|
||||
break;
|
||||
case 'auth/invalid-email':
|
||||
errorMessage = 'Ungültige E-Mail-Adresse';
|
||||
break;
|
||||
default:
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
setError(errorMessage);
|
||||
return {
|
||||
success: false,
|
||||
message: errorMessage
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Context-Wert
|
||||
const value = {
|
||||
currentUser,
|
||||
loading,
|
||||
error,
|
||||
register,
|
||||
login,
|
||||
logout,
|
||||
updateUserProfile,
|
||||
changePassword,
|
||||
resetPassword,
|
||||
// Hilfsfunktionen
|
||||
isAuthenticated: !!currentUser,
|
||||
userEmail: currentUser?.email,
|
||||
userName: currentUser?.displayName || currentUser?.email,
|
||||
userId: currentUser?.uid
|
||||
};
|
||||
|
||||
return (
|
||||
<FirebaseAuthContext.Provider value={value}>
|
||||
{!loading && children}
|
||||
</FirebaseAuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default FirebaseAuthContext;
|
||||
116
client/src/contexts/FirebaseChildAuthContext.js
Normal file
116
client/src/contexts/FirebaseChildAuthContext.js
Normal file
@ -0,0 +1,116 @@
|
||||
// Firebase Child Authentication Context
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { loginChild, validateChildSession, logoutChild } from '../firebase/childAuth';
|
||||
import { useFirebaseAuth } from './FirebaseAuthContext';
|
||||
|
||||
const FirebaseChildAuthContext = createContext();
|
||||
|
||||
export const useFirebaseChildAuth = () => {
|
||||
const context = useContext(FirebaseChildAuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useFirebaseChildAuth muss innerhalb eines FirebaseChildAuthProvider verwendet werden');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const FirebaseChildAuthProvider = ({ children }) => {
|
||||
const [child, setChild] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { currentUser } = useFirebaseAuth(); // Eltern müssen angemeldet sein
|
||||
|
||||
// Session beim Start validieren
|
||||
useEffect(() => {
|
||||
const validateSession = async () => {
|
||||
const storedChildData = localStorage.getItem('currentChild');
|
||||
if (!storedChildData) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const childData = JSON.parse(storedChildData);
|
||||
const result = await validateChildSession(childData.id);
|
||||
|
||||
if (result.success) {
|
||||
setChild(result.child);
|
||||
} else {
|
||||
// Session ungültig - lokale Daten löschen
|
||||
localStorage.removeItem('currentChild');
|
||||
setChild(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Session-Validierung fehlgeschlagen:', error);
|
||||
localStorage.removeItem('currentChild');
|
||||
setChild(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
validateSession();
|
||||
}, [currentUser]);
|
||||
|
||||
// Kinder-Login
|
||||
const login = async (username, pin) => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result = await loginChild(username, pin, currentUser);
|
||||
|
||||
if (result.success) {
|
||||
setChild(result.child);
|
||||
localStorage.setItem('currentChild', JSON.stringify(result.child));
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Login-Fehler:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Anmeldung fehlgeschlagen'
|
||||
};
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Kinder-Logout
|
||||
const logout = () => {
|
||||
const result = logoutChild();
|
||||
setChild(null);
|
||||
return result;
|
||||
};
|
||||
|
||||
// Kind-Daten aktualisieren
|
||||
const updateChildData = (updatedChild) => {
|
||||
setChild(updatedChild);
|
||||
localStorage.setItem('currentChild', JSON.stringify(updatedChild));
|
||||
};
|
||||
|
||||
// Optional: Wenn Eltern sich abmelden, Kind auch abmelden
|
||||
// Kommentiert aus, damit Kinder unabhängig angemeldet bleiben können
|
||||
// useEffect(() => {
|
||||
// if (!currentUser && child) {
|
||||
// logout();
|
||||
// }
|
||||
// }, [currentUser, child]);
|
||||
|
||||
const value = {
|
||||
child,
|
||||
loading,
|
||||
login,
|
||||
logout,
|
||||
updateChildData,
|
||||
isChildAuthenticated: !!child,
|
||||
childName: child?.name,
|
||||
childId: child?.id
|
||||
};
|
||||
|
||||
return (
|
||||
<FirebaseChildAuthContext.Provider value={value}>
|
||||
{children}
|
||||
</FirebaseChildAuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default FirebaseChildAuthContext;
|
||||
180
client/src/contexts/NotificationContext.js
Normal file
180
client/src/contexts/NotificationContext.js
Normal file
@ -0,0 +1,180 @@
|
||||
import React, { createContext, useContext, useState } from 'react';
|
||||
import { Snackbar, Alert, AlertTitle } from '@mui/material';
|
||||
|
||||
const NotificationContext = createContext();
|
||||
|
||||
export const useNotification = () => {
|
||||
const context = useContext(NotificationContext);
|
||||
if (!context) {
|
||||
throw new Error('useNotification muss innerhalb eines NotificationProvider verwendet werden');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const NotificationProvider = ({ children }) => {
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
|
||||
// Benachrichtigung hinzufügen
|
||||
const showNotification = (message, type = 'info', title = null, duration = 6000) => {
|
||||
const id = Date.now() + Math.random();
|
||||
const notification = {
|
||||
id,
|
||||
message,
|
||||
type, // 'success', 'error', 'warning', 'info'
|
||||
title,
|
||||
duration,
|
||||
open: true
|
||||
};
|
||||
|
||||
setNotifications(prev => [...prev, notification]);
|
||||
|
||||
// Auto-Hide nach duration
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
hideNotification(id);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
};
|
||||
|
||||
// Benachrichtigung ausblenden
|
||||
const hideNotification = (id) => {
|
||||
setNotifications(prev =>
|
||||
prev.map(notification =>
|
||||
notification.id === id
|
||||
? { ...notification, open: false }
|
||||
: notification
|
||||
)
|
||||
);
|
||||
|
||||
// Nach Animation entfernen
|
||||
setTimeout(() => {
|
||||
setNotifications(prev =>
|
||||
prev.filter(notification => notification.id !== id)
|
||||
);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// Alle Benachrichtigungen löschen
|
||||
const clearNotifications = () => {
|
||||
setNotifications([]);
|
||||
};
|
||||
|
||||
// Convenience-Methoden
|
||||
const showSuccess = (message, title = null, duration = 4000) => {
|
||||
return showNotification(message, 'success', title, duration);
|
||||
};
|
||||
|
||||
const showError = (message, title = 'Fehler', duration = 8000) => {
|
||||
return showNotification(message, 'error', title, duration);
|
||||
};
|
||||
|
||||
const showWarning = (message, title = 'Warnung', duration = 6000) => {
|
||||
return showNotification(message, 'warning', title, duration);
|
||||
};
|
||||
|
||||
const showInfo = (message, title = null, duration = 5000) => {
|
||||
return showNotification(message, 'info', title, duration);
|
||||
};
|
||||
|
||||
// Spezielle Methoden für Familien-Held
|
||||
const showTaskCompleted = (taskTitle, childName, points) => {
|
||||
return showSuccess(
|
||||
`${childName} hat "${taskTitle}" erledigt und ${points} Punkte erhalten! 🎉`,
|
||||
'Aufgabe erledigt!',
|
||||
5000
|
||||
);
|
||||
};
|
||||
|
||||
const showRewardRedeemed = (rewardTitle, childName, points) => {
|
||||
return showSuccess(
|
||||
`${childName} hat "${rewardTitle}" für ${points} Punkte eingelöst! 🎁`,
|
||||
'Belohnung eingelöst!',
|
||||
5000
|
||||
);
|
||||
};
|
||||
|
||||
const showLevelUp = (childName, newLevel, levelTitle) => {
|
||||
return showSuccess(
|
||||
`${childName} hat Level ${newLevel} erreicht: ${levelTitle}! 🆙`,
|
||||
'Level Up!',
|
||||
6000
|
||||
);
|
||||
};
|
||||
|
||||
const showAchievement = (childName, achievementName) => {
|
||||
return showSuccess(
|
||||
`${childName} hat ein neues Achievement erhalten: ${achievementName}! 🏆`,
|
||||
'Neues Achievement!',
|
||||
6000
|
||||
);
|
||||
};
|
||||
|
||||
const value = {
|
||||
notifications,
|
||||
showNotification,
|
||||
hideNotification,
|
||||
clearNotifications,
|
||||
showSuccess,
|
||||
showError,
|
||||
showWarning,
|
||||
showInfo,
|
||||
// Spezielle Methoden
|
||||
showTaskCompleted,
|
||||
showRewardRedeemed,
|
||||
showLevelUp,
|
||||
showAchievement
|
||||
};
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={value}>
|
||||
{children}
|
||||
|
||||
{/* Benachrichtigungen rendern */}
|
||||
{notifications.map((notification) => (
|
||||
<Snackbar
|
||||
key={notification.id}
|
||||
open={notification.open}
|
||||
autoHideDuration={null} // Wir handhaben das manuell
|
||||
onClose={() => hideNotification(notification.id)}
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
sx={{
|
||||
mt: notification.id === notifications[0]?.id ? 2 : 0,
|
||||
'& .MuiSnackbar-root': {
|
||||
position: 'relative',
|
||||
transform: 'none',
|
||||
mb: 1
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Alert
|
||||
onClose={() => hideNotification(notification.id)}
|
||||
severity={notification.type}
|
||||
variant="filled"
|
||||
sx={{
|
||||
width: '100%',
|
||||
minWidth: 300,
|
||||
maxWidth: 500,
|
||||
borderRadius: 2,
|
||||
boxShadow: 3,
|
||||
'& .MuiAlert-message': {
|
||||
fontSize: '0.95rem',
|
||||
lineHeight: 1.4
|
||||
}
|
||||
}}
|
||||
>
|
||||
{notification.title && (
|
||||
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
{notification.title}
|
||||
</AlertTitle>
|
||||
)}
|
||||
{notification.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
))}
|
||||
</NotificationContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationContext;
|
||||
217
client/src/firebase/auth.js
Normal file
217
client/src/firebase/auth.js
Normal file
@ -0,0 +1,217 @@
|
||||
// Firebase Authentication Service
|
||||
import {
|
||||
signInWithEmailAndPassword,
|
||||
createUserWithEmailAndPassword,
|
||||
signOut,
|
||||
updateProfile,
|
||||
updatePassword,
|
||||
EmailAuthProvider,
|
||||
reauthenticateWithCredential
|
||||
} from 'firebase/auth';
|
||||
import { doc, setDoc, getDoc, updateDoc } from 'firebase/firestore';
|
||||
import { auth, db } from './config';
|
||||
|
||||
// Benutzer registrieren
|
||||
export const registerUser = async (userData) => {
|
||||
try {
|
||||
const { email, password, familyName, parentName } = userData;
|
||||
|
||||
// Firebase Auth Account erstellen
|
||||
const userCredential = await createUserWithEmailAndPassword(auth, email, password);
|
||||
const user = userCredential.user;
|
||||
|
||||
// Profil in Firebase Auth aktualisieren
|
||||
await updateProfile(user, {
|
||||
displayName: parentName
|
||||
});
|
||||
|
||||
// Zusätzliche Benutzerdaten in Firestore speichern
|
||||
await setDoc(doc(db, 'users', user.uid), {
|
||||
email: user.email,
|
||||
familyName,
|
||||
parentName,
|
||||
role: 'parent',
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
preferences: {
|
||||
notifications: true,
|
||||
theme: 'light'
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Registrierung erfolgreich! 🎉',
|
||||
user: {
|
||||
uid: user.uid,
|
||||
email: user.email,
|
||||
familyName,
|
||||
parentName
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Registrierungsfehler:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: getErrorMessage(error.code)
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Benutzer anmelden
|
||||
export const loginUser = async (credentials) => {
|
||||
try {
|
||||
const { email, password } = credentials;
|
||||
|
||||
const userCredential = await signInWithEmailAndPassword(auth, email, password);
|
||||
const user = userCredential.user;
|
||||
|
||||
// Zusätzliche Benutzerdaten aus Firestore laden
|
||||
const userDoc = await getDoc(doc(db, 'users', user.uid));
|
||||
const userData = userDoc.data();
|
||||
|
||||
if (!userData || !userData.isActive) {
|
||||
throw new Error('Account ist deaktiviert');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Anmeldung erfolgreich! 🎉',
|
||||
user: {
|
||||
uid: user.uid,
|
||||
email: user.email,
|
||||
...userData
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Anmeldungsfehler:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: getErrorMessage(error.code)
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Benutzer abmelden
|
||||
export const logoutUser = async () => {
|
||||
try {
|
||||
await signOut(auth);
|
||||
return {
|
||||
success: true,
|
||||
message: 'Erfolgreich abgemeldet! 👋'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Abmeldungsfehler:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Fehler beim Abmelden'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Profil aktualisieren
|
||||
export const updateUserProfile = async (profileData) => {
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) throw new Error('Nicht angemeldet');
|
||||
|
||||
const { familyName, parentName, preferences } = profileData;
|
||||
|
||||
// Firebase Auth Profil aktualisieren
|
||||
if (parentName) {
|
||||
await updateProfile(user, {
|
||||
displayName: parentName
|
||||
});
|
||||
}
|
||||
|
||||
// Firestore Dokument aktualisieren
|
||||
const updateData = {};
|
||||
if (familyName) updateData.familyName = familyName;
|
||||
if (parentName) updateData.parentName = parentName;
|
||||
if (preferences) updateData.preferences = preferences;
|
||||
updateData.updatedAt = new Date();
|
||||
|
||||
await updateDoc(doc(db, 'users', user.uid), updateData);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Profil erfolgreich aktualisiert! ✅'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Profil-Update-Fehler:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Fehler beim Aktualisieren des Profils'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Passwort ändern
|
||||
export const changeUserPassword = async (passwordData) => {
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) throw new Error('Nicht angemeldet');
|
||||
|
||||
const { currentPassword, newPassword } = passwordData;
|
||||
|
||||
// Benutzer re-authentifizieren
|
||||
const credential = EmailAuthProvider.credential(user.email, currentPassword);
|
||||
await reauthenticateWithCredential(user, credential);
|
||||
|
||||
// Neues Passwort setzen
|
||||
await updatePassword(user, newPassword);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Passwort erfolgreich geändert! 🔒'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Passwort-Änderungs-Fehler:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: getErrorMessage(error.code)
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Aktuellen Benutzer laden
|
||||
export const getCurrentUser = async () => {
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) return null;
|
||||
|
||||
const userDoc = await getDoc(doc(db, 'users', user.uid));
|
||||
const userData = userDoc.data();
|
||||
|
||||
return {
|
||||
uid: user.uid,
|
||||
email: user.email,
|
||||
...userData
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Benutzer-Abruf-Fehler:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Fehler-Nachrichten übersetzen
|
||||
const getErrorMessage = (errorCode) => {
|
||||
switch (errorCode) {
|
||||
case 'auth/user-not-found':
|
||||
return 'Benutzer nicht gefunden';
|
||||
case 'auth/wrong-password':
|
||||
return 'Falsches Passwort';
|
||||
case 'auth/email-already-in-use':
|
||||
return 'E-Mail-Adresse wird bereits verwendet';
|
||||
case 'auth/weak-password':
|
||||
return 'Passwort ist zu schwach';
|
||||
case 'auth/invalid-email':
|
||||
return 'Ungültige E-Mail-Adresse';
|
||||
case 'auth/too-many-requests':
|
||||
return 'Zu viele Anmeldeversuche. Bitte versuche es später erneut';
|
||||
case 'auth/requires-recent-login':
|
||||
return 'Bitte melde dich erneut an, um diese Aktion durchzuführen';
|
||||
default:
|
||||
return 'Ein unbekannter Fehler ist aufgetreten';
|
||||
}
|
||||
};
|
||||
153
client/src/firebase/childAuth.js
Normal file
153
client/src/firebase/childAuth.js
Normal file
@ -0,0 +1,153 @@
|
||||
// Firebase Child Authentication Service
|
||||
import {
|
||||
collection,
|
||||
query,
|
||||
where,
|
||||
getDocs,
|
||||
doc,
|
||||
getDoc,
|
||||
updateDoc,
|
||||
serverTimestamp
|
||||
} from 'firebase/firestore';
|
||||
import { auth, db } from './config';
|
||||
|
||||
// Kinder-Login mit Benutzername und PIN
|
||||
export const loginChild = async (username, pin, currentUser = null) => {
|
||||
try {
|
||||
// Versuche zuerst mit angemeldetem Elternteil
|
||||
const user = currentUser || auth.currentUser;
|
||||
|
||||
let q;
|
||||
const childrenRef = collection(db, 'children');
|
||||
|
||||
if (user) {
|
||||
// Kind mit Benutzername und parentId suchen (wenn Eltern angemeldet)
|
||||
q = query(
|
||||
childrenRef,
|
||||
where('username', '==', username),
|
||||
where('parentId', '==', user.uid),
|
||||
where('isActive', '==', true)
|
||||
);
|
||||
} else {
|
||||
// Kind nur mit Benutzername suchen (wenn keine Eltern angemeldet)
|
||||
q = query(
|
||||
childrenRef,
|
||||
where('username', '==', username),
|
||||
where('isActive', '==', true)
|
||||
);
|
||||
}
|
||||
|
||||
const querySnapshot = await getDocs(q);
|
||||
|
||||
if (querySnapshot.empty) {
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Benutzername nicht gefunden. Stelle sicher, dass deine Eltern angemeldet sind.'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Benutzername nicht gefunden oder gehört nicht zu diesem Elternkonto.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const childDoc = querySnapshot.docs[0];
|
||||
const childData = childDoc.data();
|
||||
|
||||
// PIN überprüfen (einfacher Vergleich - in Produktion sollte gehashed werden)
|
||||
if (childData.pin !== pin) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Falsche PIN'
|
||||
};
|
||||
}
|
||||
|
||||
// Letzte Aktivität aktualisieren
|
||||
await updateDoc(doc(db, 'children', childDoc.id), {
|
||||
lastActivity: serverTimestamp()
|
||||
});
|
||||
|
||||
// Kind-Daten für Session vorbereiten
|
||||
const child = {
|
||||
id: childDoc.id,
|
||||
...childData,
|
||||
lastActivity: new Date()
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Willkommen zurück, ${childData.name}! 🎉`,
|
||||
child
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Kinder-Login-Fehler:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Anmeldung fehlgeschlagen'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Kind-Token validieren (für Session-Management)
|
||||
export const validateChildSession = async (childId) => {
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
|
||||
// Kind-Daten laden
|
||||
const childDoc = await getDoc(doc(db, 'children', childId));
|
||||
|
||||
if (!childDoc.exists()) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Kind nicht gefunden'
|
||||
};
|
||||
}
|
||||
|
||||
const childData = childDoc.data();
|
||||
|
||||
// Wenn Eltern angemeldet sind, überprüfen ob Kind zu ihnen gehört
|
||||
if (user && (childData.parentId !== user.uid || !childData.isActive)) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Ungültige Session - Kind gehört nicht zu diesem Elternkonto'
|
||||
};
|
||||
}
|
||||
|
||||
// Wenn keine Eltern angemeldet sind, nur prüfen ob Kind aktiv ist
|
||||
if (!user && !childData.isActive) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Kind-Account ist nicht aktiv'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
child: {
|
||||
id: childDoc.id,
|
||||
...childData
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Session-Validierung-Fehler:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Session-Validierung fehlgeschlagen'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Kind abmelden
|
||||
export const logoutChild = () => {
|
||||
// Lokale Session-Daten löschen
|
||||
localStorage.removeItem('childToken');
|
||||
localStorage.removeItem('childData');
|
||||
localStorage.removeItem('currentChild');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Erfolgreich abgemeldet'
|
||||
};
|
||||
};
|
||||
33
client/src/firebase/config.js
Normal file
33
client/src/firebase/config.js
Normal file
@ -0,0 +1,33 @@
|
||||
// Firebase Konfiguration
|
||||
import { initializeApp } from 'firebase/app';
|
||||
import { getAuth } from 'firebase/auth';
|
||||
import { getFirestore } from 'firebase/firestore';
|
||||
import { getFunctions } from 'firebase/functions';
|
||||
|
||||
// Firebase Konfiguration - Diese Werte bekommst du aus der Firebase Console
|
||||
const firebaseConfig = {
|
||||
apiKey: process.env.REACT_APP_FIREBASE_API_KEY,
|
||||
authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN,
|
||||
projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID,
|
||||
storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET,
|
||||
messagingSenderId: process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID,
|
||||
appId: process.env.REACT_APP_FIREBASE_APP_ID
|
||||
};
|
||||
|
||||
// Firebase initialisieren
|
||||
const app = initializeApp(firebaseConfig);
|
||||
|
||||
// Firebase Services
|
||||
export const auth = getAuth(app);
|
||||
export const db = getFirestore(app);
|
||||
export const functions = getFunctions(app);
|
||||
|
||||
// Für Entwicklung - Firebase Emulator verwenden
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
// Uncomment these lines if you want to use Firebase Emulator
|
||||
// connectAuthEmulator(auth, 'http://localhost:9099');
|
||||
// connectFirestoreEmulator(db, 'localhost', 8080);
|
||||
// connectFunctionsEmulator(functions, 'localhost', 5001);
|
||||
}
|
||||
|
||||
export default app;
|
||||
1002
client/src/firebase/database.js
Normal file
1002
client/src/firebase/database.js
Normal file
File diff suppressed because it is too large
Load Diff
13
client/src/index.css
Normal file
13
client/src/index.css
Normal file
@ -0,0 +1,13 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
17
client/src/index.js
Normal file
17
client/src/index.js
Normal file
@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
||||
1
client/src/logo.svg
Normal file
1
client/src/logo.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
625
client/src/pages/ChildDashboard.js
Normal file
625
client/src/pages/ChildDashboard.js
Normal file
@ -0,0 +1,625 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useFirebaseChildAuth } from '../contexts/FirebaseChildAuthContext';
|
||||
import { useChildTheme } from '../contexts/ChildThemeContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
Avatar,
|
||||
Chip,
|
||||
LinearProgress,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
useMediaQuery
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack,
|
||||
CheckCircle,
|
||||
Star,
|
||||
EmojiEvents,
|
||||
Assignment,
|
||||
ShoppingCart,
|
||||
Celebration,
|
||||
Logout
|
||||
} from '@mui/icons-material';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import Confetti from 'react-confetti';
|
||||
import { getChildTasks, completeTask, getChildRewards, redeemReward } from '../firebase/database';
|
||||
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
|
||||
const ChildDashboard = () => {
|
||||
const navigate = useNavigate();
|
||||
const { currentTheme, themeName } = useChildTheme();
|
||||
const isMobile = useMediaQuery(currentTheme.breakpoints.down('md'));
|
||||
const { child, logout } = useFirebaseChildAuth();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
|
||||
// Redirect if no child is authenticated
|
||||
useEffect(() => {
|
||||
if (!child) {
|
||||
navigate('/child-login');
|
||||
}
|
||||
}, [child, navigate]);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [rewards, setRewards] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showConfetti, setShowConfetti] = useState(false);
|
||||
const [celebrationDialog, setCelebrationDialog] = useState({ open: false, task: null });
|
||||
const [windowSize, setWindowSize] = useState({ width: window.innerWidth, height: window.innerHeight });
|
||||
|
||||
// Fenster-Größe für Konfetti tracken
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setWindowSize({ width: window.innerWidth, height: window.innerHeight });
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
// Kind-Daten laden
|
||||
useEffect(() => {
|
||||
const loadChildData = async () => {
|
||||
if (!child) return;
|
||||
|
||||
try {
|
||||
const [tasksResult, rewardsResult] = await Promise.all([
|
||||
getChildTasks(child.uid),
|
||||
getChildRewards(child.uid)
|
||||
]);
|
||||
|
||||
if (tasksResult.success) {
|
||||
setTasks(tasksResult.tasks || []);
|
||||
}
|
||||
|
||||
if (rewardsResult.success) {
|
||||
setRewards(rewardsResult.rewards || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Kind-Daten:', error);
|
||||
showError('Fehler beim Laden der Daten');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (child) {
|
||||
loadChildData();
|
||||
}
|
||||
}, [child, showError, logout]);
|
||||
|
||||
const handleTaskComplete = async (taskId, taskTitle) => {
|
||||
try {
|
||||
const result = await completeTask(taskId);
|
||||
|
||||
if (result.success) {
|
||||
// Aufgabe als erledigt markieren
|
||||
setTasks(prev => prev.map(task =>
|
||||
task.id === taskId
|
||||
? { ...task, status: 'completed', completedAt: new Date() }
|
||||
: task
|
||||
));
|
||||
|
||||
// Celebration anzeigen
|
||||
setCelebrationDialog({ open: true, task: { title: taskTitle } });
|
||||
setShowConfetti(true);
|
||||
|
||||
// Konfetti nach 3 Sekunden ausblenden
|
||||
setTimeout(() => setShowConfetti(false), 3000);
|
||||
|
||||
showSuccess(result.message);
|
||||
} else {
|
||||
showError(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('Fehler beim Markieren der Aufgabe');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRewardRedeem = async (rewardId, rewardTitle, cost) => {
|
||||
try {
|
||||
const result = await redeemReward(rewardId, child.uid);
|
||||
|
||||
if (result.success) {
|
||||
// Kind-Daten über Context aktualisieren
|
||||
// Die Punkte werden automatisch über den Context aktualisiert
|
||||
|
||||
showSuccess(result.message);
|
||||
|
||||
// Belohnungen neu laden um aktuelle Punkte zu zeigen
|
||||
const rewardsResult = await getChildRewards(child.uid);
|
||||
if (rewardsResult.success) {
|
||||
setRewards(rewardsResult.rewards || []);
|
||||
}
|
||||
} else {
|
||||
showError(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message || 'Fehler beim Einlösen der Belohnung');
|
||||
}
|
||||
};
|
||||
|
||||
const getTaskIcon = (category) => {
|
||||
const icons = {
|
||||
household: '🏠',
|
||||
personal: '👤',
|
||||
homework: '📚',
|
||||
chores: '🧹',
|
||||
hygiene: '🚿',
|
||||
pets: '🐕',
|
||||
garden: '🌱',
|
||||
helping: '🤝',
|
||||
learning: '🎓',
|
||||
exercise: '🏃',
|
||||
other: '📝'
|
||||
};
|
||||
return icons[category] || '📝';
|
||||
};
|
||||
|
||||
const getRewardIcon = (category) => {
|
||||
const icons = {
|
||||
'screen-time': '📱',
|
||||
treats: '🍭',
|
||||
toys: '🧸',
|
||||
activities: '🎨',
|
||||
outings: '🎢',
|
||||
privileges: '⭐',
|
||||
money: '💰',
|
||||
special: '🎁',
|
||||
electronics: '🎮',
|
||||
books: '📚',
|
||||
sports: '⚽',
|
||||
art: '🎨',
|
||||
other: '🎁'
|
||||
};
|
||||
return icons[category] || '🎁';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
|
||||
<motion.div
|
||||
animate={{ rotate: 360, scale: [1, 1.2, 1] }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: 'linear' }}
|
||||
>
|
||||
<Typography variant="h1" sx={{ fontSize: '4rem' }}>🦸♂️</Typography>
|
||||
</motion.div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!child) {
|
||||
return (
|
||||
<Box sx={{ textAlign: 'center', py: 8 }}>
|
||||
<Typography variant="h4">Kind nicht gefunden</Typography>
|
||||
<Button onClick={() => navigate('/child-login')} sx={{ mt: 2 }}>
|
||||
Zurück zur Anmeldung
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const pendingTasks = tasks.filter(task => task.status === 'pending');
|
||||
const completedTasks = tasks.filter(task => task.status === 'completed');
|
||||
const affordableRewards = rewards.filter(reward => reward.cost <= (child.points || 0));
|
||||
|
||||
return (
|
||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default' }}>
|
||||
{/* Konfetti */}
|
||||
<AnimatePresence>
|
||||
{showConfetti && (
|
||||
<Confetti
|
||||
width={windowSize.width}
|
||||
height={windowSize.height}
|
||||
recycle={false}
|
||||
numberOfPieces={200}
|
||||
gravity={0.3}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* App Bar */}
|
||||
<AppBar
|
||||
position="static"
|
||||
elevation={0}
|
||||
sx={{
|
||||
background: `linear-gradient(135deg, ${currentTheme.palette.primary.main} 0%, ${currentTheme.palette.primary.light} 100%)`,
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.1)'
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
edge="start"
|
||||
color="inherit"
|
||||
onClick={() => {
|
||||
logout();
|
||||
navigate('/child-login');
|
||||
}}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
<Logout />
|
||||
</IconButton>
|
||||
|
||||
<Chip
|
||||
label={`🎨 ${themeName}`}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'white',
|
||||
mr: 2,
|
||||
fontWeight: 600
|
||||
}}
|
||||
/>
|
||||
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
mr: 2,
|
||||
bgcolor: 'rgba(255, 255, 255, 0.2)'
|
||||
}}
|
||||
>
|
||||
{child.name.charAt(0)}
|
||||
</Avatar>
|
||||
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>
|
||||
Hallo {child.name}! 👋
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ opacity: 0.9 }}>
|
||||
{child.level?.title || 'Nachwuchs-Held'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ textAlign: 'right' }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>
|
||||
⭐ {child.points || 0}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ opacity: 0.9 }}>
|
||||
Punkte
|
||||
</Typography>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
{/* Level-Fortschritt */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<Card sx={{ mb: 4, background: 'linear-gradient(135deg, #FF9800 0%, #FFB74D 100%)', color: 'white' }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h4" sx={{ mr: 2 }}>🏆</Typography>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>
|
||||
Level {child.level?.current || 1}: {child.level?.title || 'Nachwuchs-Held'}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ opacity: 0.9 }}>
|
||||
{((child.points?.total || child.points || 0) % 100)}/100 Punkte bis zum nächsten Level
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={((child.points?.total || child.points || 0) % 100)}
|
||||
sx={{
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
bgcolor: 'rgba(255, 255, 255, 0.3)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
bgcolor: 'white'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Heutige Aufgaben */}
|
||||
<Grid size={{ xs: 12, md: 8 }}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 3, display: 'flex', alignItems: 'center' }}>
|
||||
📝 Deine Aufgaben heute
|
||||
<Chip
|
||||
label={`${pendingTasks.length} offen`}
|
||||
color="primary"
|
||||
size="small"
|
||||
sx={{ ml: 2 }}
|
||||
/>
|
||||
</Typography>
|
||||
|
||||
{pendingTasks.length > 0 ? (
|
||||
<List>
|
||||
{pendingTasks.map((task, index) => (
|
||||
<motion.div
|
||||
key={task.id}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.4, delay: index * 0.1 }}
|
||||
>
|
||||
<ListItem
|
||||
sx={{
|
||||
border: '2px solid',
|
||||
borderColor: 'primary.light',
|
||||
borderRadius: 3,
|
||||
mb: 2,
|
||||
bgcolor: 'background.paper',
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover',
|
||||
transform: 'scale(1.02)',
|
||||
transition: 'all 0.2s ease'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Typography variant="h3">
|
||||
{getTaskIcon(task.category)}
|
||||
</Typography>
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
{task.title || 'Unbekannte Aufgabe'}
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
<span>
|
||||
{task.description && (
|
||||
<Typography variant="body2" color="text.secondary" component="div" sx={{ mb: 1 }}>
|
||||
{task.description}
|
||||
</Typography>
|
||||
)}
|
||||
<Chip
|
||||
label={`⭐ ${task.points} Punkte`}
|
||||
color="secondary"
|
||||
size="small"
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<CheckCircle />}
|
||||
onClick={() => handleTaskComplete(task.id, task.title || 'Unbekannte Aufgabe')}
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
px: 3,
|
||||
py: 1.5,
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 600
|
||||
}}
|
||||
>
|
||||
Erledigt!
|
||||
</Button>
|
||||
</ListItem>
|
||||
</motion.div>
|
||||
))}
|
||||
</List>
|
||||
) : (
|
||||
<Box sx={{ textAlign: 'center', py: 6 }}>
|
||||
<Typography variant="h2" sx={{ mb: 2 }}>🎉</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Super! Alle Aufgaben erledigt!
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Du bist ein echter Held! 🦸♂️
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Erledigte Aufgaben */}
|
||||
{completedTasks.length > 0 && (
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2, color: 'success.main' }}>
|
||||
✅ Heute erledigt ({completedTasks.length})
|
||||
</Typography>
|
||||
<List>
|
||||
{completedTasks.map((task) => (
|
||||
<ListItem
|
||||
key={task.id}
|
||||
sx={{
|
||||
border: '1px solid',
|
||||
borderColor: 'success.light',
|
||||
borderRadius: 2,
|
||||
mb: 1,
|
||||
bgcolor: 'success.light',
|
||||
opacity: 0.8
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<CheckCircle color="success" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={task.title}
|
||||
secondary="Wartet auf Bestätigung der Eltern"
|
||||
/>
|
||||
<Chip
|
||||
label={`⭐ ${task.points}`}
|
||||
color="success"
|
||||
size="small"
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
|
||||
{/* Belohnungs-Shop */}
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.4 }}
|
||||
>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 3, display: 'flex', alignItems: 'center' }}>
|
||||
🛒 Belohnungs-Shop
|
||||
</Typography>
|
||||
|
||||
{affordableRewards.length > 0 ? (
|
||||
<List>
|
||||
{affordableRewards.slice(0, 5).map((reward, index) => (
|
||||
<motion.div
|
||||
key={reward.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, delay: index * 0.1 }}
|
||||
>
|
||||
<ListItem
|
||||
sx={{
|
||||
border: '1px solid',
|
||||
borderColor: 'secondary.light',
|
||||
borderRadius: 2,
|
||||
mb: 2,
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
p: 2
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h4" sx={{ mr: 2 }}>
|
||||
{getRewardIcon(reward.category)}
|
||||
</Typography>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
{reward.title || 'Unbekannte Belohnung'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{reward.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Chip
|
||||
label={`⭐ ${reward.cost}`}
|
||||
color="secondary"
|
||||
size="small"
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<ShoppingCart />}
|
||||
onClick={() => handleRewardRedeem(reward.id, reward.title || 'Unbekannte Belohnung', reward.cost)}
|
||||
disabled={(child.points || 0) < reward.cost}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
Einlösen
|
||||
</Button>
|
||||
</Box>
|
||||
</ListItem>
|
||||
</motion.div>
|
||||
))}
|
||||
</List>
|
||||
) : (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Typography variant="h3" sx={{ mb: 2 }}>💪</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Sammle mehr Punkte, um tolle Belohnungen zu bekommen!
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Du hast {child.points || 0} Punkte
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{rewards.length > affordableRewards.length && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ mt: 2, textAlign: 'center' }}
|
||||
>
|
||||
{rewards.length - affordableRewards.length} weitere Belohnungen verfügbar
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
|
||||
{/* Celebration Dialog */}
|
||||
<Dialog
|
||||
open={celebrationDialog.open}
|
||||
onClose={() => setCelebrationDialog({ open: false, task: null })}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 4,
|
||||
textAlign: 'center',
|
||||
background: 'linear-gradient(135deg, #4CAF50 0%, #81C784 100%)',
|
||||
color: 'white'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent sx={{ py: 6 }}>
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ duration: 0.5, type: 'spring' }}
|
||||
>
|
||||
<Typography variant="h2" sx={{ mb: 2 }}>🎉</Typography>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, mb: 2 }}>
|
||||
Fantastisch!
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ mb: 3, opacity: 0.9 }}>
|
||||
Du hast "{celebrationDialog.task?.title || 'die Aufgabe'}" erledigt!
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ mb: 4, opacity: 0.8 }}>
|
||||
Deine Eltern werden das überprüfen und dir dann die Punkte geben.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={() => setCelebrationDialog({ open: false, task: null })}
|
||||
sx={{
|
||||
bgcolor: 'white',
|
||||
color: 'primary.main',
|
||||
fontWeight: 600,
|
||||
px: 4,
|
||||
py: 1.5,
|
||||
'&:hover': {
|
||||
bgcolor: 'grey.100'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Weiter so! 💪
|
||||
</Button>
|
||||
</motion.div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChildDashboard;
|
||||
227
client/src/pages/ChildLoginPage.js
Normal file
227
client/src/pages/ChildLoginPage.js
Normal file
@ -0,0 +1,227 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
TextField,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
IconButton,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
InputAdornment
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack,
|
||||
Person,
|
||||
Lock,
|
||||
Visibility,
|
||||
VisibilityOff
|
||||
} from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useFirebaseChildAuth } from '../contexts/FirebaseChildAuthContext';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
|
||||
const ChildLoginPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
const { login } = useFirebaseChildAuth();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
pin: ''
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPin, setShowPin] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleInputChange = (field, value) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
if (error) setError(''); // Clear error when user starts typing
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!formData.username || !formData.pin) {
|
||||
setError('Bitte gib deinen Benutzernamen und deine PIN ein');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const result = await login(formData.username, formData.pin);
|
||||
|
||||
if (result.success) {
|
||||
showSuccess(result.message);
|
||||
navigate('/child-dashboard');
|
||||
} else {
|
||||
setError(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login-Fehler:', error);
|
||||
setError('Anmeldung fehlgeschlagen. Überprüfe deine Daten.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleLogin();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default' }}>
|
||||
{/* Header */}
|
||||
<AppBar position="static" elevation={0}>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
edge="start"
|
||||
color="inherit"
|
||||
onClick={() => navigate('/')}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Typography variant="h6" sx={{ flexGrow: 1, fontWeight: 700 }}>
|
||||
🦸♂️ Kinder-Anmeldung
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Container maxWidth="sm" sx={{ py: 4 }}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{/* Willkommens-Text */}
|
||||
<Box sx={{ textAlign: 'center', mb: 4 }}>
|
||||
<Typography variant="h3" sx={{ fontWeight: 700, mb: 2 }}>
|
||||
👋 Hallo kleiner Held!
|
||||
</Typography>
|
||||
<Typography variant="h6" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Melde dich mit deinem Benutzernamen und deiner PIN an
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{
|
||||
bgcolor: 'info.light',
|
||||
color: 'info.contrastText',
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
maxWidth: 400,
|
||||
mx: 'auto'
|
||||
}}>
|
||||
💡 <strong>Tipp:</strong> Wenn deine Eltern angemeldet sind, funktioniert die Anmeldung am besten!
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Login-Formular */}
|
||||
<Card sx={{ p: 3 }}>
|
||||
<CardContent>
|
||||
{/* Fehler-Anzeige */}
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Benutzername"
|
||||
value={formData.username}
|
||||
onChange={(e) => handleInputChange('username', e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Person />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="PIN"
|
||||
type={showPin ? 'text' : 'password'}
|
||||
value={formData.pin}
|
||||
onChange={(e) => handleInputChange('pin', e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Lock />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
onClick={() => setShowPin(!showPin)}
|
||||
edge="end"
|
||||
>
|
||||
{showPin ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={handleLogin}
|
||||
disabled={loading || !formData.username || !formData.pin}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 600,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<CircularProgress size={24} color="inherit" />
|
||||
) : (
|
||||
'Anmelden 🚀'
|
||||
)}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Box sx={{ textAlign: 'center', mt: 3 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Probleme beim Anmelden? Frage deine Eltern! 👨👩👧👦
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => navigate('/login')}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
Eltern-Anmeldung
|
||||
</Button>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChildLoginPage;
|
||||
986
client/src/pages/ChildrenManagement.js
Normal file
986
client/src/pages/ChildrenManagement.js
Normal file
@ -0,0 +1,986 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
TextField,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Chip,
|
||||
Avatar,
|
||||
IconButton,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
LinearProgress,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction,
|
||||
Divider,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack,
|
||||
Add,
|
||||
Edit,
|
||||
Delete,
|
||||
Person,
|
||||
Star,
|
||||
TrendingUp,
|
||||
Assignment,
|
||||
CardGiftcard,
|
||||
EmojiEvents,
|
||||
Settings
|
||||
} from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useFirebaseAuth } from '../contexts/FirebaseAuthContext';
|
||||
import { getChildren, createChild, updateChild, getChildStats, deleteChild, updateChildPoints, addChildAchievement } from '../firebase/database';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
|
||||
const ChildrenManagement = () => {
|
||||
const navigate = useNavigate();
|
||||
const { currentUser } = useFirebaseAuth();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
|
||||
const [children, setChildren] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedChild, setSelectedChild] = useState(null);
|
||||
const [childStats, setChildStats] = useState(null);
|
||||
|
||||
// Dialog States
|
||||
const [childDialog, setChildDialog] = useState({ open: false, child: null, mode: 'create' });
|
||||
const [deleteDialog, setDeleteDialog] = useState({ open: false, child: null });
|
||||
const [pointsDialog, setPointsDialog] = useState({ open: false, child: null });
|
||||
const [achievementDialog, setAchievementDialog] = useState({ open: false, child: null });
|
||||
|
||||
// Form State
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
age: 6,
|
||||
username: '',
|
||||
pin: '',
|
||||
avatar: 'default-child.png',
|
||||
preferences: {
|
||||
theme: 'colorful',
|
||||
language: 'de',
|
||||
notifications: true
|
||||
}
|
||||
});
|
||||
|
||||
const [pointsData, setPointsData] = useState({
|
||||
amount: 0,
|
||||
reason: '',
|
||||
type: 'add'
|
||||
});
|
||||
|
||||
const [achievementData, setAchievementData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
icon: '🏆',
|
||||
category: 'general'
|
||||
});
|
||||
|
||||
const avatars = [
|
||||
{ value: 'default-child.png', label: '👦 Standard Junge', emoji: '👦' },
|
||||
{ value: 'superhero-boy.png', label: '🦸♂️ Superheld Junge', emoji: '🦸♂️' },
|
||||
{ value: 'superhero-girl.png', label: '🦸♀️ Superheldin Mädchen', emoji: '🦸♀️' },
|
||||
{ value: 'pirate-boy.png', label: '🏴☠️ Pirat Junge', emoji: '🏴☠️' },
|
||||
{ value: 'pirate-girl.png', label: '🏴☠️ Piratin Mädchen', emoji: '🏴☠️' },
|
||||
{ value: 'princess.png', label: '👸 Prinzessin', emoji: '👸' },
|
||||
{ value: 'knight.png', label: '🛡️ Ritter', emoji: '🛡️' },
|
||||
{ value: 'astronaut.png', label: '🚀 Astronaut', emoji: '🚀' },
|
||||
{ value: 'detective.png', label: '🕵️ Detektiv', emoji: '🕵️' },
|
||||
{ value: 'artist.png', label: '🎨 Künstler', emoji: '🎨' }
|
||||
];
|
||||
|
||||
// Hilfsfunktion um Emoji für Avatar zu finden
|
||||
const getAvatarEmoji = (avatarValue) => {
|
||||
const avatar = avatars.find(a => a.value === avatarValue);
|
||||
return avatar ? avatar.emoji : '👦';
|
||||
};
|
||||
|
||||
// Helper function to get level title
|
||||
const getLevelTitle = (level) => {
|
||||
if (!level || typeof level !== 'object') {
|
||||
return 'Nachwuchs-Held';
|
||||
}
|
||||
return level.title || 'Nachwuchs-Held';
|
||||
};
|
||||
|
||||
const themes = [
|
||||
{ value: 'colorful', label: 'Bunt & Fröhlich' },
|
||||
{ value: 'nature', label: 'Natur' },
|
||||
{ value: 'space', label: 'Weltraum' },
|
||||
{ value: 'ocean', label: 'Ozean' },
|
||||
{ value: 'forest', label: 'Wald' }
|
||||
];
|
||||
|
||||
const achievementCategories = [
|
||||
{ value: 'general', label: 'Allgemein', icon: '🏆' },
|
||||
{ value: 'tasks', label: 'Aufgaben', icon: '✅' },
|
||||
{ value: 'streak', label: 'Serie', icon: '🔥' },
|
||||
{ value: 'points', label: 'Punkte', icon: '⭐' },
|
||||
{ value: 'special', label: 'Besonders', icon: '🌟' }
|
||||
];
|
||||
|
||||
const achievementIcons = [
|
||||
'🏆', '🥇', '🥈', '🥉', '🎖️', '🏅', '⭐', '🌟',
|
||||
'✅', '🔥', '💪', '🎯', '🚀', '👑', '💎', '🎊'
|
||||
];
|
||||
|
||||
// Kind-Statistiken laden
|
||||
const loadChildStats = useCallback(async (childId) => {
|
||||
try {
|
||||
const result = await getChildStats(childId);
|
||||
if (result.success) {
|
||||
setChildStats(result.stats);
|
||||
} else {
|
||||
console.error('Fehler beim Laden der Statistiken:', result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Statistiken:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Kinder laden
|
||||
const loadChildren = useCallback(async () => {
|
||||
// Nur laden wenn Benutzer authentifiziert ist
|
||||
if (!currentUser) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getChildren();
|
||||
if (result.success) {
|
||||
setChildren(result.children);
|
||||
if (result.children.length > 0) {
|
||||
setSelectedChild(result.children[0]);
|
||||
loadChildStats(result.children[0].id);
|
||||
}
|
||||
} else {
|
||||
showError(result.message || 'Fehler beim Laden der Kinderprofile');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Kinder:', error);
|
||||
showError('Fehler beim Laden der Kinderprofile');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentUser, showError, loadChildStats]);
|
||||
|
||||
// Daten laden
|
||||
useEffect(() => {
|
||||
loadChildren();
|
||||
}, [currentUser, showError]);
|
||||
|
||||
const handleChildSelect = (child) => {
|
||||
setSelectedChild(child);
|
||||
loadChildStats(child.id);
|
||||
};
|
||||
|
||||
const handleFormChange = (field, value) => {
|
||||
if (field.includes('.')) {
|
||||
const [parent, child] = field.split('.');
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[parent]: {
|
||||
...prev[parent],
|
||||
[child]: value
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateChild = () => {
|
||||
setFormData({
|
||||
name: '',
|
||||
age: 6,
|
||||
username: '',
|
||||
pin: '',
|
||||
avatar: 'default-child.png',
|
||||
preferences: {
|
||||
theme: 'colorful',
|
||||
language: 'de',
|
||||
notifications: true
|
||||
}
|
||||
});
|
||||
setChildDialog({ open: true, child: null, mode: 'create' });
|
||||
};
|
||||
|
||||
const handleEditChild = (child) => {
|
||||
// Fallback für alte Emoji-Avatare zu gültigen Werten
|
||||
let avatarValue = child.avatar;
|
||||
if (!avatars.find(a => a.value === child.avatar)) {
|
||||
avatarValue = 'default-child.png';
|
||||
}
|
||||
|
||||
setFormData({
|
||||
name: child.name,
|
||||
age: child.age,
|
||||
username: child.username || '',
|
||||
pin: '', // PIN wird aus Sicherheitsgründen nicht vorausgefüllt
|
||||
avatar: avatarValue,
|
||||
preferences: child.preferences || {
|
||||
theme: 'colorful',
|
||||
language: 'de',
|
||||
notifications: true
|
||||
}
|
||||
});
|
||||
setChildDialog({ open: true, child, mode: 'edit' });
|
||||
};
|
||||
|
||||
const handleSaveChild = async () => {
|
||||
try {
|
||||
let response;
|
||||
if (childDialog.mode === 'create') {
|
||||
response = await createChild(formData);
|
||||
} else {
|
||||
response = await updateChild(childDialog.child.id, formData);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
showSuccess(response.message);
|
||||
setChildDialog({ open: false, child: null, mode: 'create' });
|
||||
|
||||
// Kinder neu laden
|
||||
loadChildren();
|
||||
} else {
|
||||
showError(response.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('Fehler beim Speichern des Kinderprofils');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteChild = async () => {
|
||||
try {
|
||||
const response = await deleteChild(deleteDialog.child.id);
|
||||
|
||||
if (response.success) {
|
||||
showSuccess(response.message);
|
||||
setDeleteDialog({ open: false, child: null });
|
||||
|
||||
// Kinder neu laden
|
||||
loadChildren();
|
||||
|
||||
// Neues ausgewähltes Kind setzen
|
||||
if (selectedChild?.id === deleteDialog.child.id) {
|
||||
setSelectedChild(null);
|
||||
setChildStats(null);
|
||||
}
|
||||
} else {
|
||||
showError(response.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('Fehler beim Löschen des Kinderprofils');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointsAction = async () => {
|
||||
try {
|
||||
const amount = pointsData.type === 'add' ? pointsData.amount : -pointsData.amount;
|
||||
const response = await updateChildPoints(pointsDialog.child.id, amount, pointsData.reason);
|
||||
|
||||
if (response.success) {
|
||||
showSuccess(response.message);
|
||||
setPointsDialog({ open: false, child: null });
|
||||
setPointsData({ amount: 0, reason: '', type: 'add' });
|
||||
|
||||
// Kinder und Statistiken neu laden
|
||||
loadChildren();
|
||||
if (selectedChild) {
|
||||
loadChildStats(selectedChild.id);
|
||||
}
|
||||
} else {
|
||||
showError(response.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('Fehler beim Bearbeiten der Punkte');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddAchievement = async () => {
|
||||
try {
|
||||
const response = await addChildAchievement(achievementDialog.child.id, achievementData);
|
||||
|
||||
if (response.success) {
|
||||
showSuccess(response.message);
|
||||
setAchievementDialog({ open: false, child: null });
|
||||
setAchievementData({ title: '', description: '', icon: '🏆', category: 'general' });
|
||||
|
||||
// Kinder neu laden
|
||||
loadChildren();
|
||||
if (selectedChild) {
|
||||
loadChildStats(selectedChild.id);
|
||||
}
|
||||
} else {
|
||||
showError(response.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('Fehler beim Hinzufügen der Auszeichnung');
|
||||
}
|
||||
};
|
||||
|
||||
const getLevelProgress = (child) => {
|
||||
const currentLevel = child.level?.current || 1;
|
||||
const currentLevelPoints = currentLevel * 100;
|
||||
const nextLevelPoints = (currentLevel + 1) * 100;
|
||||
const progress = ((child.points.total - currentLevelPoints) / (nextLevelPoints - currentLevelPoints)) * 100;
|
||||
return Math.max(0, Math.min(100, progress));
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default' }}>
|
||||
{/* App Bar */}
|
||||
<AppBar position="static" elevation={0}>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
edge="start"
|
||||
color="inherit"
|
||||
onClick={() => navigate('/dashboard')}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
|
||||
<Person sx={{ mr: 2 }} />
|
||||
<Typography variant="h6" sx={{ flexGrow: 1, fontWeight: 700 }}>
|
||||
Kinder-Management
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
color="inherit"
|
||||
startIcon={<Add />}
|
||||
onClick={handleCreateChild}
|
||||
sx={{ fontWeight: 600 }}
|
||||
>
|
||||
Kind hinzufügen
|
||||
</Button>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
<Grid container spacing={3}>
|
||||
{/* Kinder-Liste */}
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
Familien-Helden ({children.length})
|
||||
</Typography>
|
||||
|
||||
{children.length > 0 ? (
|
||||
<List>
|
||||
{children.map((child, index) => (
|
||||
<React.Fragment key={child._id}>
|
||||
<ListItem
|
||||
component="div"
|
||||
selected={selectedChild?._id === child._id}
|
||||
onClick={() => handleChildSelect(child)}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
mb: 1,
|
||||
cursor: 'pointer',
|
||||
'&.Mui-selected': {
|
||||
bgcolor: 'primary.light',
|
||||
'&:hover': {
|
||||
bgcolor: 'primary.light'
|
||||
}
|
||||
},
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Avatar sx={{ mr: 2, bgcolor: 'primary.main' }}>
|
||||
{getAvatarEmoji(child.avatar)}
|
||||
</Avatar>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
{child.name}
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
<React.Fragment>
|
||||
<span style={{ fontSize: '0.875rem', color: 'rgba(0, 0, 0, 0.6)', display: 'block' }}>
|
||||
{child.age} Jahre • Level {child.level?.current || 1}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.875rem', color: 'rgb(156, 39, 176)', display: 'block' }}>
|
||||
⭐ {child.points.available} Punkte
|
||||
</span>
|
||||
</React.Fragment>
|
||||
}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditChild(child);
|
||||
}}
|
||||
>
|
||||
<Edit />
|
||||
</IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
{index < children.length - 1 && <Divider />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
) : (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Typography variant="h4" sx={{ mb: 2 }}>👨👩👧👦</Typography>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
Noch keine Kinder hinzugefügt
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Füge deine ersten Familien-Helden hinzu!
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={handleCreateChild}
|
||||
>
|
||||
Erstes Kind hinzufügen
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Kind-Details */}
|
||||
<Grid size={{ xs: 12, md: 8 }}>
|
||||
{selectedChild ? (
|
||||
<>
|
||||
{/* Kind-Übersicht */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Grid container spacing={3} alignItems="center">
|
||||
<Grid>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
fontSize: '2rem',
|
||||
bgcolor: 'primary.main'
|
||||
}}
|
||||
>
|
||||
{getAvatarEmoji(selectedChild.avatar)}
|
||||
</Avatar>
|
||||
</Grid>
|
||||
|
||||
<Grid size="grow">
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, mb: 1 }}>
|
||||
{selectedChild.name}
|
||||
</Typography>
|
||||
<Typography variant="h6" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{selectedChild.age} Jahre • {getLevelTitle(selectedChild.level)}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
|
||||
<Chip
|
||||
icon={<Star />}
|
||||
label={`Level ${selectedChild.level?.current || 1}`}
|
||||
color="primary"
|
||||
variant="filled"
|
||||
/>
|
||||
<Chip
|
||||
label={`⭐ ${selectedChild.points.available} Punkte`}
|
||||
color="secondary"
|
||||
variant="filled"
|
||||
/>
|
||||
<Chip
|
||||
label={`🔥 ${selectedChild.statistics?.currentStreak || 0} Tage Serie`}
|
||||
color="warning"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Level-Fortschritt */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Fortschritt zu Level {(selectedChild.level?.current || 1) + 1}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{Math.round(getLevelProgress(selectedChild))}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={getLevelProgress(selectedChild)}
|
||||
sx={{ height: 8, borderRadius: 4 }}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid item>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Star />}
|
||||
onClick={() => setPointsDialog({ open: true, child: selectedChild })}
|
||||
size="small"
|
||||
>
|
||||
Punkte bearbeiten
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<EmojiEvents />}
|
||||
onClick={() => setAchievementDialog({ open: true, child: selectedChild })}
|
||||
size="small"
|
||||
>
|
||||
Auszeichnung
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
startIcon={<Delete />}
|
||||
onClick={() => setDeleteDialog({ open: true, child: selectedChild })}
|
||||
size="small"
|
||||
>
|
||||
Löschen
|
||||
</Button>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Statistiken */}
|
||||
{childStats && (
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
<Grid size={{ xs: 12, md: 3 }}>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center' }}>
|
||||
<Assignment sx={{ fontSize: 40, color: 'primary.main', mb: 1 }} />
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, mb: 1 }}>
|
||||
{childStats.tasksCompleted || 0}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Aufgaben erledigt
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 3 }}>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center' }}>
|
||||
<Star sx={{ fontSize: 40, color: 'secondary.main', mb: 1 }} />
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, mb: 1 }}>
|
||||
{selectedChild.points.total || 0}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Punkte gesamt
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 3 }}>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center' }}>
|
||||
<CardGiftcard sx={{ fontSize: 40, color: 'success.main', mb: 1 }} />
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, mb: 1 }}>
|
||||
{childStats.rewardsRedeemed || 0}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Belohnungen eingelöst
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 3 }}>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center' }}>
|
||||
<TrendingUp sx={{ fontSize: 40, color: 'warning.main', mb: 1 }} />
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, mb: 1 }}>
|
||||
{selectedChild.statistics?.longestStreak || 0}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Längste Serie
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Auszeichnungen */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
🏆 Auszeichnungen ({selectedChild.achievements?.length || 0})
|
||||
</Typography>
|
||||
|
||||
{selectedChild.achievements && selectedChild.achievements.length > 0 ? (
|
||||
<Grid container spacing={2}>
|
||||
{selectedChild.achievements.map((achievement, index) => (
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={achievement.id || `achievement-${index}`}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.3, delay: index * 0.1 }}
|
||||
>
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ textAlign: 'center', py: 2 }}>
|
||||
<Typography variant="h4" sx={{ mb: 1 }}>
|
||||
{achievement.icon}
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
{achievement.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{achievement.description}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
{new Date(achievement.earnedAt).toLocaleDateString('de-DE')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Typography variant="h4" sx={{ mb: 2 }}>🏆</Typography>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
Noch keine Auszeichnungen
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{selectedChild.name} kann Auszeichnungen durch das Erledigen von Aufgaben verdienen!
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<EmojiEvents />}
|
||||
onClick={() => setAchievementDialog({ open: true, child: selectedChild })}
|
||||
>
|
||||
Erste Auszeichnung verleihen
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 8 }}>
|
||||
<Typography variant="h4" sx={{ mb: 2 }}>👨👩👧👦</Typography>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
Wähle ein Kind aus
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Wähle ein Kind aus der Liste aus, um Details und Statistiken zu sehen.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
|
||||
{/* Kind erstellen/bearbeiten Dialog */}
|
||||
<Dialog
|
||||
open={childDialog.open}
|
||||
onClose={() => setChildDialog({ open: false, child: null, mode: 'create' })}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
{childDialog.mode === 'create' ? 'Neues Kind hinzufügen' : 'Kind bearbeiten'}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||
<Grid size={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Name"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleFormChange('name', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Alter"
|
||||
type="number"
|
||||
value={formData.age}
|
||||
onChange={(e) => handleFormChange('age', parseInt(e.target.value))}
|
||||
inputProps={{ min: 3, max: 18 }}
|
||||
required
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Benutzername"
|
||||
value={formData.username}
|
||||
onChange={(e) => handleFormChange('username', e.target.value)}
|
||||
helperText="Für die Anmeldung des Kindes (3-20 Zeichen, nur Buchstaben, Zahlen und _)"
|
||||
required
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="PIN"
|
||||
type="password"
|
||||
value={formData.pin}
|
||||
onChange={(e) => handleFormChange('pin', e.target.value)}
|
||||
helperText="4-6 stellige Zahlen-PIN für die Anmeldung"
|
||||
inputProps={{ maxLength: 6, pattern: '[0-9]*' }}
|
||||
required={childDialog.mode === 'create'}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Avatar</InputLabel>
|
||||
<Select
|
||||
value={formData.avatar}
|
||||
onChange={(e) => handleFormChange('avatar', e.target.value)}
|
||||
label="Avatar"
|
||||
>
|
||||
{avatars.map(avatar => (
|
||||
<MenuItem key={avatar.value} value={avatar.value}>
|
||||
{avatar.emoji} {avatar.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid size={12}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Design-Thema</InputLabel>
|
||||
<Select
|
||||
value={formData.preferences.theme}
|
||||
onChange={(e) => handleFormChange('preferences.theme', e.target.value)}
|
||||
label="Design-Thema"
|
||||
>
|
||||
{themes.map(theme => (
|
||||
<MenuItem key={theme.value} value={theme.value}>
|
||||
{theme.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setChildDialog({ open: false, child: null, mode: 'create' })}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveChild}
|
||||
variant="contained"
|
||||
disabled={
|
||||
!formData.name ||
|
||||
!formData.age ||
|
||||
!formData.username ||
|
||||
(childDialog.mode === 'create' && !formData.pin)
|
||||
}
|
||||
>
|
||||
{childDialog.mode === 'create' ? 'Hinzufügen' : 'Speichern'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Punkte bearbeiten Dialog */}
|
||||
<Dialog
|
||||
open={pointsDialog.open}
|
||||
onClose={() => setPointsDialog({ open: false, child: null })}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Punkte bearbeiten</DialogTitle>
|
||||
<DialogContent>
|
||||
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||
<Grid size={12}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Aktion</InputLabel>
|
||||
<Select
|
||||
value={pointsData.type}
|
||||
onChange={(e) => setPointsData(prev => ({ ...prev, type: e.target.value }))}
|
||||
label="Aktion"
|
||||
>
|
||||
<MenuItem value="add">Punkte hinzufügen</MenuItem>
|
||||
<MenuItem value="subtract">Punkte abziehen</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid size={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Anzahl Punkte"
|
||||
type="number"
|
||||
value={pointsData.amount}
|
||||
onChange={(e) => setPointsData(prev => ({ ...prev, amount: parseInt(e.target.value) || 0 }))}
|
||||
inputProps={{ min: 1, max: 1000 }}
|
||||
required
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Grund (optional)"
|
||||
value={pointsData.reason}
|
||||
onChange={(e) => setPointsData(prev => ({ ...prev, reason: e.target.value }))}
|
||||
multiline
|
||||
rows={2}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setPointsDialog({ open: false, child: null })}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handlePointsAction}
|
||||
variant="contained"
|
||||
disabled={!pointsData.amount}
|
||||
>
|
||||
{pointsData.type === 'add' ? 'Hinzufügen' : 'Abziehen'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Auszeichnung hinzufügen Dialog */}
|
||||
<Dialog
|
||||
open={achievementDialog.open}
|
||||
onClose={() => setAchievementDialog({ open: false, child: null })}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Auszeichnung verleihen</DialogTitle>
|
||||
<DialogContent>
|
||||
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||
<Grid size={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Titel"
|
||||
value={achievementData.title}
|
||||
onChange={(e) => setAchievementData(prev => ({ ...prev, title: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Beschreibung"
|
||||
value={achievementData.description}
|
||||
onChange={(e) => setAchievementData(prev => ({ ...prev, description: e.target.value }))}
|
||||
multiline
|
||||
rows={2}
|
||||
required
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Icon</InputLabel>
|
||||
<Select
|
||||
value={achievementData.icon}
|
||||
onChange={(e) => setAchievementData(prev => ({ ...prev, icon: e.target.value }))}
|
||||
label="Icon"
|
||||
>
|
||||
{achievementIcons.map(icon => (
|
||||
<MenuItem key={icon} value={icon}>
|
||||
{icon} {icon}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Kategorie</InputLabel>
|
||||
<Select
|
||||
value={achievementData.category}
|
||||
onChange={(e) => setAchievementData(prev => ({ ...prev, category: e.target.value }))}
|
||||
label="Kategorie"
|
||||
>
|
||||
{achievementCategories.map(category => (
|
||||
<MenuItem key={category.value} value={category.value}>
|
||||
{category.icon} {category.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setAchievementDialog({ open: false, child: null })}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddAchievement}
|
||||
variant="contained"
|
||||
disabled={!achievementData.title || !achievementData.description}
|
||||
>
|
||||
Verleihen
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Löschen-Bestätigung Dialog */}
|
||||
<Dialog
|
||||
open={deleteDialog.open}
|
||||
onClose={() => setDeleteDialog({ open: false, child: null })}
|
||||
>
|
||||
<DialogTitle>Kind löschen</DialogTitle>
|
||||
<DialogContent>
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
Diese Aktion kann nicht rückgängig gemacht werden!
|
||||
</Alert>
|
||||
<Typography>
|
||||
Möchtest du das Profil von "{deleteDialog.child?.name}" wirklich löschen?
|
||||
Alle Aufgaben, Punkte und Auszeichnungen gehen dabei verloren.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialog({ open: false, child: null })}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={handleDeleteChild} color="error" variant="contained">
|
||||
Löschen
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChildrenManagement;
|
||||
345
client/src/pages/LandingPage.js
Normal file
345
client/src/pages/LandingPage.js
Normal file
@ -0,0 +1,345 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Button,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
useTheme,
|
||||
useMediaQuery
|
||||
} from '@mui/material';
|
||||
import EmojiEvents from '@mui/icons-material/EmojiEvents';
|
||||
import Assignment from '@mui/icons-material/Assignment';
|
||||
import FamilyRestroom from '@mui/icons-material/FamilyRestroom';
|
||||
import Star from '@mui/icons-material/Star';
|
||||
import PlayArrow from '@mui/icons-material/PlayArrow';
|
||||
import Login from '@mui/icons-material/Login';
|
||||
import PersonAdd from '@mui/icons-material/PersonAdd';
|
||||
import ChildCare from '@mui/icons-material/ChildCare';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useFirebaseAuth } from '../contexts/FirebaseAuthContext';
|
||||
|
||||
const LandingPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const { currentUser } = useFirebaseAuth();
|
||||
|
||||
// Wenn bereits angemeldet, zum Dashboard weiterleiten
|
||||
React.useEffect(() => {
|
||||
if (currentUser) {
|
||||
navigate('/dashboard');
|
||||
}
|
||||
}, [currentUser, navigate]);
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: <Assignment sx={{ fontSize: 48, color: theme.palette.primary.main }} />,
|
||||
title: 'Aufgaben-Management',
|
||||
description: 'Erstelle und verwalte Aufgaben für deine Kinder. Von Hausarbeit bis Hausaufgaben - alles an einem Ort.'
|
||||
},
|
||||
{
|
||||
icon: <EmojiEvents sx={{ fontSize: 48, color: theme.palette.secondary.main }} />,
|
||||
title: 'Belohnungs-System',
|
||||
description: 'Motiviere deine Kinder mit einem spielerischen Punktesystem und individuellen Belohnungen.'
|
||||
},
|
||||
{
|
||||
icon: <FamilyRestroom sx={{ fontSize: 48, color: theme.palette.success.main }} />,
|
||||
title: 'Familien-Dashboard',
|
||||
description: 'Behalte den Überblick über alle Familienmitglieder und deren Fortschritte an einem Ort.'
|
||||
},
|
||||
{
|
||||
icon: <Star sx={{ fontSize: 48, color: theme.palette.warning.main }} />,
|
||||
title: 'Gamification',
|
||||
description: 'Verwandle alltägliche Aufgaben in spannende Herausforderungen mit Levels und Achievements.'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default' }}>
|
||||
{/* Navigation */}
|
||||
<AppBar position="static" elevation={0} sx={{ bgcolor: 'transparent' }}>
|
||||
<Toolbar>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
fontWeight: 800,
|
||||
background: 'linear-gradient(45deg, #2196F3 30%, #21CBF3 90%)',
|
||||
backgroundClip: 'text',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent'
|
||||
}}
|
||||
>
|
||||
🏆 Familien-Held
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
color="primary"
|
||||
startIcon={<Login />}
|
||||
onClick={() => navigate('/login')}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
Anmelden
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PersonAdd />}
|
||||
onClick={() => navigate('/register')}
|
||||
>
|
||||
Registrieren
|
||||
</Button>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
{/* Hero Section */}
|
||||
<Container maxWidth="lg" sx={{ py: 8 }}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
>
|
||||
<Box sx={{ textAlign: 'center', mb: 8 }}>
|
||||
<Typography
|
||||
variant={isMobile ? 'h3' : 'h2'}
|
||||
sx={{
|
||||
fontWeight: 800,
|
||||
mb: 3,
|
||||
background: 'linear-gradient(45deg, #2196F3 30%, #21CBF3 90%)',
|
||||
backgroundClip: 'text',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent'
|
||||
}}
|
||||
>
|
||||
Verwandle Aufgaben in Abenteuer! 🚀
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="h5"
|
||||
color="text.secondary"
|
||||
sx={{ mb: 4, maxWidth: 600, mx: 'auto' }}
|
||||
>
|
||||
Die spielerische To-Do- & Belohnungs-App für Familien.
|
||||
Motiviere deine Kinder und bringe Spaß in den Alltag!
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<PlayArrow />}
|
||||
onClick={() => navigate('/register')}
|
||||
sx={{
|
||||
py: 2,
|
||||
px: 4,
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 600,
|
||||
borderRadius: 3
|
||||
}}
|
||||
>
|
||||
Jetzt kostenlos starten
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="large"
|
||||
onClick={() => navigate('/login')}
|
||||
sx={{
|
||||
py: 2,
|
||||
px: 4,
|
||||
fontSize: '1.1rem',
|
||||
borderRadius: 3
|
||||
}}
|
||||
>
|
||||
Bereits registriert?
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
||||
💡 Tipp: Probiere die Demo mit dem Account "demo@familie.de" und Passwort "demo123"
|
||||
</Typography>
|
||||
</Box>
|
||||
</motion.div>
|
||||
|
||||
{/* Features Section */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.8, delay: 0.3 }}
|
||||
>
|
||||
<Typography
|
||||
variant="h3"
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
fontWeight: 700,
|
||||
mb: 6,
|
||||
color: 'text.primary'
|
||||
}}
|
||||
>
|
||||
Warum Familien-Held? ✨
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={4}>
|
||||
{features.map((feature, index) => (
|
||||
<Grid size={{ xs: 12, md: 6 }} key={index}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.1 * index }}
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'visible',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-8px)',
|
||||
transition: 'transform 0.3s ease-in-out'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
{feature.icon}
|
||||
</Box>
|
||||
|
||||
<Typography variant="h5" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
{feature.title}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
{feature.description}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</motion.div>
|
||||
|
||||
{/* Call to Action */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.6 }}
|
||||
>
|
||||
<Card
|
||||
sx={{
|
||||
mt: 8,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
position: 'relative',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 6, textAlign: 'center', position: 'relative', zIndex: 1 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, mb: 2 }}>
|
||||
Bereit für das Abenteuer? 🎯
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" sx={{ mb: 4, opacity: 0.9 }}>
|
||||
Starte noch heute und verwandle dein Zuhause in eine Spielwelt voller Motivation!
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={() => navigate('/register')}
|
||||
sx={{
|
||||
bgcolor: 'white',
|
||||
color: 'primary.main',
|
||||
py: 2,
|
||||
px: 4,
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 600,
|
||||
borderRadius: 3,
|
||||
'&:hover': {
|
||||
bgcolor: 'grey.100'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Familien-Held werden! 🚀
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="large"
|
||||
startIcon={<ChildCare />}
|
||||
onClick={() => navigate('/child-login')}
|
||||
sx={{
|
||||
borderColor: 'white',
|
||||
color: 'white',
|
||||
py: 2,
|
||||
px: 4,
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 600,
|
||||
borderRadius: 3,
|
||||
'&:hover': {
|
||||
borderColor: 'white',
|
||||
bgcolor: 'rgba(255, 255, 255, 0.1)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Ich bin ein Kind! 👦
|
||||
</Button>
|
||||
</Box>
|
||||
</CardContent>
|
||||
|
||||
{/* Dekorative Elemente */}
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -20,
|
||||
right: -20,
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'rgba(255, 255, 255, 0.1)'
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: -30,
|
||||
left: -30,
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'rgba(255, 255, 255, 0.05)'
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Container>
|
||||
|
||||
{/* Footer */}
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: 'grey.100',
|
||||
py: 4,
|
||||
mt: 8,
|
||||
textAlign: 'center'
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="lg">
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
© 2024 Familien-Held - Verwandle Aufgaben in Abenteuer! 🏆
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
Entwickelt mit ❤️ für Familien
|
||||
</Typography>
|
||||
</Container>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default LandingPage;
|
||||
273
client/src/pages/LoginPage.js
Normal file
273
client/src/pages/LoginPage.js
Normal file
@ -0,0 +1,273 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, useLocation, Link } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Paper,
|
||||
TextField,
|
||||
Button,
|
||||
Typography,
|
||||
Alert,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
Divider,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import Email from '@mui/icons-material/Email';
|
||||
import Lock from '@mui/icons-material/Lock';
|
||||
import Visibility from '@mui/icons-material/Visibility';
|
||||
import VisibilityOff from '@mui/icons-material/VisibilityOff';
|
||||
import ArrowBack from '@mui/icons-material/ArrowBack';
|
||||
import LoginIcon from '@mui/icons-material/Login';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useFirebaseAuth } from '../contexts/FirebaseAuthContext';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
|
||||
const LoginPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { login } = useFirebaseAuth();
|
||||
const { showError, showSuccess } = useNotification();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
password: ''
|
||||
});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Wohin nach erfolgreichem Login weiterleiten
|
||||
const from = location.state?.from?.pathname || '/dashboard';
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
// Fehler zurücksetzen wenn Benutzer tippt
|
||||
if (error) setError('');
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const result = await login(formData.email, formData.password);
|
||||
|
||||
if (result.success) {
|
||||
showSuccess(result.message);
|
||||
navigate(from, { replace: true });
|
||||
} else {
|
||||
setError(result.message);
|
||||
showError(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = 'Ein unerwarteter Fehler ist aufgetreten';
|
||||
setError(message);
|
||||
showError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isFormValid = formData.email && formData.password;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #4CAF50 0%, #81C784 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
py: 4
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="sm">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<Paper
|
||||
elevation={8}
|
||||
sx={{
|
||||
p: 4,
|
||||
borderRadius: 3,
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(10px)'
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box sx={{ textAlign: 'center', mb: 4 }}>
|
||||
<Button
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => navigate('/')}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
left: 16,
|
||||
color: 'text.secondary'
|
||||
}}
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
|
||||
<Typography variant="h3" sx={{ mb: 1, fontSize: '3rem' }}>
|
||||
🦸♂️
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
color: 'primary.main',
|
||||
mb: 1
|
||||
}}
|
||||
>
|
||||
Willkommen zurück!
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Melde dich an, um deine Familien-Helden zu verwalten
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Fehler-Anzeige */}
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Login-Formular */}
|
||||
<Box component="form" onSubmit={handleSubmit}>
|
||||
<TextField
|
||||
fullWidth
|
||||
name="email"
|
||||
type="email"
|
||||
label="E-Mail-Adresse"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
sx={{ mb: 3 }}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Email color="action" />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
label="Passwort"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
sx={{ mb: 4 }}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Lock color="action" />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
edge="end"
|
||||
>
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={!isFormValid || loading}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : <LoginIcon />}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 600,
|
||||
borderRadius: 2,
|
||||
mb: 3
|
||||
}}
|
||||
>
|
||||
{loading ? 'Anmeldung läuft...' : 'Anmelden'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 3 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
oder
|
||||
</Typography>
|
||||
</Divider>
|
||||
|
||||
{/* Registrierung */}
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Noch kein Familien-Held?
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
component={Link}
|
||||
to="/register"
|
||||
variant="outlined"
|
||||
size="large"
|
||||
fullWidth
|
||||
sx={{
|
||||
py: 1.5,
|
||||
fontSize: '1rem',
|
||||
fontWeight: 600,
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
Jetzt kostenlos registrieren
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Demo-Hinweis */}
|
||||
<Box
|
||||
sx={{
|
||||
mt: 4,
|
||||
p: 2,
|
||||
bgcolor: 'info.light',
|
||||
borderRadius: 2,
|
||||
textAlign: 'center'
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: 'info.contrastText' }}>
|
||||
💡 <strong>Demo-Tipp:</strong> Erstelle einfach einen neuen Account
|
||||
und teste alle Funktionen kostenlos!
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
</motion.div>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
530
client/src/pages/ParentDashboard.js
Normal file
530
client/src/pages/ParentDashboard.js
Normal file
@ -0,0 +1,530 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
Avatar,
|
||||
Chip,
|
||||
LinearProgress,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemAvatar,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction,
|
||||
IconButton,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Badge,
|
||||
Fab,
|
||||
useTheme,
|
||||
useMediaQuery
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Dashboard,
|
||||
Assignment,
|
||||
EmojiEvents,
|
||||
People,
|
||||
Add,
|
||||
CheckCircle,
|
||||
Schedule,
|
||||
Star,
|
||||
TrendingUp,
|
||||
Notifications,
|
||||
AccountCircle,
|
||||
Logout,
|
||||
Settings,
|
||||
MoreVert
|
||||
} from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useFirebaseAuth } from '../contexts/FirebaseAuthContext';
|
||||
import { getDashboardData, approveTask, rejectTask } from '../firebase/database';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
|
||||
const ParentDashboard = () => {
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const { currentUser, logout, userName } = useFirebaseAuth();
|
||||
const { showError, showSuccess } = useNotification();
|
||||
|
||||
const [dashboardData, setDashboardData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
|
||||
// Dashboard-Daten laden
|
||||
const loadDashboard = async () => {
|
||||
// Nur laden wenn Benutzer authentifiziert ist
|
||||
if (!currentUser) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getDashboardData();
|
||||
if (result.success) {
|
||||
setDashboardData(result.dashboard);
|
||||
} else {
|
||||
showError(result.message || 'Fehler beim Laden der Dashboard-Daten');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden des Dashboards:', error);
|
||||
showError('Fehler beim Laden der Dashboard-Daten');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboard();
|
||||
}, [currentUser, showError]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
showSuccess('Erfolgreich abgemeldet!');
|
||||
navigate('/');
|
||||
} catch (error) {
|
||||
showError('Fehler beim Abmelden');
|
||||
}
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleTaskApproval = async (taskId, action) => {
|
||||
try {
|
||||
let result;
|
||||
if (action === 'approve') {
|
||||
result = await approveTask(taskId);
|
||||
} else {
|
||||
result = await rejectTask(taskId, 'Bitte nochmal versuchen');
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
showSuccess(result.message);
|
||||
// Dashboard neu laden
|
||||
loadDashboard();
|
||||
} else {
|
||||
showError(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('Fehler beim Bearbeiten der Aufgabe');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: 'linear' }}
|
||||
>
|
||||
<Typography variant="h2">🦸♂️</Typography>
|
||||
</motion.div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const { family, children, tasks, rewards, recentActivities } = dashboardData || {};
|
||||
|
||||
return (
|
||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default' }}>
|
||||
{/* App Bar */}
|
||||
<AppBar position="static" elevation={0}>
|
||||
<Toolbar>
|
||||
<Typography variant="h6" sx={{ flexGrow: 1, fontWeight: 700 }}>
|
||||
🦸♂️ {family?.name || 'Familien-Held'}
|
||||
</Typography>
|
||||
|
||||
<IconButton color="inherit" sx={{ mr: 1 }}>
|
||||
<Badge badgeContent={tasks?.pendingApprovals?.length || 0} color="error">
|
||||
<Notifications />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={(e) => setAnchorEl(e.currentTarget)}
|
||||
>
|
||||
<AccountCircle />
|
||||
</IconButton>
|
||||
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
>
|
||||
<MenuItem onClick={() => navigate('/settings')}>
|
||||
<Settings sx={{ mr: 1 }} /> Einstellungen
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleLogout}>
|
||||
<Logout sx={{ mr: 1 }} /> Abmelden
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
{/* Willkommens-Bereich */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, mb: 1 }}>
|
||||
Willkommen zurück, {userName}! 👋
|
||||
</Typography>
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
Hier ist ein Überblick über eure Familien-Aktivitäten
|
||||
</Typography>
|
||||
</Box>
|
||||
</motion.div>
|
||||
|
||||
{/* Statistik-Karten */}
|
||||
<Grid container spacing={3} sx={{ mb: 4 }}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.1 }}
|
||||
>
|
||||
<Card sx={{ height: '100%', background: 'linear-gradient(135deg, #4CAF50 0%, #81C784 100%)', color: 'white' }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Assignment sx={{ fontSize: 40, mr: 2 }} />
|
||||
<Box>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700 }}>
|
||||
{tasks?.today || 0}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ opacity: 0.9 }}>
|
||||
Heutige Aufgaben
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
>
|
||||
<Card sx={{ height: '100%', background: 'linear-gradient(135deg, #FF9800 0%, #FFB74D 100%)', color: 'white' }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Schedule sx={{ fontSize: 40, mr: 2 }} />
|
||||
<Box>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700 }}>
|
||||
{tasks?.pendingApprovals?.length || 0}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ opacity: 0.9 }}>
|
||||
Warten auf Genehmigung
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.3 }}
|
||||
>
|
||||
<Card sx={{ height: '100%', background: 'linear-gradient(135deg, #2196F3 0%, #64B5F6 100%)', color: 'white' }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<People sx={{ fontSize: 40, mr: 2 }} />
|
||||
<Box>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700 }}>
|
||||
{children?.length || 0}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ opacity: 0.9 }}>
|
||||
Familien-Helden
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.4 }}
|
||||
>
|
||||
<Card sx={{ height: '100%', background: 'linear-gradient(135deg, #9C27B0 0%, #BA68C8 100%)', color: 'white' }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<TrendingUp sx={{ fontSize: 40, mr: 2 }} />
|
||||
<Box>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700 }}>
|
||||
{family?.stats?.completionRate || 0}%
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ opacity: 0.9 }}>
|
||||
Erfolgsrate
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Kinder-Übersicht */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.5 }}
|
||||
>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
👨👩👧👦 Deine Helden
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => navigate('/children')}
|
||||
>
|
||||
Verwalten
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{children?.length > 0 ? (
|
||||
<List>
|
||||
{children.map((child, index) => (
|
||||
<ListItem
|
||||
key={child.id}
|
||||
sx={{
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
mb: 1,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: 'action.hover' }
|
||||
}}
|
||||
onClick={() => navigate(`/child/${child.id}`)}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: 'primary.main' }}>
|
||||
{child.avatar ? child.avatar.charAt(0) : child.name.charAt(0)}
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={child.name}
|
||||
secondary={
|
||||
<React.Fragment>
|
||||
<span style={{ fontSize: '0.875rem', color: 'rgba(0, 0, 0, 0.6)', display: 'block' }}>
|
||||
Level {child.level?.current || 1} • {child.points.available} Punkte
|
||||
</span>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={(child.points.total % 100)}
|
||||
sx={{ mt: 1, height: 6, borderRadius: 3 }}
|
||||
/>
|
||||
</React.Fragment>
|
||||
}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Chip
|
||||
label={child.level?.title || 'Nachwuchs-Held'}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
) : (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Noch keine Kinder hinzugefügt
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={() => navigate('/children')}
|
||||
>
|
||||
Erstes Kind hinzufügen
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
|
||||
{/* Wartende Genehmigungen */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.6 }}
|
||||
>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
⏳ Warten auf Genehmigung
|
||||
</Typography>
|
||||
<Badge badgeContent={tasks?.pendingApprovals?.length || 0} color="error">
|
||||
<CheckCircle color="action" />
|
||||
</Badge>
|
||||
</Box>
|
||||
|
||||
{tasks?.pendingApprovals?.length > 0 ? (
|
||||
<List>
|
||||
{tasks.pendingApprovals.slice(0, 3).map((task) => (
|
||||
<ListItem
|
||||
key={task._id}
|
||||
sx={{
|
||||
border: '1px solid',
|
||||
borderColor: 'warning.light',
|
||||
borderRadius: 2,
|
||||
mb: 1,
|
||||
bgcolor: 'warning.light',
|
||||
color: 'warning.contrastText'
|
||||
}}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<Avatar sx={{ bgcolor: 'warning.main' }}>
|
||||
{task.assignedTo.name.charAt(0)}
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={task.title}
|
||||
secondary={`${task.assignedTo.name} • ${task.points} Punkte`}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleTaskApproval(task._id, 'approve')}
|
||||
sx={{ color: 'success.main', mr: 1 }}
|
||||
>
|
||||
<CheckCircle />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleTaskApproval(task._id, 'reject')}
|
||||
sx={{ color: 'error.main' }}
|
||||
>
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
) : (
|
||||
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Keine Aufgaben warten auf Genehmigung 🎉
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
|
||||
{/* Schnellaktionen */}
|
||||
<Grid size={12}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.7 }}
|
||||
>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
|
||||
🚀 Schnellaktionen
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
startIcon={<Assignment />}
|
||||
onClick={() => navigate('/tasks')}
|
||||
sx={{ py: 2 }}
|
||||
>
|
||||
Aufgaben verwalten
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
startIcon={<EmojiEvents />}
|
||||
onClick={() => navigate('/rewards')}
|
||||
sx={{ py: 2 }}
|
||||
>
|
||||
Belohnungen erstellen
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
startIcon={<People />}
|
||||
onClick={() => navigate('/children')}
|
||||
sx={{ py: 2 }}
|
||||
>
|
||||
Kinder verwalten
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
startIcon={<Dashboard />}
|
||||
onClick={() => navigate('/stats')}
|
||||
sx={{ py: 2 }}
|
||||
>
|
||||
Statistiken
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
|
||||
{/* Floating Action Button */}
|
||||
<Fab
|
||||
color="primary"
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 16,
|
||||
right: 16
|
||||
}}
|
||||
onClick={() => navigate('/tasks/new')}
|
||||
>
|
||||
<Add />
|
||||
</Fab>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ParentDashboard;
|
||||
519
client/src/pages/RegisterPage.js
Normal file
519
client/src/pages/RegisterPage.js
Normal file
@ -0,0 +1,519 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Paper,
|
||||
TextField,
|
||||
Button,
|
||||
Typography,
|
||||
Alert,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
Divider,
|
||||
CircularProgress,
|
||||
Stepper,
|
||||
Step,
|
||||
StepLabel
|
||||
} from '@mui/material';
|
||||
import Email from '@mui/icons-material/Email';
|
||||
import Lock from '@mui/icons-material/Lock';
|
||||
import Visibility from '@mui/icons-material/Visibility';
|
||||
import VisibilityOff from '@mui/icons-material/VisibilityOff';
|
||||
import ArrowBack from '@mui/icons-material/ArrowBack';
|
||||
import PersonAdd from '@mui/icons-material/PersonAdd';
|
||||
import FamilyRestroom from '@mui/icons-material/FamilyRestroom';
|
||||
import Person from '@mui/icons-material/Person';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useFirebaseAuth } from '../contexts/FirebaseAuthContext';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
|
||||
const RegisterPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { register } = useFirebaseAuth();
|
||||
const { showError, showSuccess } = useNotification();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
familyName: '',
|
||||
parentName: ''
|
||||
});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
|
||||
const steps = ['Account-Daten', 'Familien-Info', 'Bestätigung'];
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
// Fehler für dieses Feld zurücksetzen
|
||||
if (errors[name]) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
[name]: ''
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const validateStep = (step) => {
|
||||
const newErrors = {};
|
||||
|
||||
switch (step) {
|
||||
case 0: // Account-Daten
|
||||
if (!formData.email) {
|
||||
newErrors.email = 'E-Mail-Adresse ist erforderlich';
|
||||
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
|
||||
newErrors.email = 'Ungültige E-Mail-Adresse';
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = 'Passwort ist erforderlich';
|
||||
} else if (formData.password.length < 6) {
|
||||
newErrors.password = 'Passwort muss mindestens 6 Zeichen lang sein';
|
||||
}
|
||||
|
||||
if (!formData.confirmPassword) {
|
||||
newErrors.confirmPassword = 'Passwort-Bestätigung ist erforderlich';
|
||||
} else if (formData.password !== formData.confirmPassword) {
|
||||
newErrors.confirmPassword = 'Passwörter stimmen nicht überein';
|
||||
}
|
||||
break;
|
||||
|
||||
case 1: // Familien-Info
|
||||
if (!formData.familyName) {
|
||||
newErrors.familyName = 'Familienname ist erforderlich';
|
||||
} else if (formData.familyName.length < 2) {
|
||||
newErrors.familyName = 'Familienname muss mindestens 2 Zeichen lang sein';
|
||||
}
|
||||
|
||||
if (!formData.parentName) {
|
||||
newErrors.parentName = 'Ihr Name ist erforderlich';
|
||||
} else if (formData.parentName.length < 2) {
|
||||
newErrors.parentName = 'Name muss mindestens 2 Zeichen lang sein';
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (validateStep(currentStep)) {
|
||||
setCurrentStep(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setCurrentStep(prev => prev - 1);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateStep(1)) return;
|
||||
|
||||
setLoading(true);
|
||||
setErrors({});
|
||||
|
||||
try {
|
||||
const result = await register(
|
||||
formData.email,
|
||||
formData.password,
|
||||
formData.parentName
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
showSuccess(result.message);
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
if (result.errors) {
|
||||
const errorObj = {};
|
||||
result.errors.forEach(error => {
|
||||
errorObj[error.param] = error.msg;
|
||||
});
|
||||
setErrors(errorObj);
|
||||
} else {
|
||||
setErrors({ general: result.message });
|
||||
}
|
||||
showError(result.message);
|
||||
setCurrentStep(0); // Zurück zum ersten Schritt bei Fehlern
|
||||
}
|
||||
} catch (error) {
|
||||
const message = 'Ein unerwarteter Fehler ist aufgetreten';
|
||||
setErrors({ general: message });
|
||||
showError(message);
|
||||
setCurrentStep(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStepContent = (step) => {
|
||||
switch (step) {
|
||||
case 0:
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Typography variant="h6" sx={{ mb: 3, textAlign: 'center' }}>
|
||||
Erstelle deinen Account
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
name="email"
|
||||
type="email"
|
||||
label="E-Mail-Adresse"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
error={!!errors.email}
|
||||
helperText={errors.email}
|
||||
required
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
sx={{ mb: 3 }}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Email color="action" />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
label="Passwort"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
error={!!errors.password}
|
||||
helperText={errors.password || 'Mindestens 6 Zeichen'}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
sx={{ mb: 3 }}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Lock color="action" />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
edge="end"
|
||||
>
|
||||
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
name="confirmPassword"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
label="Passwort bestätigen"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleChange}
|
||||
error={!!errors.confirmPassword}
|
||||
helperText={errors.confirmPassword}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
sx={{ mb: 3 }}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Lock color="action" />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
edge="end"
|
||||
>
|
||||
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
case 1:
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Typography variant="h6" sx={{ mb: 3, textAlign: 'center' }}>
|
||||
Erzähle uns von deiner Familie
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
name="familyName"
|
||||
label="Familienname"
|
||||
value={formData.familyName}
|
||||
onChange={handleChange}
|
||||
error={!!errors.familyName}
|
||||
helperText={errors.familyName || 'z.B. Familie Müller'}
|
||||
required
|
||||
autoFocus
|
||||
sx={{ mb: 3 }}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<FamilyRestroom color="action" />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
name="parentName"
|
||||
label="Ihr Name"
|
||||
value={formData.parentName}
|
||||
onChange={handleChange}
|
||||
error={!!errors.parentName}
|
||||
helperText={errors.parentName || 'Wie sollen wir Sie nennen?'}
|
||||
required
|
||||
sx={{ mb: 3 }}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Person color="action" />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
case 2:
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Typography variant="h6" sx={{ mb: 3, textAlign: 'center' }}>
|
||||
Alles bereit!
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ bgcolor: 'grey.50', p: 3, borderRadius: 2, mb: 3 }}>
|
||||
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||
Zusammenfassung:
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ mb: 1 }}>
|
||||
<strong>Familie:</strong> {formData.familyName}
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ mb: 1 }}>
|
||||
<strong>Elternteil:</strong> {formData.parentName}
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
<strong>E-Mail:</strong> {formData.email}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
Nach der Registrierung können Sie sofort Kinder-Profile erstellen
|
||||
und mit dem Aufgaben-Management beginnen!
|
||||
</Alert>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #4CAF50 0%, #81C784 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
py: 4
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="sm">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<Paper
|
||||
elevation={8}
|
||||
sx={{
|
||||
p: 4,
|
||||
borderRadius: 3,
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(10px)'
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box sx={{ textAlign: 'center', mb: 4 }}>
|
||||
<Button
|
||||
startIcon={<ArrowBack />}
|
||||
onClick={() => navigate('/')}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
left: 16,
|
||||
color: 'text.secondary'
|
||||
}}
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
|
||||
<Typography variant="h3" sx={{ mb: 1, fontSize: '3rem' }}>
|
||||
🦸♂️
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
color: 'primary.main',
|
||||
mb: 1
|
||||
}}
|
||||
>
|
||||
Werde ein Familien-Held!
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Erstelle deinen kostenlosen Account
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Stepper */}
|
||||
<Stepper activeStep={currentStep} sx={{ mb: 4 }}>
|
||||
{steps.map((label) => (
|
||||
<Step key={label}>
|
||||
<StepLabel>{label}</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
|
||||
{/* Allgemeine Fehler */}
|
||||
{errors.general && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
|
||||
{errors.general}
|
||||
</Alert>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Formular-Inhalt */}
|
||||
<Box component="form" onSubmit={handleSubmit}>
|
||||
{renderStepContent(currentStep)}
|
||||
|
||||
{/* Navigation */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 4 }}>
|
||||
<Button
|
||||
onClick={handleBack}
|
||||
disabled={currentStep === 0}
|
||||
sx={{ visibility: currentStep === 0 ? 'hidden' : 'visible' }}
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
|
||||
{currentStep === steps.length - 1 ? (
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={loading}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : <PersonAdd />}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
px: 4,
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 600,
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
{loading ? 'Registrierung läuft...' : 'Account erstellen'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
variant="contained"
|
||||
size="large"
|
||||
sx={{
|
||||
py: 1.5,
|
||||
px: 4,
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 600,
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
Weiter
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 3 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
oder
|
||||
</Typography>
|
||||
</Divider>
|
||||
|
||||
{/* Login */}
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Bereits ein Familien-Held?
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
component={Link}
|
||||
to="/login"
|
||||
variant="outlined"
|
||||
size="large"
|
||||
fullWidth
|
||||
sx={{
|
||||
py: 1.5,
|
||||
fontSize: '1rem',
|
||||
fontWeight: 600,
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
Jetzt anmelden
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</motion.div>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterPage;
|
||||
921
client/src/pages/RewardManagement.js
Normal file
921
client/src/pages/RewardManagement.js
Normal file
@ -0,0 +1,921 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
TextField,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Chip,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction,
|
||||
IconButton,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Tabs,
|
||||
Tab,
|
||||
Avatar,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Alert,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack,
|
||||
Add,
|
||||
Edit,
|
||||
Delete,
|
||||
CheckCircle,
|
||||
Cancel,
|
||||
Redeem,
|
||||
CardGiftcard,
|
||||
FilterList,
|
||||
Search,
|
||||
Star,
|
||||
Schedule
|
||||
} from '@mui/icons-material';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useFirebaseAuth } from '../contexts/FirebaseAuthContext';
|
||||
import { getRewards, createReward, updateReward, deleteReward, getChildren, getRewardRequests, approveRewardRequest, rejectRewardRequest, redeemRewardRequest } from '../firebase/database';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
|
||||
const RewardManagement = () => {
|
||||
const navigate = useNavigate();
|
||||
const { currentUser } = useFirebaseAuth();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
|
||||
const [rewards, setRewards] = useState([]);
|
||||
const [rewardRequests, setRewardRequests] = useState([]);
|
||||
const [children, setChildren] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentTab, setCurrentTab] = useState(0);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterCategory, setFilterCategory] = useState('');
|
||||
|
||||
// Dialog States
|
||||
const [rewardDialog, setRewardDialog] = useState({ open: false, reward: null, mode: 'create' });
|
||||
const [deleteDialog, setDeleteDialog] = useState({ open: false, reward: null });
|
||||
const [requestDialog, setRequestDialog] = useState({ open: false, request: null });
|
||||
|
||||
// Form State
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
cost: 100,
|
||||
category: 'entertainment',
|
||||
icon: '🎁',
|
||||
availableFor: [],
|
||||
ageRestriction: {
|
||||
min: 5,
|
||||
max: 12
|
||||
},
|
||||
availability: {
|
||||
isLimited: false,
|
||||
quantity: 1,
|
||||
resetPeriod: 'weekly'
|
||||
},
|
||||
timeRestriction: {
|
||||
hasTimeLimit: false,
|
||||
validFrom: '',
|
||||
validUntil: ''
|
||||
},
|
||||
requiresApproval: true
|
||||
});
|
||||
|
||||
const categories = [
|
||||
{ value: 'entertainment', label: 'Unterhaltung', icon: '🎮' },
|
||||
{ value: 'treats', label: 'Leckereien', icon: '🍭' },
|
||||
{ value: 'activities', label: 'Aktivitäten', icon: '🎨' },
|
||||
{ value: 'privileges', label: 'Privilegien', icon: '⭐' },
|
||||
{ value: 'outings', label: 'Ausflüge', icon: '🚗' },
|
||||
{ value: 'toys', label: 'Spielzeug', icon: '🧸' },
|
||||
{ value: 'books', label: 'Bücher', icon: '📚' },
|
||||
{ value: 'clothes', label: 'Kleidung', icon: '👕' },
|
||||
{ value: 'money', label: 'Taschengeld', icon: '💰' },
|
||||
{ value: 'special', label: 'Besonders', icon: '🌟' },
|
||||
{ value: 'other', label: 'Sonstiges', icon: '🎁' }
|
||||
];
|
||||
|
||||
const icons = [
|
||||
'🎁', '🎮', '🍭', '🎨', '⭐', '🚗', '🧸', '📚', '👕', '💰',
|
||||
'🌟', '🎪', '🎭', '🎬', '🎵', '🏆', '🎯', '🎲', '🎊', '🎈',
|
||||
'🍰', '🍕', '🍔', '🍟', '🍦', '🧁', '🍪', '🥤', '🍿', '🍩'
|
||||
];
|
||||
|
||||
// Daten laden
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
// Nur laden wenn Benutzer authentifiziert ist
|
||||
if (!currentUser) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const [rewardsResponse, requestsResponse, childrenResponse] = await Promise.all([
|
||||
getRewards(),
|
||||
getRewardRequests(),
|
||||
getChildren()
|
||||
]);
|
||||
|
||||
if (rewardsResponse.success) {
|
||||
setRewards(rewardsResponse.rewards);
|
||||
}
|
||||
if (requestsResponse.success) {
|
||||
setRewardRequests(requestsResponse.requests);
|
||||
}
|
||||
if (childrenResponse.success) {
|
||||
setChildren(childrenResponse.children);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Daten:', error);
|
||||
showError('Fehler beim Laden der Belohnungen');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [currentUser, showError]);
|
||||
|
||||
// Belohnungen filtern
|
||||
const getFilteredRewards = () => {
|
||||
let filtered = rewards;
|
||||
|
||||
// Nach Suchbegriff filtern
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter(reward =>
|
||||
reward.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
reward.description?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Nach Kategorie filtern
|
||||
if (filterCategory) {
|
||||
filtered = filtered.filter(reward => reward.category === filterCategory);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
const handleFormChange = (field, value) => {
|
||||
if (field.includes('.')) {
|
||||
const [parent, child] = field.split('.');
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[parent]: {
|
||||
...prev[parent],
|
||||
[child]: value
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateReward = () => {
|
||||
setFormData({
|
||||
title: '',
|
||||
description: '',
|
||||
cost: 100,
|
||||
category: 'entertainment',
|
||||
icon: '🎁',
|
||||
availableFor: [],
|
||||
ageRestriction: {
|
||||
min: 5,
|
||||
max: 12
|
||||
},
|
||||
availability: {
|
||||
isLimited: false,
|
||||
quantity: 1,
|
||||
resetPeriod: 'weekly'
|
||||
},
|
||||
timeRestriction: {
|
||||
hasTimeLimit: false,
|
||||
validFrom: '',
|
||||
validUntil: ''
|
||||
},
|
||||
requiresApproval: true
|
||||
});
|
||||
setRewardDialog({ open: true, reward: null, mode: 'create' });
|
||||
};
|
||||
|
||||
const handleEditReward = (reward) => {
|
||||
setFormData({
|
||||
title: reward.title,
|
||||
description: reward.description || '',
|
||||
cost: reward.cost,
|
||||
category: reward.category,
|
||||
icon: reward.icon,
|
||||
availableFor: reward.availableFor?.map(child => child._id) || [],
|
||||
ageRestriction: reward.ageRestriction || { min: 5, max: 12 },
|
||||
availability: reward.availability || {
|
||||
isLimited: false,
|
||||
quantity: 1,
|
||||
resetPeriod: 'weekly'
|
||||
},
|
||||
timeRestriction: reward.timeRestriction || {
|
||||
hasTimeLimit: false,
|
||||
validFrom: '',
|
||||
validUntil: ''
|
||||
},
|
||||
requiresApproval: reward.requiresApproval !== false
|
||||
});
|
||||
setRewardDialog({ open: true, reward, mode: 'edit' });
|
||||
};
|
||||
|
||||
const handleSaveReward = async () => {
|
||||
try {
|
||||
let response;
|
||||
if (rewardDialog.mode === 'create') {
|
||||
response = await createReward(formData);
|
||||
} else {
|
||||
response = await updateReward(rewardDialog.reward.id, formData);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
showSuccess(response.message);
|
||||
setRewardDialog({ open: false, reward: null, mode: 'create' });
|
||||
|
||||
// Belohnungen neu laden
|
||||
const rewardsResponse = await getRewards();
|
||||
if (rewardsResponse.success) {
|
||||
setRewards(rewardsResponse.rewards);
|
||||
}
|
||||
} else {
|
||||
showError(response.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('Fehler beim Speichern der Belohnung');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteReward = async () => {
|
||||
try {
|
||||
const response = await deleteReward(deleteDialog.reward.id);
|
||||
|
||||
if (response.success) {
|
||||
showSuccess(response.message);
|
||||
setDeleteDialog({ open: false, reward: null });
|
||||
|
||||
// Belohnungen neu laden
|
||||
const rewardsResponse = await getRewards();
|
||||
if (rewardsResponse.success) {
|
||||
setRewards(rewardsResponse.rewards);
|
||||
}
|
||||
} else {
|
||||
showError(response.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('Fehler beim Löschen der Belohnung');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestAction = async (requestId, action, reason = '') => {
|
||||
try {
|
||||
let response;
|
||||
if (action === 'approve') {
|
||||
response = await approveRewardRequest(requestId);
|
||||
} else if (action === 'reject') {
|
||||
response = await rejectRewardRequest(requestId, reason);
|
||||
} else if (action === 'redeem') {
|
||||
response = await redeemRewardRequest(requestId);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
showSuccess(response.message);
|
||||
setRequestDialog({ open: false, request: null });
|
||||
|
||||
// Anfragen neu laden
|
||||
const requestsResponse = await getRewardRequests();
|
||||
if (requestsResponse.success) {
|
||||
setRewardRequests(requestsResponse.requests);
|
||||
}
|
||||
} else {
|
||||
showError(response.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('Fehler beim Bearbeiten der Anfrage');
|
||||
}
|
||||
};
|
||||
|
||||
const getRequestStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'pending': return 'warning';
|
||||
case 'approved': return 'info';
|
||||
case 'redeemed': return 'success';
|
||||
case 'rejected': return 'error';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getRequestStatusLabel = (status) => {
|
||||
switch (status) {
|
||||
case 'pending': return 'Wartend';
|
||||
case 'approved': return 'Genehmigt';
|
||||
case 'redeemed': return 'Eingelöst';
|
||||
case 'rejected': return 'Abgelehnt';
|
||||
default: return status;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredRewards = getFilteredRewards();
|
||||
const pendingRequests = rewardRequests.filter(req => req.status === 'pending');
|
||||
const approvedRequests = rewardRequests.filter(req => req.status === 'approved');
|
||||
|
||||
return (
|
||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default' }}>
|
||||
{/* App Bar */}
|
||||
<AppBar position="static" elevation={0}>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
edge="start"
|
||||
color="inherit"
|
||||
onClick={() => navigate('/dashboard')}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
|
||||
<CardGiftcard sx={{ mr: 2 }} />
|
||||
<Typography variant="h6" sx={{ flexGrow: 1, fontWeight: 700 }}>
|
||||
Belohnungs-Management
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
color="inherit"
|
||||
startIcon={<Add />}
|
||||
onClick={handleCreateReward}
|
||||
sx={{ fontWeight: 600 }}
|
||||
>
|
||||
Neue Belohnung
|
||||
</Button>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
{/* Tabs */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Tabs
|
||||
value={currentTab}
|
||||
onChange={(e, newValue) => setCurrentTab(newValue)}
|
||||
variant="fullWidth"
|
||||
>
|
||||
<Tab label={`Belohnungen (${rewards.length})`} />
|
||||
<Tab label={`Anfragen (${pendingRequests.length})`} />
|
||||
<Tab label={`Genehmigt (${approvedRequests.length})`} />
|
||||
</Tabs>
|
||||
</Card>
|
||||
|
||||
{/* Tab Content */}
|
||||
{currentTab === 0 && (
|
||||
<>
|
||||
{/* Filter und Suche */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="Belohnungen durchsuchen..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: <Search sx={{ mr: 1, color: 'action.active' }} />
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Kategorie filtern</InputLabel>
|
||||
<Select
|
||||
value={filterCategory}
|
||||
onChange={(e) => setFilterCategory(e.target.value)}
|
||||
label="Kategorie filtern"
|
||||
>
|
||||
<MenuItem value="">Alle Kategorien</MenuItem>
|
||||
{categories.map(category => (
|
||||
<MenuItem key={category.value} value={category.value}>
|
||||
{category.icon} {category.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{filteredRewards.length} von {rewards.length} Belohnungen
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Belohnungen-Liste */}
|
||||
{filteredRewards.length > 0 ? (
|
||||
<Grid container spacing={2}>
|
||||
{filteredRewards.map((reward, index) => (
|
||||
<Grid size={{ xs: 12, md: 6, lg: 4 }} key={reward._id}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||
>
|
||||
<Card sx={{ height: '100%' }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h4" sx={{ mr: 2 }}>
|
||||
{reward.icon}
|
||||
</Typography>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
{reward.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{categories.find(c => c.value === reward.category)?.label || reward.category}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{reward.description && (
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
{reward.description}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Chip
|
||||
label={`⭐ ${reward.cost} Punkte`}
|
||||
color="secondary"
|
||||
sx={{ fontWeight: 600 }}
|
||||
/>
|
||||
{reward.requiresApproval && (
|
||||
<Chip
|
||||
label="Genehmigung erforderlich"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{reward.availableFor && reward.availableFor.length > 0 && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Verfügbar für:
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{reward.availableFor.map(child => (
|
||||
<Chip
|
||||
key={child._id}
|
||||
label={child.name}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleEditReward(reward)}
|
||||
>
|
||||
<Edit />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => setDeleteDialog({ open: true, reward })}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 8 }}>
|
||||
<Typography variant="h4" sx={{ mb: 2 }}>🎁</Typography>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
{searchTerm || filterCategory ? 'Keine Belohnungen gefunden' : 'Noch keine Belohnungen erstellt'}
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{searchTerm || filterCategory
|
||||
? 'Versuche andere Suchkriterien oder Filter'
|
||||
: 'Erstelle die ersten Belohnungen für deine Familien-Helden!'
|
||||
}
|
||||
</Typography>
|
||||
{!searchTerm && !filterCategory && (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={handleCreateReward}
|
||||
>
|
||||
Erste Belohnung erstellen
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Anfragen Tab */}
|
||||
{currentTab === 1 && (
|
||||
<>
|
||||
{pendingRequests.length > 0 ? (
|
||||
<Grid container spacing={2}>
|
||||
{pendingRequests.map((request, index) => (
|
||||
<Grid size={12} key={request._id}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||
>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="h4" sx={{ mr: 2 }}>
|
||||
{request.reward.icon}
|
||||
</Typography>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
{request.reward.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{request.child.name} möchte diese Belohnung
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Chip
|
||||
label={`⭐ ${request.pointsSpent} Punkte`}
|
||||
color="secondary"
|
||||
size="small"
|
||||
/>
|
||||
<Chip
|
||||
label={getRequestStatusLabel(request.status)}
|
||||
color={getRequestStatusColor(request.status)}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 3 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Angefragt: {new Date(request.createdAt).toLocaleDateString('de-DE')}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 3 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="success"
|
||||
onClick={() => handleRequestAction(request._id, 'approve')}
|
||||
>
|
||||
Genehmigen
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => handleRequestAction(request._id, 'reject', 'Nicht verfügbar')}
|
||||
>
|
||||
Ablehnen
|
||||
</Button>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 8 }}>
|
||||
<Typography variant="h4" sx={{ mb: 2 }}>📬</Typography>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
Keine offenen Anfragen
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Alle Belohnungsanfragen wurden bearbeitet.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Genehmigte Anfragen Tab */}
|
||||
{currentTab === 2 && (
|
||||
<>
|
||||
{approvedRequests.length > 0 ? (
|
||||
<Grid container spacing={2}>
|
||||
{approvedRequests.map((request, index) => (
|
||||
<Grid size={12} key={request._id}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||
>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="h4" sx={{ mr: 2 }}>
|
||||
{request.reward.icon}
|
||||
</Typography>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
{request.reward.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Für {request.child.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Chip
|
||||
label={`⭐ ${request.pointsSpent} Punkte`}
|
||||
color="secondary"
|
||||
size="small"
|
||||
/>
|
||||
<Chip
|
||||
label="Bereit zur Einlösung"
|
||||
color="info"
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 3 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Genehmigt: {new Date(request.approvedAt).toLocaleDateString('de-DE')}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 3 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<Redeem />}
|
||||
onClick={() => handleRequestAction(request._id, 'redeem')}
|
||||
>
|
||||
Als eingelöst markieren
|
||||
</Button>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 8 }}>
|
||||
<Typography variant="h4" sx={{ mb: 2 }}>✅</Typography>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
Keine genehmigten Anfragen
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Alle genehmigten Belohnungen wurden bereits eingelöst.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
|
||||
{/* Belohnung erstellen/bearbeiten Dialog */}
|
||||
<Dialog
|
||||
open={rewardDialog.open}
|
||||
onClose={() => setRewardDialog({ open: false, reward: null, mode: 'create' })}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
{rewardDialog.mode === 'create' ? 'Neue Belohnung erstellen' : 'Belohnung bearbeiten'}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||
<Grid size={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Belohnungs-Titel"
|
||||
value={formData.title}
|
||||
onChange={(e) => handleFormChange('title', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Beschreibung (optional)"
|
||||
value={formData.description}
|
||||
onChange={(e) => handleFormChange('description', e.target.value)}
|
||||
multiline
|
||||
rows={2}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Kosten (Punkte)"
|
||||
type="number"
|
||||
value={formData.cost}
|
||||
onChange={(e) => handleFormChange('cost', parseInt(e.target.value))}
|
||||
inputProps={{ min: 1, max: 10000 }}
|
||||
required
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Kategorie</InputLabel>
|
||||
<Select
|
||||
value={formData.category}
|
||||
onChange={(e) => handleFormChange('category', e.target.value)}
|
||||
label="Kategorie"
|
||||
>
|
||||
{categories.map(category => (
|
||||
<MenuItem key={category.value} value={category.value}>
|
||||
{category.icon} {category.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Icon</InputLabel>
|
||||
<Select
|
||||
value={formData.icon}
|
||||
onChange={(e) => handleFormChange('icon', e.target.value)}
|
||||
label="Icon"
|
||||
>
|
||||
{icons.map(icon => (
|
||||
<MenuItem key={icon} value={icon}>
|
||||
{icon} {icon}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid size={12}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Verfügbar für Kinder</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={formData.availableFor}
|
||||
onChange={(e) => handleFormChange('availableFor', e.target.value)}
|
||||
label="Verfügbar für Kinder"
|
||||
renderValue={(selected) => (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{selected.map((value) => {
|
||||
const child = children.find(c => c._id === value);
|
||||
return (
|
||||
<Chip key={value} label={child?.name || value} size="small" />
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
>
|
||||
{children.map(child => (
|
||||
<MenuItem key={child._id} value={child._id}>
|
||||
{child.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid size={12}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={formData.requiresApproval}
|
||||
onChange={(e) => handleFormChange('requiresApproval', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Genehmigung durch Eltern erforderlich"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={12}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={formData.availability.isLimited}
|
||||
onChange={(e) => handleFormChange('availability.isLimited', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Begrenzte Verfügbarkeit"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{formData.availability.isLimited && (
|
||||
<>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Anzahl"
|
||||
type="number"
|
||||
value={formData.availability.quantity}
|
||||
onChange={(e) => handleFormChange('availability.quantity', parseInt(e.target.value))}
|
||||
inputProps={{ min: 1 }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Reset-Periode</InputLabel>
|
||||
<Select
|
||||
value={formData.availability.resetPeriod}
|
||||
onChange={(e) => handleFormChange('availability.resetPeriod', e.target.value)}
|
||||
label="Reset-Periode"
|
||||
>
|
||||
<MenuItem value="daily">Täglich</MenuItem>
|
||||
<MenuItem value="weekly">Wöchentlich</MenuItem>
|
||||
<MenuItem value="monthly">Monatlich</MenuItem>
|
||||
<MenuItem value="never">Nie</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setRewardDialog({ open: false, reward: null, mode: 'create' })}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveReward}
|
||||
variant="contained"
|
||||
disabled={!formData.title || !formData.cost}
|
||||
>
|
||||
{rewardDialog.mode === 'create' ? 'Erstellen' : 'Speichern'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Löschen-Bestätigung Dialog */}
|
||||
<Dialog
|
||||
open={deleteDialog.open}
|
||||
onClose={() => setDeleteDialog({ open: false, reward: null })}
|
||||
>
|
||||
<DialogTitle>Belohnung löschen</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>
|
||||
Möchtest du die Belohnung "{deleteDialog.reward?.title}" wirklich löschen?
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialog({ open: false, reward: null })}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={handleDeleteReward} color="error" variant="contained">
|
||||
Löschen
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default RewardManagement;
|
||||
733
client/src/pages/TaskManagement.js
Normal file
733
client/src/pages/TaskManagement.js
Normal file
@ -0,0 +1,733 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
TextField,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Chip,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction,
|
||||
IconButton,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Tabs,
|
||||
Tab,
|
||||
Avatar,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ArrowBack,
|
||||
Add,
|
||||
Edit,
|
||||
Delete,
|
||||
CheckCircle,
|
||||
Schedule,
|
||||
Assignment,
|
||||
FilterList,
|
||||
Search
|
||||
} from '@mui/icons-material';
|
||||
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
|
||||
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useFirebaseAuth } from '../contexts/FirebaseAuthContext';
|
||||
import { getTasks, createTask, updateTask, deleteTask, approveTask, rejectTask, getChildren } from '../firebase/database';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
|
||||
const TaskManagement = () => {
|
||||
const navigate = useNavigate();
|
||||
const { currentUser } = useFirebaseAuth();
|
||||
const { showSuccess, showError } = useNotification();
|
||||
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [children, setChildren] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentTab, setCurrentTab] = useState(0);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterChild, setFilterChild] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
|
||||
// Dialog States
|
||||
const [taskDialog, setTaskDialog] = useState({ open: false, task: null, mode: 'create' });
|
||||
const [deleteDialog, setDeleteDialog] = useState({ open: false, task: null });
|
||||
|
||||
// Form State
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
assignedTo: '',
|
||||
dueDate: new Date(),
|
||||
points: 10,
|
||||
category: 'household',
|
||||
difficulty: 'easy',
|
||||
priority: 'normal',
|
||||
estimatedTime: '',
|
||||
recurring: {
|
||||
isRecurring: false,
|
||||
frequency: 'daily',
|
||||
endDate: null
|
||||
}
|
||||
});
|
||||
|
||||
const categories = [
|
||||
{ value: 'household', label: 'Haushalt', icon: '🏠' },
|
||||
{ value: 'personal', label: 'Persönlich', icon: '👤' },
|
||||
{ value: 'homework', label: 'Hausaufgaben', icon: '📚' },
|
||||
{ value: 'chores', label: 'Aufräumen', icon: '🧹' },
|
||||
{ value: 'hygiene', label: 'Hygiene', icon: '🚿' },
|
||||
{ value: 'pets', label: 'Haustiere', icon: '🐕' },
|
||||
{ value: 'garden', label: 'Garten', icon: '🌱' },
|
||||
{ value: 'helping', label: 'Helfen', icon: '🤝' },
|
||||
{ value: 'learning', label: 'Lernen', icon: '🎓' },
|
||||
{ value: 'exercise', label: 'Sport', icon: '🏃' },
|
||||
{ value: 'other', label: 'Sonstiges', icon: '📝' }
|
||||
];
|
||||
|
||||
const difficulties = [
|
||||
{ value: 'easy', label: 'Einfach', color: 'success' },
|
||||
{ value: 'medium', label: 'Mittel', color: 'warning' },
|
||||
{ value: 'hard', label: 'Schwer', color: 'error' }
|
||||
];
|
||||
|
||||
const priorities = [
|
||||
{ value: 'low', label: 'Niedrig', color: 'default' },
|
||||
{ value: 'normal', label: 'Normal', color: 'primary' },
|
||||
{ value: 'high', label: 'Hoch', color: 'warning' },
|
||||
{ value: 'urgent', label: 'Dringend', color: 'error' }
|
||||
];
|
||||
|
||||
// Daten laden
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
// Nur laden wenn Benutzer authentifiziert ist
|
||||
if (!currentUser) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const [tasksResponse, childrenResponse] = await Promise.all([
|
||||
getTasks(),
|
||||
getChildren()
|
||||
]);
|
||||
|
||||
if (tasksResponse.success) {
|
||||
setTasks(tasksResponse.tasks);
|
||||
}
|
||||
if (childrenResponse.success) {
|
||||
setChildren(childrenResponse.children);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Daten:', error);
|
||||
showError('Fehler beim Laden der Aufgaben');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [currentUser, showError]);
|
||||
|
||||
// Aufgaben filtern
|
||||
const getFilteredTasks = () => {
|
||||
let filtered = tasks;
|
||||
|
||||
// Nach Tab filtern
|
||||
switch (currentTab) {
|
||||
case 0: // Alle
|
||||
break;
|
||||
case 1: // Offen
|
||||
filtered = filtered.filter(task => task.status === 'pending');
|
||||
break;
|
||||
case 2: // Warten auf Genehmigung
|
||||
filtered = filtered.filter(task => task.status === 'completed');
|
||||
break;
|
||||
case 3: // Genehmigt
|
||||
filtered = filtered.filter(task => task.status === 'approved');
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Nach Suchbegriff filtern
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter(task =>
|
||||
task.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
task.description?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Nach Kind filtern
|
||||
if (filterChild) {
|
||||
filtered = filtered.filter(task => (task.assignedTo.id || task.assignedTo) === filterChild);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
const handleFormChange = (field, value) => {
|
||||
if (field.includes('.')) {
|
||||
const [parent, child] = field.split('.');
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[parent]: {
|
||||
...prev[parent],
|
||||
[child]: value
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateTask = () => {
|
||||
setFormData({
|
||||
title: '',
|
||||
description: '',
|
||||
assignedTo: '',
|
||||
dueDate: new Date(),
|
||||
points: 10,
|
||||
category: 'household',
|
||||
difficulty: 'easy',
|
||||
priority: 'normal',
|
||||
estimatedTime: '',
|
||||
recurring: {
|
||||
isRecurring: false,
|
||||
frequency: 'daily',
|
||||
endDate: null
|
||||
}
|
||||
});
|
||||
setTaskDialog({ open: true, task: null, mode: 'create' });
|
||||
};
|
||||
|
||||
const handleEditTask = (task) => {
|
||||
setFormData({
|
||||
title: task.title,
|
||||
description: task.description || '',
|
||||
assignedTo: task.assignedTo.id || task.assignedTo,
|
||||
dueDate: new Date(task.dueDate),
|
||||
points: task.points,
|
||||
category: task.category,
|
||||
difficulty: task.difficulty,
|
||||
priority: task.priority,
|
||||
estimatedTime: task.estimatedTime || '',
|
||||
recurring: task.recurring || {
|
||||
isRecurring: false,
|
||||
frequency: 'daily',
|
||||
endDate: null
|
||||
}
|
||||
});
|
||||
setTaskDialog({ open: true, task, mode: 'edit' });
|
||||
};
|
||||
|
||||
const handleSaveTask = async () => {
|
||||
try {
|
||||
const taskData = {
|
||||
...formData,
|
||||
dueDate: formData.dueDate.toISOString()
|
||||
};
|
||||
|
||||
let response;
|
||||
if (taskDialog.mode === 'create') {
|
||||
response = await createTask(taskData);
|
||||
} else {
|
||||
response = await updateTask(taskDialog.task.id, taskData);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
showSuccess(response.message);
|
||||
setTaskDialog({ open: false, task: null, mode: 'create' });
|
||||
|
||||
// Aufgaben neu laden
|
||||
const tasksResponse = await getTasks();
|
||||
if (tasksResponse.success) {
|
||||
setTasks(tasksResponse.tasks);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message || 'Fehler beim Speichern der Aufgabe');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTask = async () => {
|
||||
try {
|
||||
const response = await deleteTask(deleteDialog.task.id);
|
||||
|
||||
if (response.success) {
|
||||
showSuccess(response.message);
|
||||
setDeleteDialog({ open: false, task: null });
|
||||
|
||||
// Aufgabe aus Liste entfernen
|
||||
setTasks(prev => prev.filter(task => task.id !== deleteDialog.task.id));
|
||||
}
|
||||
} catch (error) {
|
||||
showError('Fehler beim Löschen der Aufgabe');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTaskAction = async (taskId, action) => {
|
||||
try {
|
||||
let response;
|
||||
if (action === 'approve') {
|
||||
response = await approveTask(taskId);
|
||||
} else if (action === 'reject') {
|
||||
response = await rejectTask(taskId, 'Bitte nochmal versuchen');
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
showSuccess(response.message);
|
||||
|
||||
// Aufgaben neu laden
|
||||
const tasksResponse = await getTasks();
|
||||
if (tasksResponse.success) {
|
||||
setTasks(tasksResponse.tasks);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
showError('Fehler beim Bearbeiten der Aufgabe');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'pending': return 'warning';
|
||||
case 'completed': return 'info';
|
||||
case 'approved': return 'success';
|
||||
case 'rejected': return 'error';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status) => {
|
||||
switch (status) {
|
||||
case 'pending': return 'Offen';
|
||||
case 'completed': return 'Erledigt';
|
||||
case 'approved': return 'Genehmigt';
|
||||
case 'rejected': return 'Abgelehnt';
|
||||
default: return status;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredTasks = getFilteredTasks();
|
||||
|
||||
return (
|
||||
<LocalizationProvider dateAdapter={AdapterDateFns}>
|
||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default' }}>
|
||||
{/* App Bar */}
|
||||
<AppBar position="static" elevation={0}>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
edge="start"
|
||||
color="inherit"
|
||||
onClick={() => navigate('/dashboard')}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
|
||||
<Assignment sx={{ mr: 2 }} />
|
||||
<Typography variant="h6" sx={{ flexGrow: 1, fontWeight: 700 }}>
|
||||
Aufgaben-Management
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
color="inherit"
|
||||
startIcon={<Add />}
|
||||
onClick={handleCreateTask}
|
||||
sx={{ fontWeight: 600 }}
|
||||
>
|
||||
Neue Aufgabe
|
||||
</Button>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||
{/* Filter und Suche */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="Aufgaben durchsuchen..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: <Search sx={{ mr: 1, color: 'action.active' }} />
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Kind filtern</InputLabel>
|
||||
<Select
|
||||
value={filterChild}
|
||||
onChange={(e) => setFilterChild(e.target.value)}
|
||||
label="Kind filtern"
|
||||
>
|
||||
<MenuItem value="">Alle Kinder</MenuItem>
|
||||
{children.map(child => (
|
||||
<MenuItem key={child.id} value={child.id}>
|
||||
{child.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{filteredTasks.length} von {tasks.length} Aufgaben
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tabs */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Tabs
|
||||
value={currentTab}
|
||||
onChange={(e, newValue) => setCurrentTab(newValue)}
|
||||
variant="fullWidth"
|
||||
>
|
||||
<Tab label={`Alle (${tasks.length})`} />
|
||||
<Tab label={`Offen (${tasks.filter(t => t.status === 'pending').length})`} />
|
||||
<Tab label={`Warten (${tasks.filter(t => t.status === 'completed').length})`} />
|
||||
<Tab label={`Genehmigt (${tasks.filter(t => t.status === 'approved').length})`} />
|
||||
</Tabs>
|
||||
</Card>
|
||||
|
||||
{/* Aufgaben-Liste */}
|
||||
{filteredTasks.length > 0 ? (
|
||||
<Grid container spacing={2}>
|
||||
{filteredTasks.map((task, index) => (
|
||||
<Grid size={12} key={task.id}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||
>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mr: 2 }}>
|
||||
{task.title}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={getStatusLabel(task.status)}
|
||||
color={getStatusColor(task.status)}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{task.description && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{task.description}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Chip
|
||||
label={`⭐ ${task.points} Punkte`}
|
||||
color="secondary"
|
||||
size="small"
|
||||
/>
|
||||
<Chip
|
||||
label={categories.find(c => c.value === task.category)?.label || task.category}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Chip
|
||||
label={difficulties.find(d => d.value === task.difficulty)?.label || task.difficulty}
|
||||
color={difficulties.find(d => d.value === task.difficulty)?.color || 'default'}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
|
||||
<Avatar sx={{ width: 32, height: 32, mr: 1, bgcolor: 'primary.main' }}>
|
||||
{task.assignedTo.name.charAt(0)}
|
||||
</Avatar>
|
||||
<Typography variant="body2">
|
||||
{task.assignedTo.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Fällig: {new Date(task.dueDate).toLocaleDateString('de-DE')}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 3 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
{task.status === 'completed' && (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="success"
|
||||
onClick={() => handleTaskAction(task.id, 'approve')}
|
||||
>
|
||||
Genehmigen
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => handleTaskAction(task.id, 'reject')}
|
||||
>
|
||||
Ablehnen
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(task.status === 'pending' || task.status === 'rejected') && (
|
||||
<>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleEditTask(task)}
|
||||
>
|
||||
<Edit />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => setDeleteDialog({ open: true, task })}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 8 }}>
|
||||
<Typography variant="h4" sx={{ mb: 2 }}>📝</Typography>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
{searchTerm || filterChild ? 'Keine Aufgaben gefunden' : 'Noch keine Aufgaben erstellt'}
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{searchTerm || filterChild
|
||||
? 'Versuche andere Suchkriterien oder Filter'
|
||||
: 'Erstelle die erste Aufgabe für deine Familien-Helden!'
|
||||
}
|
||||
</Typography>
|
||||
{!searchTerm && !filterChild && (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={handleCreateTask}
|
||||
>
|
||||
Erste Aufgabe erstellen
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Container>
|
||||
|
||||
{/* Aufgabe erstellen/bearbeiten Dialog */}
|
||||
<Dialog
|
||||
open={taskDialog.open}
|
||||
onClose={() => setTaskDialog({ open: false, task: null, mode: 'create' })}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
{taskDialog.mode === 'create' ? 'Neue Aufgabe erstellen' : 'Aufgabe bearbeiten'}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||
<Grid size={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Aufgaben-Titel"
|
||||
value={formData.title}
|
||||
onChange={(e) => handleFormChange('title', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Beschreibung (optional)"
|
||||
value={formData.description}
|
||||
onChange={(e) => handleFormChange('description', e.target.value)}
|
||||
multiline
|
||||
rows={2}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Kind zuweisen</InputLabel>
|
||||
<Select
|
||||
value={formData.assignedTo}
|
||||
onChange={(e) => handleFormChange('assignedTo', e.target.value)}
|
||||
label="Kind zuweisen"
|
||||
>
|
||||
{children.map(child => (
|
||||
<MenuItem key={child.id} value={child.id}>
|
||||
{child.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<DatePicker
|
||||
label="Fälligkeitsdatum"
|
||||
value={formData.dueDate}
|
||||
onChange={(date) => handleFormChange('dueDate', date)}
|
||||
renderInput={(params) => <TextField {...params} fullWidth />}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Punkte"
|
||||
type="number"
|
||||
value={formData.points}
|
||||
onChange={(e) => handleFormChange('points', parseInt(e.target.value))}
|
||||
inputProps={{ min: 1, max: 500 }}
|
||||
required
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Kategorie</InputLabel>
|
||||
<Select
|
||||
value={formData.category}
|
||||
onChange={(e) => handleFormChange('category', e.target.value)}
|
||||
label="Kategorie"
|
||||
>
|
||||
{categories.map(category => (
|
||||
<MenuItem key={category.value} value={category.value}>
|
||||
{category.icon} {category.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Schwierigkeit</InputLabel>
|
||||
<Select
|
||||
value={formData.difficulty}
|
||||
onChange={(e) => handleFormChange('difficulty', e.target.value)}
|
||||
label="Schwierigkeit"
|
||||
>
|
||||
{difficulties.map(difficulty => (
|
||||
<MenuItem key={difficulty.value} value={difficulty.value}>
|
||||
{difficulty.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
<Grid size={12}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={formData.recurring.isRecurring}
|
||||
onChange={(e) => handleFormChange('recurring.isRecurring', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Wiederkehrende Aufgabe"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{formData.recurring.isRecurring && (
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Häufigkeit</InputLabel>
|
||||
<Select
|
||||
value={formData.recurring.frequency}
|
||||
onChange={(e) => handleFormChange('recurring.frequency', e.target.value)}
|
||||
label="Häufigkeit"
|
||||
>
|
||||
<MenuItem value="daily">Täglich</MenuItem>
|
||||
<MenuItem value="weekly">Wöchentlich</MenuItem>
|
||||
<MenuItem value="monthly">Monatlich</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setTaskDialog({ open: false, task: null, mode: 'create' })}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveTask}
|
||||
variant="contained"
|
||||
disabled={!formData.title || !formData.assignedTo}
|
||||
>
|
||||
{taskDialog.mode === 'create' ? 'Erstellen' : 'Speichern'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Löschen-Bestätigung Dialog */}
|
||||
<Dialog
|
||||
open={deleteDialog.open}
|
||||
onClose={() => setDeleteDialog({ open: false, task: null })}
|
||||
>
|
||||
<DialogTitle>Aufgabe löschen</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>
|
||||
Möchtest du die Aufgabe "{deleteDialog.task?.title}" wirklich löschen?
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialog({ open: false, task: null })}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={handleDeleteTask} color="error" variant="contained">
|
||||
Löschen
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
</LocalizationProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskManagement;
|
||||
13
client/src/reportWebVitals.js
Normal file
13
client/src/reportWebVitals.js
Normal file
@ -0,0 +1,13 @@
|
||||
const reportWebVitals = onPerfEntry => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
||||
5
client/src/setupTests.js
Normal file
5
client/src/setupTests.js
Normal file
@ -0,0 +1,5 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
952
client/src/themes/childThemes.js
Normal file
952
client/src/themes/childThemes.js
Normal file
@ -0,0 +1,952 @@
|
||||
import { createTheme } from '@mui/material/styles';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
|
||||
// Basis-Theme-Konfiguration
|
||||
const baseTheme = {
|
||||
typography: {
|
||||
fontFamily: '"Comic Neue", "Roboto", "Helvetica", "Arial", sans-serif',
|
||||
h1: { fontWeight: 700 },
|
||||
h2: { fontWeight: 700 },
|
||||
h3: { fontWeight: 600 },
|
||||
h4: { fontWeight: 600 },
|
||||
h5: { fontWeight: 600 },
|
||||
h6: { fontWeight: 600 },
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 16,
|
||||
},
|
||||
components: {
|
||||
MuiCssBaseline: {
|
||||
styleOverrides: (theme) => ({
|
||||
body: {
|
||||
transition: 'background-image 0.5s ease-in-out',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundAttachment: 'fixed',
|
||||
minHeight: '100vh',
|
||||
},
|
||||
}),
|
||||
},
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 20,
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
fontSize: '1.1rem',
|
||||
padding: '12px 24px',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-3px)',
|
||||
boxShadow: '0 10px 20px rgba(0, 0, 0, 0.15)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 20,
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.1)',
|
||||
transition: 'transform 0.3s, box-shadow 0.3s',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-5px)',
|
||||
boxShadow: '0 12px 28px rgba(0, 0, 0, 0.15)',
|
||||
},
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 0,
|
||||
opacity: 0.05,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 16,
|
||||
fontWeight: 600,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 0,
|
||||
opacity: 0.05,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Farbenfrohe Theme (Standard)
|
||||
const colorfulTheme = createTheme({
|
||||
...baseTheme,
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: {
|
||||
main: '#FF6B6B', // Freundliches Rot
|
||||
light: '#FF8E8E',
|
||||
dark: '#E55555',
|
||||
contrastText: '#FFFFFF',
|
||||
},
|
||||
secondary: {
|
||||
main: '#4ECDC4', // Türkis
|
||||
light: '#7DD3CC',
|
||||
dark: '#3BA99F',
|
||||
contrastText: '#FFFFFF',
|
||||
},
|
||||
success: {
|
||||
main: '#45B7D1', // Helles Blau
|
||||
light: '#6BC5D8',
|
||||
dark: '#3A9BC1',
|
||||
},
|
||||
warning: {
|
||||
main: '#FFA726', // Orange
|
||||
light: '#FFB74D',
|
||||
dark: '#F57C00',
|
||||
},
|
||||
error: {
|
||||
main: '#EF5350',
|
||||
light: '#E57373',
|
||||
dark: '#C62828',
|
||||
},
|
||||
background: {
|
||||
default: '#FFF8E1', // Warmes Cremeweiß
|
||||
paper: '#FFFFFF',
|
||||
},
|
||||
text: {
|
||||
primary: '#2D3748',
|
||||
secondary: '#4A5568',
|
||||
},
|
||||
},
|
||||
themeIcon: '🌈',
|
||||
components: {
|
||||
MuiCssBaseline: {
|
||||
styleOverrides: {
|
||||
body: {
|
||||
backgroundImage: 'linear-gradient(135deg, #FFF8E1 0%, #FFECB3 100%)',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'100%\' height=\'100%\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cdefs%3E%3ClinearGradient id=\'rainbow\' x1=\'0%\' y1=\'0%\' x2=\'100%\' y2=\'0%\'%3E%3Cstop offset=\'0%\' stop-color=\'%23FF6B6B\' stop-opacity=\'0.2\' /%3E%3Cstop offset=\'20%\' stop-color=\'%23FFD93D\' stop-opacity=\'0.2\' /%3E%3Cstop offset=\'40%\' stop-color=\'%236BCB77\' stop-opacity=\'0.2\' /%3E%3Cstop offset=\'60%\' stop-color=\'%234ECDC4\' stop-opacity=\'0.2\' /%3E%3Cstop offset=\'80%\' stop-color=\'%236A0572\' stop-opacity=\'0.2\' /%3E%3Cstop offset=\'100%\' stop-color=\'%23FF6B6B\' stop-opacity=\'0.2\' /%3E%3C/linearGradient%3E%3C/defs%3E%3Cpath d=\'M0,40 Q50,60 100,40 Q150,20 200,40 Q250,60 300,40 Q350,20 400,40 Q450,60 500,40 Q550,20 600,40 Q650,60 700,40 Q750,20 800,40 Q850,60 900,40 Q950,20 1000,40 Q1050,60 1100,40 Q1150,20 1200,40 Q1250,60 1300,40 Q1350,20 1400,40 Q1450,60 1500,40 Q1550,20 1600,40 Q1650,60 1700,40 Q1750,20 1800,40 Q1850,60 1900,40 Q1950,20 2000,40\' stroke=\'url(%23rainbow)\' stroke-width=\'20\' fill=\'none\' /%3E%3Cpath d=\'M0,80 Q50,100 100,80 Q150,60 200,80 Q250,100 300,80 Q350,60 400,80 Q450,100 500,80 Q550,60 600,80 Q650,100 700,80 Q750,60 800,80 Q850,100 900,80 Q950,60 1000,80 Q1050,100 1100,80 Q1150,60 1200,80 Q1250,100 1300,80 Q1350,60 1400,80 Q1450,100 1500,80 Q1550,60 1600,80 Q1650,100 1700,80 Q1750,60 1800,80 Q1850,100 1900,80 Q1950,60 2000,80\' stroke=\'url(%23rainbow)\' stroke-width=\'20\' fill=\'none\' /%3E%3Cpath d=\'M0,120 Q50,140 100,120 Q150,100 200,120 Q250,140 300,120 Q350,100 400,120 Q450,140 500,120 Q550,100 600,120 Q650,140 700,120 Q750,100 800,120 Q850,140 900,120 Q950,100 1000,120 Q1050,140 1100,120 Q1150,100 1200,120 Q1250,140 1300,120 Q1350,100 1400,120 Q1450,140 1500,120 Q1550,100 1600,120 Q1650,140 1700,120 Q1750,100 1800,120 Q1850,140 1900,120 Q1950,100 2000,120\' stroke=\'url(%23rainbow)\' stroke-width=\'20\' fill=\'none\' /%3E%3C/svg%3E")',
|
||||
opacity: 0.3,
|
||||
zIndex: -1,
|
||||
pointerEvents: 'none',
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'&::before': {
|
||||
backgroundImage: 'linear-gradient(135deg, rgba(255,107,107,0.1) 0%, rgba(78,205,196,0.1) 100%)',
|
||||
},
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
bottom: -5,
|
||||
left: '10%',
|
||||
width: '80%',
|
||||
height: 10,
|
||||
background: 'linear-gradient(90deg, #FF6B6B, #FFD93D, #6BCB77, #4ECDC4, #6A0572)',
|
||||
borderRadius: 10,
|
||||
opacity: 0.6,
|
||||
filter: 'blur(3px)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
background: 'linear-gradient(45deg, #FF6B6B 0%, #4ECDC4 100%)',
|
||||
backgroundSize: '200% 200%',
|
||||
animation: 'gradient-animation 5s ease infinite',
|
||||
'@keyframes gradient-animation': {
|
||||
'0%': { backgroundPosition: '0% 50%' },
|
||||
'50%': { backgroundPosition: '100% 50%' },
|
||||
'100%': { backgroundPosition: '0% 50%' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'&::before': {
|
||||
backgroundImage: 'linear-gradient(135deg, rgba(255,107,107,0.05) 0%, rgba(78,205,196,0.05) 100%)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Natur Theme
|
||||
const natureTheme = createTheme({
|
||||
...baseTheme,
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: {
|
||||
main: '#4CAF50', // Grün
|
||||
light: '#81C784',
|
||||
dark: '#388E3C',
|
||||
contrastText: '#FFFFFF',
|
||||
},
|
||||
secondary: {
|
||||
main: '#8BC34A', // Hellgrün
|
||||
light: '#AED581',
|
||||
dark: '#689F38',
|
||||
contrastText: '#FFFFFF',
|
||||
},
|
||||
success: {
|
||||
main: '#66BB6A',
|
||||
light: '#81C784',
|
||||
dark: '#4CAF50',
|
||||
},
|
||||
warning: {
|
||||
main: '#FF8F00', // Bernstein
|
||||
light: '#FFA726',
|
||||
dark: '#E65100',
|
||||
},
|
||||
error: {
|
||||
main: '#D32F2F',
|
||||
light: '#EF5350',
|
||||
dark: '#C62828',
|
||||
},
|
||||
background: {
|
||||
default: '#F1F8E9', // Sehr helles Grün
|
||||
paper: '#FFFFFF',
|
||||
},
|
||||
text: {
|
||||
primary: '#1B5E20',
|
||||
secondary: '#2E7D32',
|
||||
},
|
||||
},
|
||||
themeIcon: '🌿',
|
||||
components: {
|
||||
MuiCssBaseline: {
|
||||
styleOverrides: {
|
||||
body: {
|
||||
backgroundImage: 'linear-gradient(135deg, #F1F8E9 0%, #DCEDC8 100%)',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'100%\' height=\'100%\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cg fill=\'%234CAF50\' fill-opacity=\'0.1\'%3E%3Cpath d=\'M30,20 Q40,5 50,20 Q60,35 70,20 L70,50 L30,50 Z\' /%3E%3C/g%3E%3Cg fill=\'%238BC34A\' fill-opacity=\'0.1\' transform=\'translate(100,0)\'%3E%3Cpath d=\'M30,20 Q40,5 50,20 Q60,35 70,20 L70,50 L30,50 Z\' /%3E%3C/g%3E%3Cg fill=\'%234CAF50\' fill-opacity=\'0.1\' transform=\'translate(200,0)\'%3E%3Cpath d=\'M30,20 Q40,5 50,20 Q60,35 70,20 L70,50 L30,50 Z\' /%3E%3C/g%3E%3Cg fill=\'%238BC34A\' fill-opacity=\'0.1\' transform=\'translate(0,100)\'%3E%3Cpath d=\'M30,20 Q40,5 50,20 Q60,35 70,20 L70,50 L30,50 Z\' /%3E%3C/g%3E%3Cg fill=\'%234CAF50\' fill-opacity=\'0.1\' transform=\'translate(100,100)\'%3E%3Cpath d=\'M30,20 Q40,5 50,20 Q60,35 70,20 L70,50 L30,50 Z\' /%3E%3C/g%3E%3Cg fill=\'%238BC34A\' fill-opacity=\'0.1\' transform=\'translate(200,100)\'%3E%3Cpath d=\'M30,20 Q40,5 50,20 Q60,35 70,20 L70,50 L30,50 Z\' /%3E%3C/g%3E%3C/svg%3E")',
|
||||
backgroundSize: '300px 300px',
|
||||
opacity: 0.5,
|
||||
zIndex: -1,
|
||||
pointerEvents: 'none',
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'&::before': {
|
||||
backgroundImage: 'linear-gradient(135deg, rgba(76,175,80,0.1) 0%, rgba(139,195,74,0.1) 100%)',
|
||||
},
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
bottom: -2,
|
||||
left: '5%',
|
||||
width: '90%',
|
||||
height: 5,
|
||||
background: 'linear-gradient(90deg, #4CAF50, #8BC34A)',
|
||||
borderRadius: 5,
|
||||
opacity: 0.6,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: -10,
|
||||
left: -10,
|
||||
right: -10,
|
||||
bottom: -10,
|
||||
backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'100\' height=\'100\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cpath d=\'M30,20 Q40,5 50,20 Q60,35 70,20 L70,50 L30,50 Z\' fill=\'%234CAF50\' fill-opacity=\'0.2\' /%3E%3C/svg%3E")',
|
||||
backgroundSize: '100px 100px',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.3s',
|
||||
zIndex: -1,
|
||||
},
|
||||
'&:hover::before': {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'&::before': {
|
||||
backgroundImage: 'linear-gradient(135deg, rgba(76,175,80,0.05) 0%, rgba(139,195,74,0.05) 100%)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Weltraum Theme
|
||||
const spaceTheme = createTheme({
|
||||
...baseTheme,
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
primary: {
|
||||
main: '#9C27B0', // Lila
|
||||
light: '#BA68C8',
|
||||
dark: '#7B1FA2',
|
||||
contrastText: '#FFFFFF',
|
||||
},
|
||||
secondary: {
|
||||
main: '#3F51B5', // Indigo
|
||||
light: '#7986CB',
|
||||
dark: '#303F9F',
|
||||
contrastText: '#FFFFFF',
|
||||
},
|
||||
success: {
|
||||
main: '#00BCD4', // Cyan
|
||||
light: '#4DD0E1',
|
||||
dark: '#0097A7',
|
||||
},
|
||||
warning: {
|
||||
main: '#FF9800',
|
||||
light: '#FFB74D',
|
||||
dark: '#F57C00',
|
||||
},
|
||||
error: {
|
||||
main: '#F44336',
|
||||
light: '#EF5350',
|
||||
dark: '#D32F2F',
|
||||
},
|
||||
background: {
|
||||
default: '#0D1421', // Dunkles Weltraum-Blau
|
||||
paper: '#1A202C',
|
||||
},
|
||||
text: {
|
||||
primary: '#E2E8F0',
|
||||
secondary: '#A0AEC0',
|
||||
},
|
||||
},
|
||||
themeIcon: '🚀',
|
||||
components: {
|
||||
MuiCssBaseline: {
|
||||
styleOverrides: {
|
||||
body: {
|
||||
background: 'radial-gradient(ellipse at center, #0a0a0a 0%, #000000 100%)',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'100%\' height=\'100%\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cdefs%3E%3CradialGradient id=\'star\' cx=\'50%\' cy=\'50%\' r=\'50%\'%3E%3Cstop offset=\'0%\' style=\'stop-color:%23ffffff;stop-opacity:0.9\' /%3E%3Cstop offset=\'100%\' style=\'stop-color:%23ffffff;stop-opacity:0\' /%3E%3C/radialGradient%3E%3C/defs%3E%3Cg%3E%3Ccircle cx=\'5%\' cy=\'10%\' r=\'1\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'15%\' cy=\'25%\' r=\'0.8\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'25%\' cy=\'8%\' r=\'1.2\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'35%\' cy=\'30%\' r=\'0.6\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'45%\' cy=\'15%\' r=\'1\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'55%\' cy=\'35%\' r=\'0.9\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'65%\' cy=\'12%\' r=\'0.7\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'75%\' cy=\'28%\' r=\'1.1\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'85%\' cy=\'18%\' r=\'0.8\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'95%\' cy=\'32%\' r=\'1\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'8%\' cy=\'45%\' r=\'0.9\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'18%\' cy=\'60%\' r=\'0.7\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'28%\' cy=\'50%\' r=\'1.2\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'38%\' cy=\'65%\' r=\'0.8\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'48%\' cy=\'55%\' r=\'1\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'58%\' cy=\'70%\' r=\'0.6\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'68%\' cy=\'48%\' r=\'1.1\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'78%\' cy=\'62%\' r=\'0.9\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'88%\' cy=\'52%\' r=\'0.7\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'92%\' cy=\'68%\' r=\'1\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'12%\' cy=\'80%\' r=\'0.8\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'22%\' cy=\'85%\' r=\'1\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'32%\' cy=\'78%\' r=\'0.9\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'42%\' cy=\'88%\' r=\'0.7\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'52%\' cy=\'82%\' r=\'1.1\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'62%\' cy=\'90%\' r=\'0.8\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'72%\' cy=\'85%\' r=\'1\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'82%\' cy=\'78%\' r=\'0.6\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'90%\' cy=\'88%\' r=\'0.9\' fill=\'url(%23star)\' /%3E%3C/g%3E%3C/svg%3E")',
|
||||
backgroundSize: 'cover',
|
||||
opacity: 0.7,
|
||||
zIndex: -2,
|
||||
pointerEvents: 'none',
|
||||
animation: 'twinkle 4s ease-in-out infinite alternate',
|
||||
},
|
||||
'@keyframes twinkle': {
|
||||
'0%': { opacity: 0.5 },
|
||||
'100%': { opacity: 0.9 },
|
||||
},
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'fixed',
|
||||
top: '20%',
|
||||
right: '10%',
|
||||
width: '150px',
|
||||
height: '150px',
|
||||
background: 'radial-gradient(circle, rgba(255,165,0,0.8) 0%, rgba(255,69,0,0.6) 30%, rgba(139,69,19,0.4) 60%, rgba(0,0,0,0) 100%)',
|
||||
borderRadius: '50%',
|
||||
zIndex: -1,
|
||||
pointerEvents: 'none',
|
||||
animation: 'planet-glow 8s ease-in-out infinite alternate',
|
||||
},
|
||||
'@keyframes planet-glow': {
|
||||
'0%': { opacity: 0.6, transform: 'scale(1)' },
|
||||
'100%': { opacity: 0.9, transform: 'scale(1.1)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
background: 'linear-gradient(135deg, rgba(15, 15, 25, 0.95) 0%, rgba(25, 25, 40, 0.95) 100%)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
border: '2px solid rgba(0, 255, 255, 0.3)',
|
||||
borderRadius: 12,
|
||||
boxShadow: '0 0 20px rgba(0, 255, 255, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.1)',
|
||||
position: 'relative',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'100\' height=\'100\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cdefs%3E%3ClinearGradient id=\'circuit\' x1=\'0%\' y1=\'0%\' x2=\'100%\' y2=\'100%\'%3E%3Cstop offset=\'0%\' style=\'stop-color:%2300ffff;stop-opacity:0.3\' /%3E%3Cstop offset=\'100%\' style=\'stop-color:%230080ff;stop-opacity:0.1\' /%3E%3C/linearGradient%3E%3C/defs%3E%3Cg%3E%3Crect x=\'10\' y=\'10\' width=\'20\' height=\'2\' fill=\'url(%23circuit)\' /%3E%3Crect x=\'40\' y=\'20\' width=\'15\' height=\'2\' fill=\'url(%23circuit)\' /%3E%3Crect x=\'70\' y=\'15\' width=\'25\' height=\'2\' fill=\'url(%23circuit)\' /%3E%3Ccircle cx=\'15\' cy=\'30\' r=\'2\' fill=\'%2300ffff\' fill-opacity=\'0.4\' /%3E%3Ccircle cx=\'50\' cy=\'40\' r=\'1.5\' fill=\'%2300ffff\' fill-opacity=\'0.3\' /%3E%3Ccircle cx=\'80\' cy=\'35\' r=\'2.5\' fill=\'%2300ffff\' fill-opacity=\'0.5\' /%3E%3C/g%3E%3C/svg%3E")',
|
||||
backgroundSize: '100px 100px',
|
||||
opacity: 0.6,
|
||||
zIndex: 0,
|
||||
pointerEvents: 'none',
|
||||
animation: 'circuit-pulse 4s ease-in-out infinite',
|
||||
},
|
||||
'@keyframes circuit-pulse': {
|
||||
'0%': { opacity: 0.4 },
|
||||
'50%': { opacity: 0.8 },
|
||||
'100%': { opacity: 0.4 },
|
||||
},
|
||||
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
background: 'linear-gradient(45deg, #001122 0%, #003366 100%)',
|
||||
border: '2px solid rgba(0, 255, 255, 0.5)',
|
||||
boxShadow: '0 0 15px rgba(0, 255, 255, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'50\' height=\'50\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cg%3E%3Crect x=\'5\' y=\'20\' width=\'10\' height=\'1\' fill=\'%2300ffff\' fill-opacity=\'0.4\' /%3E%3Crect x=\'25\' y=\'15\' width=\'8\' height=\'1\' fill=\'%2300ffff\' fill-opacity=\'0.3\' /%3E%3Crect x=\'35\' y=\'25\' width=\'12\' height=\'1\' fill=\'%2300ffff\' fill-opacity=\'0.5\' /%3E%3Ccircle cx=\'10\' cy=\'10\' r=\'1\' fill=\'%2300ffff\' fill-opacity=\'0.6\' /%3E%3Ccircle cx=\'40\' cy=\'35\' r=\'0.8\' fill=\'%2300ffff\' fill-opacity=\'0.4\' /%3E%3C/g%3E%3C/svg%3E")',
|
||||
backgroundSize: '50px 50px',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.3s ease',
|
||||
animation: 'tech-pattern 6s linear infinite',
|
||||
},
|
||||
'@keyframes tech-pattern': {
|
||||
'0%': { backgroundPosition: '0 0' },
|
||||
'100%': { backgroundPosition: '50px 50px' },
|
||||
},
|
||||
'&:hover::before': {
|
||||
opacity: 1,
|
||||
},
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: '-100%',
|
||||
width: '200%',
|
||||
height: '100%',
|
||||
background: 'linear-gradient(90deg, rgba(0,255,255,0) 0%, rgba(0,255,255,0.4) 50%, rgba(0,255,255,0) 100%)',
|
||||
transform: 'skewX(-20deg)',
|
||||
transition: 'all 0.6s ease',
|
||||
},
|
||||
'&:hover': {
|
||||
boxShadow: '0 0 25px rgba(0, 255, 255, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.2)',
|
||||
borderColor: 'rgba(0, 255, 255, 0.8)',
|
||||
},
|
||||
'&:hover::after': {
|
||||
left: '100%',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
background: 'linear-gradient(135deg, rgba(10, 10, 20, 0.9) 0%, rgba(20, 20, 35, 0.9) 100%)',
|
||||
backdropFilter: 'blur(6px)',
|
||||
border: '1px solid rgba(0, 255, 255, 0.2)',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 4px 15px rgba(0, 255, 255, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.05)',
|
||||
position: 'relative',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'40\' height=\'40\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cg%3E%3Crect x=\'5\' y=\'15\' width=\'8\' height=\'0.5\' fill=\'%2300ffff\' fill-opacity=\'0.2\' /%3E%3Crect x=\'20\' y=\'10\' width=\'6\' height=\'0.5\' fill=\'%2300ffff\' fill-opacity=\'0.15\' /%3E%3Crect x=\'30\' y=\'20\' width=\'7\' height=\'0.5\' fill=\'%2300ffff\' fill-opacity=\'0.25\' /%3E%3Ccircle cx=\'8\' cy=\'8\' r=\'0.5\' fill=\'%2300ffff\' fill-opacity=\'0.3\' /%3E%3Ccircle cx=\'25\' cy=\'25\' r=\'0.4\' fill=\'%2300ffff\' fill-opacity=\'0.2\' /%3E%3Ccircle cx=\'35\' cy=\'12\' r=\'0.6\' fill=\'%2300ffff\' fill-opacity=\'0.25\' /%3E%3C/g%3E%3C/svg%3E")',
|
||||
backgroundSize: '40px 40px',
|
||||
opacity: 0.5,
|
||||
zIndex: 0,
|
||||
pointerEvents: 'none',
|
||||
animation: 'subtle-tech 8s ease-in-out infinite',
|
||||
},
|
||||
'@keyframes subtle-tech': {
|
||||
'0%': { opacity: 0.3, backgroundPosition: '0 0' },
|
||||
'50%': { opacity: 0.7, backgroundPosition: '20px 20px' },
|
||||
'100%': { opacity: 0.3, backgroundPosition: '0 0' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Ozean Theme
|
||||
const oceanTheme = createTheme({
|
||||
...baseTheme,
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: {
|
||||
main: '#2196F3', // Blau
|
||||
light: '#64B5F6',
|
||||
dark: '#1976D2',
|
||||
contrastText: '#FFFFFF',
|
||||
},
|
||||
secondary: {
|
||||
main: '#00BCD4', // Cyan
|
||||
light: '#4DD0E1',
|
||||
dark: '#0097A7',
|
||||
contrastText: '#FFFFFF',
|
||||
},
|
||||
success: {
|
||||
main: '#009688', // Teal
|
||||
light: '#4DB6AC',
|
||||
dark: '#00695C',
|
||||
},
|
||||
warning: {
|
||||
main: '#FF9800',
|
||||
light: '#FFB74D',
|
||||
dark: '#F57C00',
|
||||
},
|
||||
error: {
|
||||
main: '#F44336',
|
||||
light: '#EF5350',
|
||||
dark: '#D32F2F',
|
||||
},
|
||||
background: {
|
||||
default: '#E3F2FD', // Helles Blau
|
||||
paper: '#FFFFFF',
|
||||
},
|
||||
text: {
|
||||
primary: '#0D47A1',
|
||||
secondary: '#1565C0',
|
||||
},
|
||||
},
|
||||
themeIcon: '🌊',
|
||||
components: {
|
||||
MuiCssBaseline: {
|
||||
styleOverrides: {
|
||||
body: {
|
||||
backgroundImage: 'linear-gradient(180deg, #87CEEB 0%, #4682B4 30%, #1E90FF 60%, #0066CC 100%)',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'100%\' height=\'100%\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cdefs%3E%3Cpath id=\'fish\' d=\'M0,10 Q5,5 10,10 Q15,15 20,10 L15,8 L20,10 L15,12 Z\' /%3E%3Cpath id=\'wave\' d=\'M0,20 Q25,10 50,20 Q75,30 100,20 Q125,10 150,20 Q175,30 200,20\' /%3E%3C/defs%3E%3Cg fill=\'%23FFD700\' fill-opacity=\'0.3\'%3E%3Cuse href=\'%23fish\' x=\'50\' y=\'30\' /%3E%3Cuse href=\'%23fish\' x=\'150\' y=\'80\' transform=\'scale(-1,1) translate(-170,0)\' /%3E%3Cuse href=\'%23fish\' x=\'80\' y=\'120\' /%3E%3Cuse href=\'%23fish\' x=\'200\' y=\'60\' transform=\'scale(-1,1) translate(-220,0)\' /%3E%3C/g%3E%3Cg fill=\'%23FF6347\' fill-opacity=\'0.3\'%3E%3Cuse href=\'%23fish\' x=\'120\' y=\'40\' transform=\'scale(-1,1) translate(-140,0)\' /%3E%3Cuse href=\'%23fish\' x=\'30\' y=\'90\' /%3E%3Cuse href=\'%23fish\' x=\'180\' y=\'110\' transform=\'scale(-1,1) translate(-200,0)\' /%3E%3C/g%3E%3Cg stroke=\'%23FFFFFF\' stroke-width=\'2\' fill=\'none\' stroke-opacity=\'0.2\'%3E%3Cuse href=\'%23wave\' y=\'0\' /%3E%3Cuse href=\'%23wave\' y=\'40\' /%3E%3Cuse href=\'%23wave\' y=\'80\' /%3E%3Cuse href=\'%23wave\' y=\'120\' /%3E%3C/g%3E%3Cg fill=\'%23FFFFFF\' fill-opacity=\'0.1\'%3E%3Ccircle cx=\'40\' cy=\'25\' r=\'2\' /%3E%3Ccircle cx=\'90\' cy=\'45\' r=\'1.5\' /%3E%3Ccircle cx=\'160\' cy=\'35\' r=\'2.5\' /%3E%3Ccircle cx=\'70\' cy=\'75\' r=\'1\' /%3E%3Ccircle cx=\'130\' cy=\'95\' r=\'2\' /%3E%3Ccircle cx=\'190\' cy=\'85\' r=\'1.5\' /%3E%3C/g%3E%3C/svg%3E")',
|
||||
backgroundSize: '250px 150px',
|
||||
opacity: 0.8,
|
||||
zIndex: -1,
|
||||
pointerEvents: 'none',
|
||||
animation: 'ocean-flow 20s linear infinite',
|
||||
},
|
||||
'@keyframes ocean-flow': {
|
||||
'0%': { backgroundPosition: '0 0' },
|
||||
'100%': { backgroundPosition: '250px 0' },
|
||||
},
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '40%',
|
||||
background: 'url("data:image/svg+xml,%3Csvg width=\'100%\' height=\'100%\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cpath d=\'M0,50 Q50,30 100,50 Q150,70 200,50 Q250,30 300,50 Q350,70 400,50 L400,100 L0,100 Z\' fill=\'%23006994\' fill-opacity=\'0.3\' /%3E%3Cpath d=\'M0,60 Q50,40 100,60 Q150,80 200,60 Q250,40 300,60 Q350,80 400,60 L400,100 L0,100 Z\' fill=\'%23004d6b\' fill-opacity=\'0.2\' /%3E%3C/svg%3E")',
|
||||
backgroundSize: '400px 100px',
|
||||
zIndex: -1,
|
||||
pointerEvents: 'none',
|
||||
animation: 'wave-bottom 12s ease-in-out infinite',
|
||||
},
|
||||
'@keyframes wave-bottom': {
|
||||
'0%': { backgroundPosition: '0 0' },
|
||||
'50%': { backgroundPosition: '200px 0' },
|
||||
'100%': { backgroundPosition: '0 0' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
background: 'linear-gradient(135deg, rgba(135, 206, 235, 0.8) 0%, rgba(70, 130, 180, 0.9) 100%)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
border: '2px solid rgba(255, 255, 255, 0.3)',
|
||||
borderRadius: 20,
|
||||
boxShadow: '0 8px 32px rgba(0, 102, 204, 0.3), inset 0 2px 4px rgba(255, 255, 255, 0.2)',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'100\' height=\'100\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cg fill=\'%23FFFFFF\' fill-opacity=\'0.1\'%3E%3Ccircle cx=\'20\' cy=\'20\' r=\'3\' /%3E%3Ccircle cx=\'60\' cy=\'40\' r=\'2\' /%3E%3Ccircle cx=\'80\' cy=\'15\' r=\'2.5\' /%3E%3Ccircle cx=\'40\' cy=\'70\' r=\'1.5\' /%3E%3Ccircle cx=\'70\' cy=\'80\' r=\'2\' /%3E%3C/g%3E%3C/svg%3E")',
|
||||
backgroundSize: '100px 100px',
|
||||
opacity: 0.6,
|
||||
zIndex: 0,
|
||||
pointerEvents: 'none',
|
||||
animation: 'bubble-float 8s ease-in-out infinite',
|
||||
},
|
||||
'@keyframes bubble-float': {
|
||||
'0%': { backgroundPosition: '0 0' },
|
||||
'50%': { backgroundPosition: '50px -20px' },
|
||||
'100%': { backgroundPosition: '0 0' },
|
||||
},
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 8,
|
||||
background: 'url("data:image/svg+xml,%3Csvg width=\'100%\' height=\'8\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cpath d=\'M0,4 Q25,0 50,4 Q75,8 100,4 Q125,0 150,4 Q175,8 200,4\' stroke=\'%23FFFFFF\' stroke-width=\'2\' fill=\'none\' stroke-opacity=\'0.4\' /%3E%3C/svg%3E")',
|
||||
backgroundSize: '200px 8px',
|
||||
borderRadius: '0 0 20px 20px',
|
||||
animation: 'water-ripple 4s ease-in-out infinite',
|
||||
},
|
||||
'@keyframes water-ripple': {
|
||||
'0%': { backgroundPosition: '0 0', opacity: 0.4 },
|
||||
'50%': { backgroundPosition: '100px 0', opacity: 0.7 },
|
||||
'100%': { backgroundPosition: '0 0', opacity: 0.4 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
background: 'linear-gradient(45deg, #1E90FF 0%, #00CED1 100%)',
|
||||
border: '2px solid rgba(255, 255, 255, 0.3)',
|
||||
boxShadow: '0 4px 15px rgba(30, 144, 255, 0.4), inset 0 2px 4px rgba(255, 255, 255, 0.2)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'100\' height=\'100\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cg fill=\'%23FFFFFF\' fill-opacity=\'0.2\'%3E%3Ccircle cx=\'20\' cy=\'30\' r=\'2\' /%3E%3Ccircle cx=\'60\' cy=\'20\' r=\'1.5\' /%3E%3Ccircle cx=\'80\' cy=\'40\' r=\'1\' /%3E%3C/g%3E%3C/svg%3E")',
|
||||
backgroundSize: '100px 100px',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.3s ease',
|
||||
animation: 'bubble-rise 6s ease-in-out infinite',
|
||||
},
|
||||
'@keyframes bubble-rise': {
|
||||
'0%': { backgroundPosition: '0 100px' },
|
||||
'100%': { backgroundPosition: '0 -100px' },
|
||||
},
|
||||
'&:hover::before': {
|
||||
opacity: 1,
|
||||
},
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: '-100%',
|
||||
width: '200%',
|
||||
height: '100%',
|
||||
background: 'linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.3) 50%, rgba(255,255,255,0) 100%)',
|
||||
transform: 'skewX(-20deg)',
|
||||
transition: 'all 0.8s ease',
|
||||
},
|
||||
'&:hover::after': {
|
||||
left: '100%',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
background: 'linear-gradient(135deg, rgba(176, 224, 230, 0.85) 0%, rgba(135, 206, 235, 0.9) 100%)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '2px solid rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: 15,
|
||||
boxShadow: '0 6px 20px rgba(0, 102, 204, 0.2), inset 0 1px 3px rgba(255, 255, 255, 0.3)',
|
||||
position: 'relative',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'50\' height=\'50\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cg fill=\'%23FFFFFF\' fill-opacity=\'0.08\'%3E%3Ccircle cx=\'10\' cy=\'10\' r=\'1\' /%3E%3Ccircle cx=\'30\' cy=\'20\' r=\'1.5\' /%3E%3Ccircle cx=\'40\' cy=\'35\' r=\'1\' /%3E%3Ccircle cx=\'15\' cy=\'40\' r=\'0.8\' /%3E%3C/g%3E%3C/svg%3E")',
|
||||
backgroundSize: '50px 50px',
|
||||
opacity: 0.7,
|
||||
zIndex: 0,
|
||||
pointerEvents: 'none',
|
||||
animation: 'gentle-float 10s ease-in-out infinite',
|
||||
},
|
||||
'@keyframes gentle-float': {
|
||||
'0%': { backgroundPosition: '0 0' },
|
||||
'50%': { backgroundPosition: '25px -10px' },
|
||||
'100%': { backgroundPosition: '0 0' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Wald Theme
|
||||
const forestTheme = createTheme({
|
||||
...baseTheme,
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: {
|
||||
main: '#2E7D32', // Waldgrün
|
||||
light: '#4CAF50',
|
||||
dark: '#1B5E20',
|
||||
contrastText: '#FFFFFF',
|
||||
},
|
||||
secondary: {
|
||||
main: '#8D6E63', // Baumrinde Braun
|
||||
light: '#A1887F',
|
||||
dark: '#5D4037',
|
||||
contrastText: '#FFFFFF',
|
||||
},
|
||||
success: {
|
||||
main: '#66BB6A',
|
||||
light: '#81C784',
|
||||
dark: '#4CAF50',
|
||||
},
|
||||
warning: {
|
||||
main: '#FF8F00', // Herbstfarbe
|
||||
light: '#FFA726',
|
||||
dark: '#E65100',
|
||||
},
|
||||
error: {
|
||||
main: '#D32F2F',
|
||||
light: '#EF5350',
|
||||
dark: '#C62828',
|
||||
},
|
||||
background: {
|
||||
default: '#E8F5E8', // Sanftes Waldgrün
|
||||
paper: '#F1F8E9',
|
||||
},
|
||||
text: {
|
||||
primary: '#1B5E20',
|
||||
secondary: '#2E7D32',
|
||||
},
|
||||
},
|
||||
themeIcon: '🌲',
|
||||
components: {
|
||||
MuiCssBaseline: {
|
||||
styleOverrides: {
|
||||
body: {
|
||||
background: 'linear-gradient(180deg, #87CEEB 0%, #98FB98 20%, #228B22 40%, #006400 100%)',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'100%\' height=\'100%\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cdefs%3E%3Cg id=\'tree1\'%3E%3Crect x=\'45\' y=\'60\' width=\'10\' height=\'25\' fill=\'%238D6E63\' /%3E%3Cellipse cx=\'50\' cy=\'45\' rx=\'20\' ry=\'25\' fill=\'%232E7D32\' /%3E%3Cellipse cx=\'50\' cy=\'35\' rx=\'15\' ry=\'20\' fill=\'%234CAF50\' /%3E%3C/g%3E%3Cg id=\'tree2\'%3E%3Crect x=\'47\' y=\'65\' width=\'6\' height=\'20\' fill=\'%235D4037\' /%3E%3Cpath d=\'M50,20 L35,55 L65,55 Z\' fill=\'%231B5E20\' /%3E%3Cpath d=\'M50,30 L40,50 L60,50 Z\' fill=\'%232E7D32\' /%3E%3C/g%3E%3Cg id=\'bush\'%3E%3Cellipse cx=\'25\' cy=\'75\' rx=\'12\' ry=\'8\' fill=\'%234CAF50\' /%3E%3Cellipse cx=\'30\' cy=\'70\' rx=\'8\' ry=\'6\' fill=\'%2366BB6A\' /%3E%3C/g%3E%3Cg id=\'rabbit\'%3E%3Cellipse cx=\'80\' cy=\'78\' rx=\'4\' ry=\'3\' fill=\'%23D7CCC8\' /%3E%3Cellipse cx=\'78\' cy=\'75\' rx=\'2\' ry=\'3\' fill=\'%23BCAAA4\' /%3E%3Cellipse cx=\'77\' cy=\'72\' rx=\'1\' ry=\'2\' fill=\'%23BCAAA4\' /%3E%3Cellipse cx=\'79\' cy=\'72\' rx=\'1\' ry=\'2\' fill=\'%23BCAAA4\' /%3E%3C/g%3E%3Cg id=\'bird\'%3E%3Cellipse cx=\'15\' cy=\'25\' rx=\'3\' ry=\'2\' fill=\'%23FF5722\' /%3E%3Cpath d=\'M12,25 L8,23 L8,27 Z\' fill=\'%23FF8A65\' /%3E%3C/g%3E%3C/defs%3E%3Cuse href=\'%23tree1\' x=\'0\' y=\'0\' /%3E%3Cuse href=\'%23tree2\' x=\'120\' y=\'10\' /%3E%3Cuse href=\'%23tree1\' x=\'200\' y=\'-5\' /%3E%3Cuse href=\'%23tree2\' x=\'300\' y=\'5\' /%3E%3Cuse href=\'%23bush\' x=\'50\' y=\'0\' /%3E%3Cuse href=\'%23bush\' x=\'150\' y=\'10\' /%3E%3Cuse href=\'%23rabbit\' x=\'0\' y=\'0\' /%3E%3Cuse href=\'%23bird\' x=\'100\' y=\'0\' /%3E%3Cuse href=\'%23tree1\' x=\'0\' y=\'100\' /%3E%3Cuse href=\'%23tree2\' x=\'80\' y=\'120\' /%3E%3Cuse href=\'%23tree1\' x=\'180\' y=\'110\' /%3E%3Cuse href=\'%23bush\' x=\'250\' y=\'120\' /%3E%3Cuse href=\'%23rabbit\' x=\'200\' y=\'100\' /%3E%3C/svg%3E")',
|
||||
backgroundSize: '400px 300px',
|
||||
opacity: 0.4,
|
||||
zIndex: -2,
|
||||
pointerEvents: 'none',
|
||||
animation: 'forest-sway 15s ease-in-out infinite',
|
||||
},
|
||||
'@keyframes forest-sway': {
|
||||
'0%': { transform: 'translateX(0px)' },
|
||||
'25%': { transform: 'translateX(-2px)' },
|
||||
'50%': { transform: 'translateX(0px)' },
|
||||
'75%': { transform: 'translateX(2px)' },
|
||||
'100%': { transform: 'translateX(0px)' },
|
||||
},
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '30%',
|
||||
background: 'linear-gradient(to top, rgba(46, 125, 50, 0.3) 0%, rgba(46, 125, 50, 0.1) 50%, rgba(46, 125, 50, 0) 100%)',
|
||||
zIndex: -1,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
background: 'linear-gradient(135deg, rgba(241, 248, 233, 0.95) 0%, rgba(232, 245, 232, 0.95) 100%)',
|
||||
backdropFilter: 'blur(6px)',
|
||||
border: '3px solid rgba(141, 110, 99, 0.4)',
|
||||
borderRadius: 16,
|
||||
boxShadow: '0 8px 25px rgba(46, 125, 50, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.3)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'60\' height=\'60\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cg%3E%3Cpath d=\'M5,30 Q15,25 25,30 Q35,35 45,30 Q55,25 65,30\' stroke=\'%238D6E63\' stroke-width=\'0.5\' fill=\'none\' opacity=\'0.3\' /%3E%3Cpath d=\'M0,20 Q10,15 20,20 Q30,25 40,20 Q50,15 60,20\' stroke=\'%232E7D32\' stroke-width=\'0.5\' fill=\'none\' opacity=\'0.2\' /%3E%3Ccircle cx=\'15\' cy=\'45\' r=\'1\' fill=\'%234CAF50\' opacity=\'0.4\' /%3E%3Ccircle cx=\'35\' cy=\'15\' r=\'0.5\' fill=\'%2366BB6A\' opacity=\'0.3\' /%3E%3Ccircle cx=\'50\' cy=\'40\' r=\'0.8\' fill=\'%238BC34A\' opacity=\'0.3\' /%3E%3C/g%3E%3C/svg%3E")',
|
||||
backgroundSize: '60px 60px',
|
||||
opacity: 0.6,
|
||||
zIndex: 0,
|
||||
pointerEvents: 'none',
|
||||
animation: 'wood-grain 20s ease-in-out infinite',
|
||||
},
|
||||
'@keyframes wood-grain': {
|
||||
'0%': { opacity: 0.4, backgroundPosition: '0 0' },
|
||||
'50%': { opacity: 0.8, backgroundPosition: '30px 30px' },
|
||||
'100%': { opacity: 0.4, backgroundPosition: '0 0' },
|
||||
},
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
bottom: -3,
|
||||
left: '10%',
|
||||
width: '80%',
|
||||
height: 6,
|
||||
background: 'linear-gradient(90deg, #8D6E63 0%, #4CAF50 50%, #2E7D32 100%)',
|
||||
borderRadius: 8,
|
||||
opacity: 0.7,
|
||||
boxShadow: '0 2px 8px rgba(46, 125, 50, 0.3)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
background: 'linear-gradient(135deg, #8D6E63 0%, #A1887F 50%, #4CAF50 100%)',
|
||||
border: '2px solid rgba(93, 64, 55, 0.6)',
|
||||
borderRadius: 12,
|
||||
boxShadow: '0 4px 15px rgba(46, 125, 50, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.2)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'40\' height=\'40\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cg%3E%3Cpath d=\'M5,20 Q15,15 25,20 Q35,25 45,20\' stroke=\'%235D4037\' stroke-width=\'0.8\' fill=\'none\' opacity=\'0.4\' /%3E%3Cpath d=\'M0,30 Q10,25 20,30 Q30,35 40,30\' stroke=\'%235D4037\' stroke-width=\'0.6\' fill=\'none\' opacity=\'0.3\' /%3E%3Ccircle cx=\'10\' cy=\'10\' r=\'0.5\' fill=\'%232E7D32\' opacity=\'0.5\' /%3E%3Ccircle cx=\'30\' cy=\'35\' r=\'0.8\' fill=\'%234CAF50\' opacity=\'0.4\' /%3E%3C/g%3E%3C/svg%3E")',
|
||||
backgroundSize: '40px 40px',
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.3s ease',
|
||||
zIndex: 0,
|
||||
},
|
||||
'&:hover::before': {
|
||||
opacity: 1,
|
||||
},
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
background: 'radial-gradient(circle, rgba(93, 64, 55, 0.8) 0%, rgba(93, 64, 55, 0.4) 70%, transparent 100%)',
|
||||
borderRadius: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
opacity: 0.6,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
background: 'linear-gradient(135deg, rgba(241, 248, 233, 0.9) 0%, rgba(232, 245, 232, 0.9) 100%)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
border: '2px solid rgba(141, 110, 99, 0.3)',
|
||||
borderRadius: 12,
|
||||
boxShadow: '0 6px 20px rgba(46, 125, 50, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.2)',
|
||||
position: 'relative',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'50\' height=\'50\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cg%3E%3Cpath d=\'M10,25 Q20,20 30,25 Q40,30 50,25\' stroke=\'%238D6E63\' stroke-width=\'0.3\' fill=\'none\' opacity=\'0.2\' /%3E%3Cpath d=\'M5,35 Q15,30 25,35 Q35,40 45,35\' stroke=\'%232E7D32\' stroke-width=\'0.3\' fill=\'none\' opacity=\'0.15\' /%3E%3Ccircle cx=\'12\' cy=\'15\' r=\'0.5\' fill=\'%234CAF50\' opacity=\'0.3\' /%3E%3Ccircle cx=\'38\' cy=\'40\' r=\'0.3\' fill=\'%2366BB6A\' opacity=\'0.25\' /%3E%3C/g%3E%3C/svg%3E")',
|
||||
backgroundSize: '50px 50px',
|
||||
opacity: 0.4,
|
||||
zIndex: 0,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Theme-Mapping
|
||||
export const childThemes = {
|
||||
colorful: colorfulTheme,
|
||||
nature: natureTheme,
|
||||
space: spaceTheme,
|
||||
ocean: oceanTheme,
|
||||
forest: forestTheme,
|
||||
};
|
||||
|
||||
// Funktion zum Abrufen des Themes basierend auf Kinder-Preferences
|
||||
export const getChildTheme = (childPreferences) => {
|
||||
const themeName = childPreferences?.theme || 'colorful';
|
||||
return childThemes[themeName] || childThemes.colorful;
|
||||
};
|
||||
|
||||
export default childThemes;
|
||||
112
middleware/auth.js
Normal file
112
middleware/auth.js
Normal file
@ -0,0 +1,112 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const User = require('../models/User');
|
||||
|
||||
// Authentifizierungs-Middleware
|
||||
const auth = async (req, res, next) => {
|
||||
try {
|
||||
// Token aus Header extrahieren
|
||||
const token = req.header('Authorization')?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Kein Token gefunden. Zugriff verweigert.'
|
||||
});
|
||||
}
|
||||
|
||||
// Token verifizieren
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
// Benutzer aus Datenbank abrufen
|
||||
const user = await User.findById(decoded.user.id).select('-password');
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Token ungültig. Benutzer nicht gefunden oder deaktiviert.'
|
||||
});
|
||||
}
|
||||
|
||||
// Benutzer zu Request hinzufügen
|
||||
req.user = user;
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Auth-Middleware-Fehler:', error.message);
|
||||
|
||||
if (error.name === 'JsonWebTokenError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Token ungültig.'
|
||||
});
|
||||
}
|
||||
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Token abgelaufen. Bitte melde dich erneut an.'
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler bei der Authentifizierung'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Admin-Middleware (für zukünftige Erweiterungen)
|
||||
const adminAuth = async (req, res, next) => {
|
||||
try {
|
||||
// Erst normale Authentifizierung
|
||||
await auth(req, res, () => {});
|
||||
|
||||
// Dann Admin-Berechtigung prüfen
|
||||
if (req.user.role !== 'admin') {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Zugriff verweigert. Admin-Berechtigung erforderlich.'
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Admin-Auth-Middleware-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler bei der Admin-Authentifizierung'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Optionale Authentifizierung (für öffentliche Endpunkte mit optionalen Benutzerdaten)
|
||||
const optionalAuth = async (req, res, next) => {
|
||||
try {
|
||||
const token = req.header('Authorization')?.replace('Bearer ', '');
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const user = await User.findById(decoded.user.id).select('-password');
|
||||
|
||||
if (user && user.isActive) {
|
||||
req.user = user;
|
||||
}
|
||||
} catch (error) {
|
||||
// Token ungültig, aber das ist okay für optionale Auth
|
||||
console.log('Optionale Auth: Token ungültig, fortfahren ohne Benutzer');
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Optional-Auth-Middleware-Fehler:', error.message);
|
||||
// Bei optionaler Auth trotzdem fortfahren
|
||||
next();
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
auth,
|
||||
adminAuth,
|
||||
optionalAuth
|
||||
};
|
||||
85
middleware/childAuth.js
Normal file
85
middleware/childAuth.js
Normal file
@ -0,0 +1,85 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const Child = require('../models/Child');
|
||||
|
||||
// Middleware für Kinder-Authentifizierung
|
||||
const childAuth = async (req, res, next) => {
|
||||
// Token aus Header oder Body holen
|
||||
const token = req.header('x-child-auth-token') ||
|
||||
req.header('x-auth-token') ||
|
||||
req.body.token;
|
||||
|
||||
// Prüfen ob Token vorhanden
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Kein Zugangs-Token, Zugriff verweigert'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Token verifizieren
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
// Prüfen ob es ein Kind-Token ist
|
||||
if (!decoded.child) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Ungültiger Token-Typ'
|
||||
});
|
||||
}
|
||||
|
||||
// Kind aus Datenbank laden
|
||||
const child = await Child.findById(decoded.child.id);
|
||||
|
||||
if (!child || !child.isActive) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Kind nicht gefunden oder deaktiviert'
|
||||
});
|
||||
}
|
||||
|
||||
// Kind-Daten zu Request hinzufügen
|
||||
req.child = child;
|
||||
req.childId = child._id;
|
||||
req.parentId = child.parent;
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Kinder-Auth-Middleware-Fehler:', error.message);
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: 'Token ist nicht gültig'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Middleware für optionale Kinder-Authentifizierung
|
||||
const optionalChildAuth = async (req, res, next) => {
|
||||
const token = req.header('x-child-auth-token') ||
|
||||
req.header('x-auth-token') ||
|
||||
req.body.token;
|
||||
|
||||
if (!token) {
|
||||
return next();
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
if (decoded.child) {
|
||||
const child = await Child.findById(decoded.child.id);
|
||||
if (child && child.isActive) {
|
||||
req.child = child;
|
||||
req.childId = child._id;
|
||||
req.parentId = child.parent;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Bei optionaler Auth ignorieren wir Fehler
|
||||
console.log('Optionale Kinder-Auth fehlgeschlagen:', error.message);
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = { childAuth, optionalChildAuth };
|
||||
275
models/Child.js
Normal file
275
models/Child.js
Normal file
@ -0,0 +1,275 @@
|
||||
const mongoose = require('mongoose');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
// Schema für Kinder-Profile
|
||||
const childSchema = new mongoose.Schema({
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
maxlength: 50
|
||||
},
|
||||
age: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 3,
|
||||
max: 18
|
||||
},
|
||||
avatar: {
|
||||
type: String,
|
||||
default: 'default-child.png',
|
||||
enum: [
|
||||
'default-child.png',
|
||||
'superhero-boy.png',
|
||||
'superhero-girl.png',
|
||||
'pirate-boy.png',
|
||||
'pirate-girl.png',
|
||||
'princess.png',
|
||||
'knight.png',
|
||||
'astronaut.png',
|
||||
'detective.png',
|
||||
'artist.png'
|
||||
]
|
||||
},
|
||||
parent: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true
|
||||
},
|
||||
// Authentifizierung für Kinder
|
||||
username: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
trim: true,
|
||||
minlength: 3,
|
||||
maxlength: 20,
|
||||
match: /^[a-zA-Z0-9_]+$/
|
||||
},
|
||||
pin: {
|
||||
type: String,
|
||||
required: true,
|
||||
minlength: 4,
|
||||
maxlength: 6,
|
||||
match: /^[0-9]+$/
|
||||
},
|
||||
// Für sichere PIN-Speicherung (gehashed)
|
||||
hashedPin: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
points: {
|
||||
total: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
min: 0
|
||||
},
|
||||
available: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
min: 0
|
||||
},
|
||||
spent: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
min: 0
|
||||
}
|
||||
},
|
||||
level: {
|
||||
current: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
min: 1
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Nachwuchs-Held'
|
||||
}
|
||||
},
|
||||
achievements: [{
|
||||
name: String,
|
||||
description: String,
|
||||
icon: String,
|
||||
earnedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
}],
|
||||
preferences: {
|
||||
theme: {
|
||||
type: String,
|
||||
enum: ['colorful', 'nature', 'space', 'ocean', 'forest'],
|
||||
default: 'colorful'
|
||||
},
|
||||
favoriteColor: {
|
||||
type: String,
|
||||
default: '#4CAF50'
|
||||
},
|
||||
soundEffects: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
animations: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
stats: {
|
||||
tasksCompleted: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
tasksCompletedToday: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
streak: {
|
||||
current: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
longest: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
lastCompletionDate: Date
|
||||
}
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
lastActivity: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
});
|
||||
|
||||
// Indizes für bessere Performance
|
||||
childSchema.index({ parent: 1, isActive: 1 });
|
||||
childSchema.index({ 'points.total': -1 });
|
||||
childSchema.index({ username: 1 }, { unique: true });
|
||||
|
||||
// Methode zum Punkte hinzufügen
|
||||
childSchema.methods.addPoints = function(points) {
|
||||
this.points.total += points;
|
||||
this.points.available += points;
|
||||
this.lastActivity = new Date();
|
||||
|
||||
// Level-System aktualisieren
|
||||
this.updateLevel();
|
||||
|
||||
return this.save();
|
||||
};
|
||||
|
||||
// PIN-Hashing vor dem Speichern
|
||||
childSchema.pre('save', async function(next) {
|
||||
// Nur hashen wenn PIN geändert wurde
|
||||
if (!this.isModified('pin')) return next();
|
||||
|
||||
try {
|
||||
// PIN hashen
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
this.hashedPin = await bcrypt.hash(this.pin, salt);
|
||||
|
||||
// Klartext-PIN entfernen (nicht in DB speichern)
|
||||
this.pin = undefined;
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// PIN-Verifikation
|
||||
childSchema.methods.verifyPin = async function(pin) {
|
||||
return await bcrypt.compare(pin, this.hashedPin);
|
||||
};
|
||||
|
||||
// Sichere Daten für Frontend (ohne hashedPin)
|
||||
childSchema.methods.toSafeObject = function() {
|
||||
const obj = this.toObject();
|
||||
delete obj.hashedPin;
|
||||
delete obj.pin;
|
||||
return obj;
|
||||
};
|
||||
|
||||
// Methode zum Punkte ausgeben
|
||||
childSchema.methods.spendPoints = function(points) {
|
||||
if (this.points.available >= points) {
|
||||
this.points.available -= points;
|
||||
this.points.spent += points;
|
||||
this.lastActivity = new Date();
|
||||
return this.save();
|
||||
}
|
||||
throw new Error('Nicht genügend Punkte verfügbar');
|
||||
};
|
||||
|
||||
// Level-System aktualisieren
|
||||
childSchema.methods.updateLevel = function() {
|
||||
const totalPoints = this.points.total;
|
||||
const newLevel = Math.floor(totalPoints / 100) + 1; // Alle 100 Punkte ein Level
|
||||
|
||||
if (newLevel > this.level.current) {
|
||||
this.level.current = newLevel;
|
||||
this.level.title = this.getLevelTitle(newLevel);
|
||||
}
|
||||
};
|
||||
|
||||
// Level-Titel bestimmen
|
||||
childSchema.methods.getLevelTitle = function(level) {
|
||||
const titles = {
|
||||
1: 'Nachwuchs-Held',
|
||||
2: 'Aufgaben-Entdecker',
|
||||
3: 'Fleißiger Helfer',
|
||||
4: 'Verantwortungs-Champion',
|
||||
5: 'Familien-Superheld',
|
||||
6: 'Aufgaben-Meister',
|
||||
7: 'Pflichten-Profi',
|
||||
8: 'Haushalts-Held',
|
||||
9: 'Verantwortungs-Legende',
|
||||
10: 'Familien-Champion'
|
||||
};
|
||||
|
||||
return titles[level] || `Super-Held Level ${level}`;
|
||||
};
|
||||
|
||||
// Streak aktualisieren
|
||||
childSchema.methods.updateStreak = function() {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const lastCompletion = this.stats.streak.lastCompletionDate;
|
||||
|
||||
if (!lastCompletion) {
|
||||
// Erste Aufgabe
|
||||
this.stats.streak.current = 1;
|
||||
this.stats.streak.lastCompletionDate = today;
|
||||
} else {
|
||||
const lastDate = new Date(lastCompletion);
|
||||
lastDate.setHours(0, 0, 0, 0);
|
||||
|
||||
const daysDiff = (today - lastDate) / (1000 * 60 * 60 * 24);
|
||||
|
||||
if (daysDiff === 1) {
|
||||
// Aufeinanderfolgende Tage
|
||||
this.stats.streak.current += 1;
|
||||
this.stats.streak.lastCompletionDate = today;
|
||||
} else if (daysDiff > 1) {
|
||||
// Streak unterbrochen
|
||||
this.stats.streak.current = 1;
|
||||
this.stats.streak.lastCompletionDate = today;
|
||||
}
|
||||
// daysDiff === 0 bedeutet heute schon eine Aufgabe erledigt
|
||||
}
|
||||
|
||||
// Längste Streak aktualisieren
|
||||
if (this.stats.streak.current > this.stats.streak.longest) {
|
||||
this.stats.streak.longest = this.stats.streak.current;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = mongoose.model('Child', childSchema);
|
||||
359
models/Reward.js
Normal file
359
models/Reward.js
Normal file
@ -0,0 +1,359 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
// Schema für Belohnungen
|
||||
const rewardSchema = new mongoose.Schema({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
maxlength: 100
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
trim: true,
|
||||
maxlength: 500
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: 'gift',
|
||||
enum: [
|
||||
'gift', 'tv', 'game', 'movie', 'ice-cream', 'toy', 'book',
|
||||
'outing', 'money', 'treat', 'activity', 'privilege', 'special',
|
||||
'electronics', 'sports', 'art', 'music', 'food', 'other'
|
||||
]
|
||||
},
|
||||
cost: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 1,
|
||||
max: 10000
|
||||
},
|
||||
category: {
|
||||
type: String,
|
||||
enum: [
|
||||
'screen-time', 'treats', 'toys', 'activities', 'outings',
|
||||
'privileges', 'money', 'special', 'electronics', 'books',
|
||||
'sports', 'art', 'other'
|
||||
],
|
||||
default: 'treats'
|
||||
},
|
||||
createdBy: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true
|
||||
},
|
||||
availableFor: [{
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Child'
|
||||
}], // Leer = für alle Kinder verfügbar
|
||||
ageRestriction: {
|
||||
minAge: {
|
||||
type: Number,
|
||||
min: 3,
|
||||
max: 18
|
||||
},
|
||||
maxAge: {
|
||||
type: Number,
|
||||
min: 3,
|
||||
max: 18
|
||||
}
|
||||
},
|
||||
availability: {
|
||||
isLimited: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
totalQuantity: {
|
||||
type: Number,
|
||||
min: 1,
|
||||
required: function() { return this.availability.isLimited; }
|
||||
},
|
||||
remainingQuantity: {
|
||||
type: Number,
|
||||
min: 0,
|
||||
required: function() { return this.availability.isLimited; }
|
||||
},
|
||||
resetPeriod: {
|
||||
type: String,
|
||||
enum: ['daily', 'weekly', 'monthly', 'never'],
|
||||
default: 'never'
|
||||
},
|
||||
lastReset: Date
|
||||
},
|
||||
timeRestrictions: {
|
||||
validFrom: Date,
|
||||
validUntil: Date,
|
||||
daysOfWeek: [{
|
||||
type: Number,
|
||||
min: 0,
|
||||
max: 6 // 0 = Sonntag, 6 = Samstag
|
||||
}],
|
||||
timeOfDay: {
|
||||
startTime: String, // Format: "HH:MM"
|
||||
endTime: String // Format: "HH:MM"
|
||||
}
|
||||
},
|
||||
requiresApproval: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
isInstant: {
|
||||
type: Boolean,
|
||||
default: false // true = sofort verfügbar, false = muss genehmigt werden
|
||||
},
|
||||
tags: [{
|
||||
type: String,
|
||||
trim: true,
|
||||
maxlength: 20
|
||||
}],
|
||||
image: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
instructions: {
|
||||
type: String,
|
||||
trim: true,
|
||||
maxlength: 1000
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
isPopular: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
sortOrder: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
stats: {
|
||||
timesRedeemed: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
totalPointsSpent: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
lastRedeemed: Date
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
});
|
||||
|
||||
// Schema für Belohnungs-Anfragen
|
||||
const rewardRequestSchema = new mongoose.Schema({
|
||||
reward: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Reward',
|
||||
required: true
|
||||
},
|
||||
child: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Child',
|
||||
required: true
|
||||
},
|
||||
pointsSpent: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 1
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['pending', 'approved', 'rejected', 'redeemed'],
|
||||
default: 'pending'
|
||||
},
|
||||
requestedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
processedAt: Date,
|
||||
processedBy: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User'
|
||||
},
|
||||
redeemedAt: Date,
|
||||
rejectionReason: {
|
||||
type: String,
|
||||
trim: true,
|
||||
maxlength: 200
|
||||
},
|
||||
notes: {
|
||||
type: String,
|
||||
trim: true,
|
||||
maxlength: 500
|
||||
}
|
||||
});
|
||||
|
||||
// Indizes für bessere Performance
|
||||
rewardSchema.index({ createdBy: 1, isActive: 1 });
|
||||
rewardSchema.index({ cost: 1, isActive: 1 });
|
||||
rewardSchema.index({ category: 1, isActive: 1 });
|
||||
rewardSchema.index({ isPopular: -1, sortOrder: 1 });
|
||||
|
||||
rewardRequestSchema.index({ child: 1, status: 1, requestedAt: -1 });
|
||||
rewardRequestSchema.index({ status: 1, requestedAt: -1 });
|
||||
|
||||
// Middleware: updatedAt automatisch setzen
|
||||
rewardSchema.pre('save', function(next) {
|
||||
this.updatedAt = new Date();
|
||||
next();
|
||||
});
|
||||
|
||||
// Methode: Verfügbarkeit prüfen
|
||||
rewardSchema.methods.isAvailableFor = function(child) {
|
||||
// Altersrestriktion prüfen
|
||||
if (this.ageRestriction.minAge && child.age < this.ageRestriction.minAge) {
|
||||
return false;
|
||||
}
|
||||
if (this.ageRestriction.maxAge && child.age > this.ageRestriction.maxAge) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verfügbarkeit für spezifische Kinder prüfen
|
||||
if (this.availableFor.length > 0 && !this.availableFor.includes(child._id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Mengenbegrenzung prüfen
|
||||
if (this.availability.isLimited && this.availability.remainingQuantity <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Zeitrestriktionen prüfen
|
||||
const now = new Date();
|
||||
if (this.timeRestrictions.validFrom && now < this.timeRestrictions.validFrom) {
|
||||
return false;
|
||||
}
|
||||
if (this.timeRestrictions.validUntil && now > this.timeRestrictions.validUntil) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.isActive;
|
||||
};
|
||||
|
||||
// Methode: Belohnung einlösen
|
||||
rewardSchema.methods.redeem = async function(childId) {
|
||||
const Child = mongoose.model('Child');
|
||||
const child = await Child.findById(childId);
|
||||
|
||||
if (!child) {
|
||||
throw new Error('Kind nicht gefunden');
|
||||
}
|
||||
|
||||
if (!this.isAvailableFor(child)) {
|
||||
throw new Error('Belohnung nicht verfügbar für dieses Kind');
|
||||
}
|
||||
|
||||
if (child.points.available < this.cost) {
|
||||
throw new Error('Nicht genügend Punkte verfügbar');
|
||||
}
|
||||
|
||||
// Punkte abziehen
|
||||
await child.spendPoints(this.cost);
|
||||
|
||||
// Verfügbare Menge reduzieren
|
||||
if (this.availability.isLimited) {
|
||||
this.availability.remainingQuantity -= 1;
|
||||
}
|
||||
|
||||
// Statistiken aktualisieren
|
||||
this.stats.timesRedeemed += 1;
|
||||
this.stats.totalPointsSpent += this.cost;
|
||||
this.stats.lastRedeemed = new Date();
|
||||
|
||||
await this.save();
|
||||
|
||||
// Belohnungs-Anfrage erstellen (falls Genehmigung erforderlich)
|
||||
if (this.requiresApproval) {
|
||||
const RewardRequest = mongoose.model('RewardRequest');
|
||||
const request = new RewardRequest({
|
||||
reward: this._id,
|
||||
child: childId,
|
||||
pointsSpent: this.cost,
|
||||
status: 'pending'
|
||||
});
|
||||
await request.save();
|
||||
return request;
|
||||
} else {
|
||||
// Sofortige Einlösung
|
||||
const RewardRequest = mongoose.model('RewardRequest');
|
||||
const request = new RewardRequest({
|
||||
reward: this._id,
|
||||
child: childId,
|
||||
pointsSpent: this.cost,
|
||||
status: 'redeemed',
|
||||
redeemedAt: new Date()
|
||||
});
|
||||
await request.save();
|
||||
return request;
|
||||
}
|
||||
};
|
||||
|
||||
// Statische Methode: Verfügbare Belohnungen für ein Kind
|
||||
rewardSchema.statics.findAvailableFor = function(child) {
|
||||
const query = {
|
||||
isActive: true,
|
||||
$or: [
|
||||
{ availableFor: { $size: 0 } }, // Für alle verfügbar
|
||||
{ availableFor: child._id } // Spezifisch für dieses Kind
|
||||
]
|
||||
};
|
||||
|
||||
// Altersrestriktionen
|
||||
if (child.age) {
|
||||
query.$and = [
|
||||
{ $or: [{ 'ageRestriction.minAge': { $exists: false } }, { 'ageRestriction.minAge': { $lte: child.age } }] },
|
||||
{ $or: [{ 'ageRestriction.maxAge': { $exists: false } }, { 'ageRestriction.maxAge': { $gte: child.age } }] }
|
||||
];
|
||||
}
|
||||
|
||||
return this.find(query).sort({ isPopular: -1, sortOrder: 1, cost: 1 });
|
||||
};
|
||||
|
||||
// Methoden für RewardRequest
|
||||
rewardRequestSchema.methods.approve = function(approvedByUserId) {
|
||||
this.status = 'approved';
|
||||
this.processedAt = new Date();
|
||||
this.processedBy = approvedByUserId;
|
||||
return this.save();
|
||||
};
|
||||
|
||||
rewardRequestSchema.methods.reject = function(reason, rejectedByUserId) {
|
||||
this.status = 'rejected';
|
||||
this.rejectionReason = reason;
|
||||
this.processedAt = new Date();
|
||||
this.processedBy = rejectedByUserId;
|
||||
|
||||
// Punkte zurückgeben
|
||||
return this.refundPoints();
|
||||
};
|
||||
|
||||
rewardRequestSchema.methods.markRedeemed = function() {
|
||||
this.status = 'redeemed';
|
||||
this.redeemedAt = new Date();
|
||||
return this.save();
|
||||
};
|
||||
|
||||
rewardRequestSchema.methods.refundPoints = async function() {
|
||||
const Child = mongoose.model('Child');
|
||||
const child = await Child.findById(this.child);
|
||||
if (child) {
|
||||
child.points.available += this.pointsSpent;
|
||||
child.points.spent -= this.pointsSpent;
|
||||
await child.save();
|
||||
}
|
||||
return this.save();
|
||||
};
|
||||
|
||||
const Reward = mongoose.model('Reward', rewardSchema);
|
||||
const RewardRequest = mongoose.model('RewardRequest', rewardRequestSchema);
|
||||
|
||||
module.exports = { Reward, RewardRequest };
|
||||
278
models/Task.js
Normal file
278
models/Task.js
Normal file
@ -0,0 +1,278 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
// Schema für Aufgaben
|
||||
const taskSchema = new mongoose.Schema({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
maxlength: 100
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
trim: true,
|
||||
maxlength: 500
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: 'task',
|
||||
enum: [
|
||||
'task', 'clean', 'dishes', 'vacuum', 'trash', 'bed', 'homework',
|
||||
'teeth', 'shower', 'clothes', 'toys', 'pets', 'garden', 'help',
|
||||
'read', 'practice', 'exercise', 'cook', 'organize', 'other'
|
||||
]
|
||||
},
|
||||
points: {
|
||||
type: Number,
|
||||
required: true,
|
||||
min: 1,
|
||||
max: 500,
|
||||
default: 10
|
||||
},
|
||||
difficulty: {
|
||||
type: String,
|
||||
enum: ['easy', 'medium', 'hard'],
|
||||
default: 'easy'
|
||||
},
|
||||
category: {
|
||||
type: String,
|
||||
enum: [
|
||||
'household', 'personal', 'homework', 'chores', 'hygiene',
|
||||
'pets', 'garden', 'helping', 'learning', 'exercise', 'other'
|
||||
],
|
||||
default: 'household'
|
||||
},
|
||||
assignedTo: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Child',
|
||||
required: true
|
||||
},
|
||||
createdBy: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true
|
||||
},
|
||||
dueDate: {
|
||||
type: Date,
|
||||
required: true
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['pending', 'completed', 'approved', 'rejected'],
|
||||
default: 'pending'
|
||||
},
|
||||
completedAt: {
|
||||
type: Date
|
||||
},
|
||||
approvedAt: {
|
||||
type: Date
|
||||
},
|
||||
approvedBy: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User'
|
||||
},
|
||||
rejectionReason: {
|
||||
type: String,
|
||||
trim: true,
|
||||
maxlength: 200
|
||||
},
|
||||
recurring: {
|
||||
isRecurring: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
frequency: {
|
||||
type: String,
|
||||
enum: ['daily', 'weekly', 'monthly'],
|
||||
required: function() { return this.recurring.isRecurring; }
|
||||
},
|
||||
daysOfWeek: [{
|
||||
type: Number,
|
||||
min: 0,
|
||||
max: 6 // 0 = Sonntag, 6 = Samstag
|
||||
}],
|
||||
endDate: Date,
|
||||
nextDueDate: Date
|
||||
},
|
||||
priority: {
|
||||
type: String,
|
||||
enum: ['low', 'normal', 'high', 'urgent'],
|
||||
default: 'normal'
|
||||
},
|
||||
estimatedTime: {
|
||||
type: Number, // in Minuten
|
||||
min: 1,
|
||||
max: 480 // max 8 Stunden
|
||||
},
|
||||
tags: [{
|
||||
type: String,
|
||||
trim: true,
|
||||
maxlength: 20
|
||||
}],
|
||||
notes: {
|
||||
type: String,
|
||||
trim: true,
|
||||
maxlength: 1000
|
||||
},
|
||||
attachments: [{
|
||||
filename: String,
|
||||
url: String,
|
||||
uploadedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
}],
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
updatedAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
});
|
||||
|
||||
// Indizes für bessere Performance
|
||||
taskSchema.index({ assignedTo: 1, status: 1, dueDate: 1 });
|
||||
taskSchema.index({ createdBy: 1, createdAt: -1 });
|
||||
taskSchema.index({ dueDate: 1, status: 1 });
|
||||
taskSchema.index({ 'recurring.isRecurring': 1, 'recurring.nextDueDate': 1 });
|
||||
|
||||
// Middleware: updatedAt automatisch setzen
|
||||
taskSchema.pre('save', function(next) {
|
||||
this.updatedAt = new Date();
|
||||
next();
|
||||
});
|
||||
|
||||
// Methode: Aufgabe als erledigt markieren
|
||||
taskSchema.methods.markCompleted = function() {
|
||||
this.status = 'completed';
|
||||
this.completedAt = new Date();
|
||||
return this.save();
|
||||
};
|
||||
|
||||
// Methode: Aufgabe genehmigen
|
||||
taskSchema.methods.approve = async function(approvedByUserId) {
|
||||
this.status = 'approved';
|
||||
this.approvedAt = new Date();
|
||||
this.approvedBy = approvedByUserId;
|
||||
|
||||
// Punkte dem Kind gutschreiben
|
||||
const Child = mongoose.model('Child');
|
||||
const child = await Child.findById(this.assignedTo);
|
||||
if (child) {
|
||||
await child.addPoints(this.points);
|
||||
|
||||
// Statistiken aktualisieren
|
||||
child.stats.tasksCompleted += 1;
|
||||
child.stats.tasksCompletedToday += 1;
|
||||
child.updateStreak();
|
||||
await child.save();
|
||||
}
|
||||
|
||||
// Nächste wiederkehrende Aufgabe erstellen
|
||||
if (this.recurring.isRecurring) {
|
||||
await this.createNextRecurringTask();
|
||||
}
|
||||
|
||||
return this.save();
|
||||
};
|
||||
|
||||
// Methode: Aufgabe ablehnen
|
||||
taskSchema.methods.reject = function(reason, rejectedByUserId) {
|
||||
this.status = 'rejected';
|
||||
this.rejectionReason = reason;
|
||||
this.approvedBy = rejectedByUserId;
|
||||
return this.save();
|
||||
};
|
||||
|
||||
// Methode: Nächste wiederkehrende Aufgabe erstellen
|
||||
taskSchema.methods.createNextRecurringTask = async function() {
|
||||
if (!this.recurring.isRecurring) return;
|
||||
|
||||
const Task = mongoose.model('Task');
|
||||
let nextDueDate = new Date(this.dueDate);
|
||||
|
||||
// Nächstes Fälligkeitsdatum berechnen
|
||||
switch (this.recurring.frequency) {
|
||||
case 'daily':
|
||||
nextDueDate.setDate(nextDueDate.getDate() + 1);
|
||||
break;
|
||||
case 'weekly':
|
||||
nextDueDate.setDate(nextDueDate.getDate() + 7);
|
||||
break;
|
||||
case 'monthly':
|
||||
nextDueDate.setMonth(nextDueDate.getMonth() + 1);
|
||||
break;
|
||||
}
|
||||
|
||||
// Prüfen ob End-Datum erreicht
|
||||
if (this.recurring.endDate && nextDueDate > this.recurring.endDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Neue Aufgabe erstellen
|
||||
const newTask = new Task({
|
||||
title: this.title,
|
||||
description: this.description,
|
||||
icon: this.icon,
|
||||
points: this.points,
|
||||
difficulty: this.difficulty,
|
||||
category: this.category,
|
||||
assignedTo: this.assignedTo,
|
||||
createdBy: this.createdBy,
|
||||
dueDate: nextDueDate,
|
||||
recurring: this.recurring,
|
||||
priority: this.priority,
|
||||
estimatedTime: this.estimatedTime,
|
||||
tags: this.tags,
|
||||
notes: this.notes
|
||||
});
|
||||
|
||||
await newTask.save();
|
||||
};
|
||||
|
||||
// Statische Methode: Überfällige Aufgaben finden
|
||||
taskSchema.statics.findOverdue = function() {
|
||||
const now = new Date();
|
||||
return this.find({
|
||||
dueDate: { $lt: now },
|
||||
status: { $in: ['pending', 'completed'] },
|
||||
isActive: true
|
||||
});
|
||||
};
|
||||
|
||||
// Statische Methode: Heutige Aufgaben für ein Kind
|
||||
taskSchema.statics.findTodayTasks = function(childId) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
return this.find({
|
||||
assignedTo: childId,
|
||||
dueDate: { $gte: today, $lt: tomorrow },
|
||||
isActive: true
|
||||
}).sort({ priority: -1, createdAt: 1 });
|
||||
};
|
||||
|
||||
// Virtuelle Eigenschaft: Ist überfällig
|
||||
taskSchema.virtual('isOverdue').get(function() {
|
||||
return this.dueDate < new Date() && this.status === 'pending';
|
||||
});
|
||||
|
||||
// Virtuelle Eigenschaft: Ist heute fällig
|
||||
taskSchema.virtual('isDueToday').get(function() {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
return this.dueDate >= today && this.dueDate < tomorrow;
|
||||
});
|
||||
|
||||
module.exports = mongoose.model('Task', taskSchema);
|
||||
97
models/User.js
Normal file
97
models/User.js
Normal file
@ -0,0 +1,97 @@
|
||||
const mongoose = require('mongoose');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
// Schema für Eltern/Familie
|
||||
const userSchema = new mongoose.Schema({
|
||||
email: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
lowercase: true,
|
||||
trim: true
|
||||
},
|
||||
password: {
|
||||
type: String,
|
||||
required: true,
|
||||
minlength: 6
|
||||
},
|
||||
familyName: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
parentName: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
role: {
|
||||
type: String,
|
||||
enum: ['parent', 'admin'],
|
||||
default: 'parent'
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
preferences: {
|
||||
notifications: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
language: {
|
||||
type: String,
|
||||
default: 'de'
|
||||
},
|
||||
theme: {
|
||||
type: String,
|
||||
enum: ['light', 'dark'],
|
||||
default: 'light'
|
||||
}
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
lastLogin: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
}
|
||||
});
|
||||
|
||||
// Passwort hashen vor dem Speichern
|
||||
userSchema.pre('save', async function(next) {
|
||||
// Nur hashen wenn Passwort geändert wurde
|
||||
if (!this.isModified('password')) return next();
|
||||
|
||||
try {
|
||||
// Passwort hashen
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
this.password = await bcrypt.hash(this.password, salt);
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Methode zum Passwort-Vergleich
|
||||
userSchema.methods.comparePassword = async function(candidatePassword) {
|
||||
return await bcrypt.compare(candidatePassword, this.password);
|
||||
};
|
||||
|
||||
// Virtuelle Eigenschaft für Kinder-Anzahl
|
||||
userSchema.virtual('childrenCount', {
|
||||
ref: 'Child',
|
||||
localField: '_id',
|
||||
foreignField: 'parent',
|
||||
count: true
|
||||
});
|
||||
|
||||
// JSON-Ausgabe anpassen (Passwort ausblenden)
|
||||
userSchema.methods.toJSON = function() {
|
||||
const user = this.toObject();
|
||||
delete user.password;
|
||||
return user;
|
||||
};
|
||||
|
||||
module.exports = mongoose.model('User', userSchema);
|
||||
1685
package-lock.json
generated
Normal file
1685
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
package.json
Normal file
37
package.json
Normal file
@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "todo-kids",
|
||||
"version": "1.0.0",
|
||||
"description": "Eine spielerische To-Do- & Belohnungs-App für Kinder und Eltern mit verschiedenen Themes",
|
||||
"main": "server.js",
|
||||
"homepage": "https://github.com/DEIN-USERNAME/todo-kids",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/DEIN-USERNAME/todo-kids.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/DEIN-USERNAME/todo-kids/issues"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js",
|
||||
"client": "cd client && npm start",
|
||||
"server": "nodemon server.js",
|
||||
"build": "cd client && npm run build",
|
||||
"heroku-postbuild": "cd client && npm install && npm run build"
|
||||
},
|
||||
"keywords": ["todo", "family", "kids", "rewards", "gamification", "react", "material-ui", "themes", "children"],
|
||||
"author": "ToDo Kids Team",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"mongoose": "^7.5.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"express-validator": "^7.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
}
|
||||
}
|
||||
336
routes/auth.js
Normal file
336
routes/auth.js
Normal file
@ -0,0 +1,336 @@
|
||||
const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const User = require('../models/User');
|
||||
const { auth } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// @route POST /api/auth/register
|
||||
// @desc Registriere neuen Eltern-Account
|
||||
// @access Public
|
||||
router.post('/register', [
|
||||
body('email')
|
||||
.isEmail()
|
||||
.normalizeEmail()
|
||||
.withMessage('Bitte gib eine gültige E-Mail-Adresse ein'),
|
||||
body('password')
|
||||
.isLength({ min: 6 })
|
||||
.withMessage('Passwort muss mindestens 6 Zeichen lang sein'),
|
||||
body('familyName')
|
||||
.trim()
|
||||
.isLength({ min: 2, max: 50 })
|
||||
.withMessage('Familienname muss zwischen 2 und 50 Zeichen lang sein'),
|
||||
body('parentName')
|
||||
.trim()
|
||||
.isLength({ min: 2, max: 50 })
|
||||
.withMessage('Elternname muss zwischen 2 und 50 Zeichen lang sein')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
// Validierungsfehler prüfen
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validierungsfehler',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { email, password, familyName, parentName } = req.body;
|
||||
|
||||
// Prüfen ob Benutzer bereits existiert
|
||||
let user = await User.findOne({ email });
|
||||
if (user) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Ein Account mit dieser E-Mail-Adresse existiert bereits'
|
||||
});
|
||||
}
|
||||
|
||||
// Neuen Benutzer erstellen
|
||||
user = new User({
|
||||
email,
|
||||
password,
|
||||
familyName,
|
||||
parentName
|
||||
});
|
||||
|
||||
await user.save();
|
||||
|
||||
// JWT Token erstellen
|
||||
const payload = {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
role: user.role
|
||||
}
|
||||
};
|
||||
|
||||
jwt.sign(
|
||||
payload,
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '7d' },
|
||||
(err, token) => {
|
||||
if (err) throw err;
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: `Willkommen bei Familien-Held, ${parentName}! 🎉`,
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
familyName: user.familyName,
|
||||
parentName: user.parentName,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Registrierungsfehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler bei der Registrierung'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/auth/login
|
||||
// @desc Eltern-Login
|
||||
// @access Public
|
||||
router.post('/login', [
|
||||
body('email')
|
||||
.isEmail()
|
||||
.normalizeEmail()
|
||||
.withMessage('Bitte gib eine gültige E-Mail-Adresse ein'),
|
||||
body('password')
|
||||
.exists()
|
||||
.withMessage('Passwort ist erforderlich')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
// Validierungsfehler prüfen
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validierungsfehler',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { email, password } = req.body;
|
||||
|
||||
// Benutzer finden
|
||||
const user = await User.findOne({ email, isActive: true });
|
||||
if (!user) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Ungültige Anmeldedaten'
|
||||
});
|
||||
}
|
||||
|
||||
// Passwort prüfen
|
||||
const isMatch = await user.comparePassword(password);
|
||||
if (!isMatch) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Ungültige Anmeldedaten'
|
||||
});
|
||||
}
|
||||
|
||||
// Letzten Login aktualisieren
|
||||
user.lastLogin = new Date();
|
||||
await user.save();
|
||||
|
||||
// JWT Token erstellen
|
||||
const payload = {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
role: user.role
|
||||
}
|
||||
};
|
||||
|
||||
jwt.sign(
|
||||
payload,
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '7d' },
|
||||
(err, token) => {
|
||||
if (err) throw err;
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Willkommen zurück, ${user.parentName}! 👋`,
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
familyName: user.familyName,
|
||||
parentName: user.parentName,
|
||||
role: user.role,
|
||||
lastLogin: user.lastLogin
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Login-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Login'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/auth/me
|
||||
// @desc Aktuellen Benutzer abrufen
|
||||
// @access Private
|
||||
router.get('/me', auth, async (req, res) => {
|
||||
try {
|
||||
const user = await User.findById(req.user.id).select('-password');
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Benutzer nicht gefunden'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Benutzer-Abruf-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Abrufen der Benutzerdaten'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route PUT /api/auth/profile
|
||||
// @desc Profil aktualisieren
|
||||
// @access Private
|
||||
router.put('/profile', [
|
||||
auth,
|
||||
body('familyName')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ min: 2, max: 50 })
|
||||
.withMessage('Familienname muss zwischen 2 und 50 Zeichen lang sein'),
|
||||
body('parentName')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ min: 2, max: 50 })
|
||||
.withMessage('Elternname muss zwischen 2 und 50 Zeichen lang sein')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
// Validierungsfehler prüfen
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validierungsfehler',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { familyName, parentName, preferences } = req.body;
|
||||
const updateFields = {};
|
||||
|
||||
if (familyName) updateFields.familyName = familyName;
|
||||
if (parentName) updateFields.parentName = parentName;
|
||||
if (preferences) updateFields.preferences = { ...preferences };
|
||||
|
||||
const user = await User.findByIdAndUpdate(
|
||||
req.user.id,
|
||||
{ $set: updateFields },
|
||||
{ new: true, runValidators: true }
|
||||
).select('-password');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Profil erfolgreich aktualisiert! ✅',
|
||||
user
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Profil-Update-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Aktualisieren des Profils'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route PUT /api/auth/password
|
||||
// @desc Passwort ändern
|
||||
// @access Private
|
||||
router.put('/password', [
|
||||
auth,
|
||||
body('currentPassword')
|
||||
.exists()
|
||||
.withMessage('Aktuelles Passwort ist erforderlich'),
|
||||
body('newPassword')
|
||||
.isLength({ min: 6 })
|
||||
.withMessage('Neues Passwort muss mindestens 6 Zeichen lang sein')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
// Validierungsfehler prüfen
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validierungsfehler',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
|
||||
// Benutzer mit Passwort abrufen
|
||||
const user = await User.findById(req.user.id);
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Benutzer nicht gefunden'
|
||||
});
|
||||
}
|
||||
|
||||
// Aktuelles Passwort prüfen
|
||||
const isMatch = await user.comparePassword(currentPassword);
|
||||
if (!isMatch) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Aktuelles Passwort ist falsch'
|
||||
});
|
||||
}
|
||||
|
||||
// Neues Passwort setzen
|
||||
user.password = newPassword;
|
||||
await user.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Passwort erfolgreich geändert! 🔒'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Passwort-Änderungs-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Ändern des Passworts'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/auth/logout
|
||||
// @desc Logout (Token invalidieren - clientseitig)
|
||||
// @access Private
|
||||
router.post('/logout', auth, (req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Erfolgreich abgemeldet! Bis bald! 👋'
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
137
routes/childAuth.js
Normal file
137
routes/childAuth.js
Normal file
@ -0,0 +1,137 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const Child = require('../models/Child');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// @route POST /api/child-auth/login
|
||||
// @desc Kind-Anmeldung mit Benutzername und PIN
|
||||
// @access Public
|
||||
router.post('/login', [
|
||||
body('username')
|
||||
.trim()
|
||||
.isLength({ min: 3, max: 20 })
|
||||
.withMessage('Benutzername muss zwischen 3 und 20 Zeichen lang sein')
|
||||
.matches(/^[a-zA-Z0-9_]+$/)
|
||||
.withMessage('Benutzername darf nur Buchstaben, Zahlen und Unterstriche enthalten'),
|
||||
body('pin')
|
||||
.isLength({ min: 4, max: 6 })
|
||||
.withMessage('PIN muss zwischen 4 und 6 Zeichen lang sein')
|
||||
.matches(/^[0-9]+$/)
|
||||
.withMessage('PIN darf nur Zahlen enthalten')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
// Validierungsfehler prüfen
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Ungültige Eingaben',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { username, pin } = req.body;
|
||||
|
||||
// Kind finden
|
||||
const child = await Child.findOne({
|
||||
username: username.toLowerCase(),
|
||||
isActive: true
|
||||
}).populate('parent', 'familyName');
|
||||
|
||||
if (!child) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Benutzername oder PIN ist falsch'
|
||||
});
|
||||
}
|
||||
|
||||
// PIN verifizieren
|
||||
const isValidPin = await child.verifyPin(pin);
|
||||
if (!isValidPin) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Benutzername oder PIN ist falsch'
|
||||
});
|
||||
}
|
||||
|
||||
// JWT Token erstellen (für Kinder)
|
||||
const payload = {
|
||||
child: {
|
||||
id: child._id,
|
||||
username: child.username,
|
||||
name: child.name,
|
||||
parent: child.parent._id
|
||||
}
|
||||
};
|
||||
|
||||
const token = jwt.sign(
|
||||
payload,
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '7d' } // Längere Gültigkeit für Kinder
|
||||
);
|
||||
|
||||
// Letzte Aktivität aktualisieren
|
||||
child.lastActivity = new Date();
|
||||
await child.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Willkommen zurück, ${child.name}!`,
|
||||
token,
|
||||
child: child.toSafeObject()
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Kind-Anmeldung-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler bei der Anmeldung'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/child-auth/verify-token
|
||||
// @desc Kind-Token verifizieren
|
||||
// @access Public
|
||||
router.post('/verify-token', async (req, res) => {
|
||||
try {
|
||||
const token = req.header('x-auth-token') || req.body.token;
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Kein Token vorhanden'
|
||||
});
|
||||
}
|
||||
|
||||
// Token verifizieren
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
// Kind laden
|
||||
const child = await Child.findById(decoded.child.id)
|
||||
.populate('parent', 'familyName');
|
||||
|
||||
if (!child || !child.isActive) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Ungültiger Token'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
child: child.toSafeObject()
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Token-Verifikation-Fehler:', error.message);
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
message: 'Ungültiger Token'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
477
routes/children.js
Normal file
477
routes/children.js
Normal file
@ -0,0 +1,477 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const Child = require('../models/Child');
|
||||
const { auth } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// @route GET /api/children
|
||||
// @desc Alle Kinder des angemeldeten Elternteils abrufen
|
||||
// @access Private
|
||||
router.get('/', auth, async (req, res) => {
|
||||
try {
|
||||
const children = await Child.find({
|
||||
parent: req.user.id,
|
||||
isActive: true
|
||||
}).sort({ createdAt: 1 });
|
||||
|
||||
// Sichere Daten für Frontend (ohne hashedPin)
|
||||
const safeChildren = children.map(child => child.toSafeObject());
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
count: safeChildren.length,
|
||||
children: safeChildren
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Kinder-Abruf-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Abrufen der Kinder'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/children/:id
|
||||
// @desc Einzelnes Kind abrufen
|
||||
// @access Private
|
||||
router.get('/:id', auth, async (req, res) => {
|
||||
try {
|
||||
const child = await Child.findOne({
|
||||
_id: req.params.id,
|
||||
parent: req.user.id,
|
||||
isActive: true
|
||||
});
|
||||
|
||||
if (!child) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Kind nicht gefunden'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
child: child.toSafeObject()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Kind-Abruf-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Abrufen des Kindes'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/children
|
||||
// @desc Neues Kind erstellen
|
||||
// @access Private
|
||||
router.post('/', [
|
||||
auth,
|
||||
body('name')
|
||||
.trim()
|
||||
.isLength({ min: 2, max: 50 })
|
||||
.withMessage('Name muss zwischen 2 und 50 Zeichen lang sein'),
|
||||
body('age')
|
||||
.isInt({ min: 3, max: 18 })
|
||||
.withMessage('Alter muss zwischen 3 und 18 Jahren liegen'),
|
||||
body('username')
|
||||
.trim()
|
||||
.isLength({ min: 3, max: 20 })
|
||||
.withMessage('Benutzername muss zwischen 3 und 20 Zeichen lang sein')
|
||||
.matches(/^[a-zA-Z0-9_]+$/)
|
||||
.withMessage('Benutzername darf nur Buchstaben, Zahlen und Unterstriche enthalten'),
|
||||
body('pin')
|
||||
.isLength({ min: 4, max: 6 })
|
||||
.withMessage('PIN muss zwischen 4 und 6 Zeichen lang sein')
|
||||
.matches(/^[0-9]+$/)
|
||||
.withMessage('PIN darf nur Zahlen enthalten'),
|
||||
body('avatar')
|
||||
.optional()
|
||||
.isIn([
|
||||
'default-child.png', 'superhero-boy.png', 'superhero-girl.png',
|
||||
'pirate-boy.png', 'pirate-girl.png', 'princess.png', 'knight.png',
|
||||
'astronaut.png', 'detective.png', 'artist.png'
|
||||
])
|
||||
.withMessage('Ungültiger Avatar ausgewählt')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
// Validierungsfehler prüfen
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validierungsfehler',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { name, age, username, pin, avatar, preferences } = req.body;
|
||||
|
||||
// Prüfen ob bereits ein Kind mit diesem Namen existiert
|
||||
const existingChild = await Child.findOne({
|
||||
parent: req.user.id,
|
||||
name: name.trim(),
|
||||
isActive: true
|
||||
});
|
||||
|
||||
if (existingChild) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Ein Kind mit diesem Namen existiert bereits'
|
||||
});
|
||||
}
|
||||
|
||||
// Prüfen ob Benutzername bereits existiert
|
||||
const existingUsername = await Child.findOne({
|
||||
username: username.toLowerCase(),
|
||||
isActive: true
|
||||
});
|
||||
|
||||
if (existingUsername) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Dieser Benutzername ist bereits vergeben'
|
||||
});
|
||||
}
|
||||
|
||||
// Neues Kind erstellen
|
||||
const child = new Child({
|
||||
name: name.trim(),
|
||||
age,
|
||||
username: username.toLowerCase(),
|
||||
pin, // Wird automatisch gehashed durch pre-save Hook
|
||||
avatar: avatar || 'default-child.png',
|
||||
parent: req.user.id,
|
||||
preferences: preferences || {}
|
||||
});
|
||||
|
||||
await child.save();
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: `${name} wurde erfolgreich als neuer Familien-Held hinzugefügt! 🎉\nBenutzername: ${username}\nPIN: Sicher gespeichert`,
|
||||
child: child.toSafeObject()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Kind-Erstellungs-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Erstellen des Kindes'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route PUT /api/children/:id
|
||||
// @desc Kind aktualisieren
|
||||
// @access Private
|
||||
router.put('/:id', [
|
||||
auth,
|
||||
body('name')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ min: 2, max: 50 })
|
||||
.withMessage('Name muss zwischen 2 und 50 Zeichen lang sein'),
|
||||
body('age')
|
||||
.optional()
|
||||
.isInt({ min: 3, max: 18 })
|
||||
.withMessage('Alter muss zwischen 3 und 18 Jahren liegen'),
|
||||
body('avatar')
|
||||
.optional()
|
||||
.isIn([
|
||||
'default-child.png', 'superhero-boy.png', 'superhero-girl.png',
|
||||
'pirate-boy.png', 'pirate-girl.png', 'princess.png', 'knight.png',
|
||||
'astronaut.png', 'detective.png', 'artist.png'
|
||||
])
|
||||
.withMessage('Ungültiger Avatar ausgewählt')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
// Validierungsfehler prüfen
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validierungsfehler',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { name, age, avatar, preferences } = req.body;
|
||||
const updateFields = {};
|
||||
|
||||
if (name) updateFields.name = name.trim();
|
||||
if (age) updateFields.age = age;
|
||||
if (avatar) updateFields.avatar = avatar;
|
||||
if (preferences) updateFields.preferences = { ...preferences };
|
||||
|
||||
const child = await Child.findOneAndUpdate(
|
||||
{ _id: req.params.id, parent: req.user.id, isActive: true },
|
||||
{ $set: updateFields },
|
||||
{ new: true, runValidators: true }
|
||||
);
|
||||
|
||||
if (!child) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Kind nicht gefunden'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `${child.name}s Profil wurde erfolgreich aktualisiert! ✅`,
|
||||
child
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Kind-Update-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Aktualisieren des Kindes'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route DELETE /api/children/:id
|
||||
// @desc Kind deaktivieren (soft delete)
|
||||
// @access Private
|
||||
router.delete('/:id', auth, async (req, res) => {
|
||||
try {
|
||||
const child = await Child.findOneAndUpdate(
|
||||
{ _id: req.params.id, parent: req.user.id, isActive: true },
|
||||
{ $set: { isActive: false } },
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
if (!child) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Kind nicht gefunden'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `${child.name} wurde aus der Familie entfernt`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Kind-Löschungs-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Entfernen des Kindes'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/children/:id/stats
|
||||
// @desc Statistiken für ein Kind abrufen
|
||||
// @access Private
|
||||
router.get('/:id/stats', auth, async (req, res) => {
|
||||
try {
|
||||
const child = await Child.findOne({
|
||||
_id: req.params.id,
|
||||
parent: req.user.id,
|
||||
isActive: true
|
||||
});
|
||||
|
||||
if (!child) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Kind nicht gefunden'
|
||||
});
|
||||
}
|
||||
|
||||
// Zusätzliche Statistiken berechnen
|
||||
const Task = require('../models/Task');
|
||||
const { RewardRequest } = require('../models/Reward');
|
||||
|
||||
const [completedTasks, pendingTasks, rewardRequests] = await Promise.all([
|
||||
Task.countDocuments({ assignedTo: child._id, status: 'approved' }),
|
||||
Task.countDocuments({ assignedTo: child._id, status: 'pending' }),
|
||||
RewardRequest.countDocuments({ child: child._id })
|
||||
]);
|
||||
|
||||
const stats = {
|
||||
points: child.points,
|
||||
level: child.level,
|
||||
achievements: child.achievements,
|
||||
tasks: {
|
||||
completed: completedTasks,
|
||||
pending: pendingTasks,
|
||||
completedToday: child.stats.tasksCompletedToday
|
||||
},
|
||||
streak: child.stats.streak,
|
||||
rewards: {
|
||||
totalRequests: rewardRequests
|
||||
},
|
||||
memberSince: child.createdAt,
|
||||
lastActivity: child.lastActivity
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
stats
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Kind-Statistik-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Abrufen der Statistiken'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/children/:id/points
|
||||
// @desc Punkte manuell hinzufügen/abziehen (für Eltern)
|
||||
// @access Private
|
||||
router.post('/:id/points', [
|
||||
auth,
|
||||
body('points')
|
||||
.isInt({ min: -1000, max: 1000 })
|
||||
.withMessage('Punkte müssen zwischen -1000 und 1000 liegen'),
|
||||
body('reason')
|
||||
.trim()
|
||||
.isLength({ min: 3, max: 200 })
|
||||
.withMessage('Grund muss zwischen 3 und 200 Zeichen lang sein')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
// Validierungsfehler prüfen
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validierungsfehler',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { points, reason } = req.body;
|
||||
|
||||
const child = await Child.findOne({
|
||||
_id: req.params.id,
|
||||
parent: req.user.id,
|
||||
isActive: true
|
||||
});
|
||||
|
||||
if (!child) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Kind nicht gefunden'
|
||||
});
|
||||
}
|
||||
|
||||
// Punkte hinzufügen oder abziehen
|
||||
if (points > 0) {
|
||||
await child.addPoints(points);
|
||||
} else {
|
||||
// Negative Punkte (Abzug)
|
||||
const absPoints = Math.abs(points);
|
||||
if (child.points.available < absPoints) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Nicht genügend Punkte zum Abziehen verfügbar'
|
||||
});
|
||||
}
|
||||
child.points.available -= absPoints;
|
||||
child.points.total -= absPoints;
|
||||
await child.save();
|
||||
}
|
||||
|
||||
const action = points > 0 ? 'hinzugefügt' : 'abgezogen';
|
||||
const emoji = points > 0 ? '🎉' : '⚠️';
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `${Math.abs(points)} Punkte wurden ${child.name} ${action}! ${emoji}`,
|
||||
reason,
|
||||
child: {
|
||||
id: child._id,
|
||||
name: child.name,
|
||||
points: child.points,
|
||||
level: child.level
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Punkte-Änderungs-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Ändern der Punkte'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/children/:id/achievements
|
||||
// @desc Achievement hinzufügen
|
||||
// @access Private
|
||||
router.post('/:id/achievements', [
|
||||
auth,
|
||||
body('name')
|
||||
.trim()
|
||||
.isLength({ min: 3, max: 50 })
|
||||
.withMessage('Achievement-Name muss zwischen 3 und 50 Zeichen lang sein'),
|
||||
body('description')
|
||||
.trim()
|
||||
.isLength({ min: 5, max: 200 })
|
||||
.withMessage('Beschreibung muss zwischen 5 und 200 Zeichen lang sein'),
|
||||
body('icon')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ min: 1, max: 20 })
|
||||
.withMessage('Icon muss zwischen 1 und 20 Zeichen lang sein')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
// Validierungsfehler prüfen
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validierungsfehler',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { name, description, icon } = req.body;
|
||||
|
||||
const child = await Child.findOne({
|
||||
_id: req.params.id,
|
||||
parent: req.user.id,
|
||||
isActive: true
|
||||
});
|
||||
|
||||
if (!child) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Kind nicht gefunden'
|
||||
});
|
||||
}
|
||||
|
||||
// Achievement hinzufügen
|
||||
const achievement = {
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
icon: icon || '🏆',
|
||||
earnedAt: new Date()
|
||||
};
|
||||
|
||||
child.achievements.push(achievement);
|
||||
await child.save();
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: `${child.name} hat ein neues Achievement erhalten: ${name}! 🏆`,
|
||||
achievement,
|
||||
child: {
|
||||
id: child._id,
|
||||
name: child.name,
|
||||
achievements: child.achievements
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Achievement-Hinzufügungs-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Hinzufügen des Achievements'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
580
routes/families.js
Normal file
580
routes/families.js
Normal file
@ -0,0 +1,580 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const User = require('../models/User');
|
||||
const Child = require('../models/Child');
|
||||
const Task = require('../models/Task');
|
||||
const { Reward, RewardRequest } = require('../models/Reward');
|
||||
const { auth } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// @route GET /api/families/dashboard
|
||||
// @desc Dashboard-Daten für Eltern abrufen
|
||||
// @access Private
|
||||
router.get('/dashboard', auth, async (req, res) => {
|
||||
try {
|
||||
// Alle Kinder der Familie abrufen
|
||||
const children = await Child.find({
|
||||
parent: req.user.id,
|
||||
isActive: true
|
||||
}).sort({ createdAt: 1 });
|
||||
|
||||
const childIds = children.map(child => child._id);
|
||||
|
||||
// Aufgaben-Statistiken
|
||||
const [totalTasks, pendingTasks, completedTasks, approvedTasks] = await Promise.all([
|
||||
Task.countDocuments({ createdBy: req.user.id, isActive: true }),
|
||||
Task.countDocuments({ createdBy: req.user.id, status: 'pending', isActive: true }),
|
||||
Task.countDocuments({ createdBy: req.user.id, status: 'completed', isActive: true }),
|
||||
Task.countDocuments({ createdBy: req.user.id, status: 'approved', isActive: true })
|
||||
]);
|
||||
|
||||
// Heutige Aufgaben
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
const todayTasks = await Task.find({
|
||||
createdBy: req.user.id,
|
||||
dueDate: { $gte: today, $lt: tomorrow },
|
||||
isActive: true
|
||||
}).populate('assignedTo', 'name avatar');
|
||||
|
||||
// Überfällige Aufgaben
|
||||
const overdueTasks = await Task.find({
|
||||
createdBy: req.user.id,
|
||||
status: 'pending',
|
||||
dueDate: { $lt: today },
|
||||
isActive: true
|
||||
}).populate('assignedTo', 'name avatar');
|
||||
|
||||
// Wartende Genehmigungen
|
||||
const pendingApprovals = await Task.find({
|
||||
createdBy: req.user.id,
|
||||
status: 'completed',
|
||||
isActive: true
|
||||
}).populate('assignedTo', 'name avatar').sort({ completedAt: 1 });
|
||||
|
||||
// Belohnungs-Anfragen
|
||||
const pendingRewards = await RewardRequest.find({
|
||||
child: { $in: childIds },
|
||||
status: 'pending'
|
||||
})
|
||||
.populate('reward', 'title cost icon')
|
||||
.populate('child', 'name avatar')
|
||||
.sort({ requestedAt: 1 });
|
||||
|
||||
// Familien-Statistiken
|
||||
const familyStats = {
|
||||
totalChildren: children.length,
|
||||
totalPoints: children.reduce((sum, child) => sum + child.points.total, 0),
|
||||
availablePoints: children.reduce((sum, child) => sum + child.points.available, 0),
|
||||
totalTasks,
|
||||
completionRate: totalTasks > 0 ? Math.round((approvedTasks / totalTasks) * 100) : 0
|
||||
};
|
||||
|
||||
// Aktivitäts-Feed (letzte 10 Aktivitäten)
|
||||
const recentActivities = await Task.find({
|
||||
createdBy: req.user.id,
|
||||
status: { $in: ['approved', 'completed'] },
|
||||
isActive: true
|
||||
})
|
||||
.populate('assignedTo', 'name avatar')
|
||||
.sort({ updatedAt: -1 })
|
||||
.limit(10);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
dashboard: {
|
||||
family: {
|
||||
name: req.user.familyName,
|
||||
parent: req.user.parentName,
|
||||
stats: familyStats
|
||||
},
|
||||
children: children.map(child => ({
|
||||
id: child._id,
|
||||
name: child.name,
|
||||
avatar: child.avatar,
|
||||
age: child.age,
|
||||
points: child.points,
|
||||
level: child.level,
|
||||
stats: child.stats,
|
||||
lastActivity: child.lastActivity
|
||||
})),
|
||||
tasks: {
|
||||
today: todayTasks.length,
|
||||
pending: pendingTasks,
|
||||
completed: completedTasks,
|
||||
approved: approvedTasks,
|
||||
overdue: overdueTasks.length,
|
||||
todayList: todayTasks.slice(0, 5), // Nur erste 5 für Dashboard
|
||||
overdueList: overdueTasks.slice(0, 5),
|
||||
pendingApprovals: pendingApprovals.slice(0, 5)
|
||||
},
|
||||
rewards: {
|
||||
pendingRequests: pendingRewards.length,
|
||||
pendingList: pendingRewards.slice(0, 5)
|
||||
},
|
||||
recentActivities: recentActivities.map(activity => ({
|
||||
id: activity._id,
|
||||
title: activity.title,
|
||||
child: activity.assignedTo,
|
||||
status: activity.status,
|
||||
points: activity.points,
|
||||
completedAt: activity.completedAt,
|
||||
approvedAt: activity.approvedAt
|
||||
}))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Dashboard-Abruf-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Abrufen des Dashboards'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/families/stats
|
||||
// @desc Detaillierte Familien-Statistiken abrufen
|
||||
// @access Private
|
||||
router.get('/stats', auth, async (req, res) => {
|
||||
try {
|
||||
const { period = '30' } = req.query; // Tage
|
||||
const days = parseInt(period);
|
||||
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - days);
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
|
||||
// Alle Kinder der Familie
|
||||
const children = await Child.find({
|
||||
parent: req.user.id,
|
||||
isActive: true
|
||||
});
|
||||
const childIds = children.map(child => child._id);
|
||||
|
||||
// Aufgaben-Statistiken für den Zeitraum
|
||||
const [tasksCreated, tasksCompleted, tasksApproved] = await Promise.all([
|
||||
Task.countDocuments({
|
||||
createdBy: req.user.id,
|
||||
createdAt: { $gte: startDate },
|
||||
isActive: true
|
||||
}),
|
||||
Task.countDocuments({
|
||||
createdBy: req.user.id,
|
||||
status: 'completed',
|
||||
completedAt: { $gte: startDate },
|
||||
isActive: true
|
||||
}),
|
||||
Task.countDocuments({
|
||||
createdBy: req.user.id,
|
||||
status: 'approved',
|
||||
approvedAt: { $gte: startDate },
|
||||
isActive: true
|
||||
})
|
||||
]);
|
||||
|
||||
// Punkte-Statistiken
|
||||
const pointsEarned = await Task.aggregate([
|
||||
{
|
||||
$match: {
|
||||
createdBy: req.user._id,
|
||||
status: 'approved',
|
||||
approvedAt: { $gte: startDate },
|
||||
isActive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
totalPoints: { $sum: '$points' }
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
const pointsSpent = await RewardRequest.aggregate([
|
||||
{
|
||||
$match: {
|
||||
child: { $in: childIds },
|
||||
status: { $in: ['approved', 'redeemed'] },
|
||||
requestedAt: { $gte: startDate }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
totalSpent: { $sum: '$pointsSpent' }
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
// Kategorie-Verteilung
|
||||
const tasksByCategory = await Task.aggregate([
|
||||
{
|
||||
$match: {
|
||||
createdBy: req.user._id,
|
||||
status: 'approved',
|
||||
approvedAt: { $gte: startDate },
|
||||
isActive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: '$category',
|
||||
count: { $sum: 1 },
|
||||
points: { $sum: '$points' }
|
||||
}
|
||||
},
|
||||
{ $sort: { count: -1 } }
|
||||
]);
|
||||
|
||||
// Tägliche Aktivität (letzte 7 Tage)
|
||||
const last7Days = [];
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - i);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
const nextDay = new Date(date);
|
||||
nextDay.setDate(nextDay.getDate() + 1);
|
||||
|
||||
const dayTasks = await Task.countDocuments({
|
||||
createdBy: req.user.id,
|
||||
status: 'approved',
|
||||
approvedAt: { $gte: date, $lt: nextDay },
|
||||
isActive: true
|
||||
});
|
||||
|
||||
last7Days.push({
|
||||
date: date.toISOString().split('T')[0],
|
||||
tasks: dayTasks
|
||||
});
|
||||
}
|
||||
|
||||
// Kind-spezifische Statistiken
|
||||
const childStats = await Promise.all(
|
||||
children.map(async (child) => {
|
||||
const [childTasks, childPoints] = await Promise.all([
|
||||
Task.countDocuments({
|
||||
assignedTo: child._id,
|
||||
status: 'approved',
|
||||
approvedAt: { $gte: startDate }
|
||||
}),
|
||||
Task.aggregate([
|
||||
{
|
||||
$match: {
|
||||
assignedTo: child._id,
|
||||
status: 'approved',
|
||||
approvedAt: { $gte: startDate }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
totalPoints: { $sum: '$points' }
|
||||
}
|
||||
}
|
||||
])
|
||||
]);
|
||||
|
||||
return {
|
||||
id: child._id,
|
||||
name: child.name,
|
||||
avatar: child.avatar,
|
||||
tasksCompleted: childTasks,
|
||||
pointsEarned: childPoints[0]?.totalPoints || 0,
|
||||
currentPoints: child.points.available,
|
||||
level: child.level,
|
||||
streak: child.stats.streak.current
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const stats = {
|
||||
period: `${days} Tage`,
|
||||
overview: {
|
||||
tasksCreated,
|
||||
tasksCompleted,
|
||||
tasksApproved,
|
||||
completionRate: tasksCreated > 0 ? Math.round((tasksApproved / tasksCreated) * 100) : 0,
|
||||
pointsEarned: pointsEarned[0]?.totalPoints || 0,
|
||||
pointsSpent: pointsSpent[0]?.totalSpent || 0
|
||||
},
|
||||
categories: tasksByCategory,
|
||||
dailyActivity: last7Days,
|
||||
children: childStats,
|
||||
family: {
|
||||
totalChildren: children.length,
|
||||
totalPoints: children.reduce((sum, child) => sum + child.points.total, 0),
|
||||
averageLevel: children.length > 0
|
||||
? Math.round(children.reduce((sum, child) => sum + child.level.current, 0) / children.length * 10) / 10
|
||||
: 0
|
||||
}
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
stats
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Statistik-Abruf-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Abrufen der Statistiken'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/families/leaderboard
|
||||
// @desc Familien-Bestenliste abrufen
|
||||
// @access Private
|
||||
router.get('/leaderboard', auth, async (req, res) => {
|
||||
try {
|
||||
const { period = 'week' } = req.query; // week, month, all
|
||||
|
||||
let startDate;
|
||||
switch (period) {
|
||||
case 'week':
|
||||
startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - 7);
|
||||
break;
|
||||
case 'month':
|
||||
startDate = new Date();
|
||||
startDate.setMonth(startDate.getMonth() - 1);
|
||||
break;
|
||||
default:
|
||||
startDate = new Date(0); // Alle Zeit
|
||||
}
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
|
||||
// Alle Kinder der Familie
|
||||
const children = await Child.find({
|
||||
parent: req.user.id,
|
||||
isActive: true
|
||||
});
|
||||
|
||||
// Leaderboard-Daten für jedes Kind
|
||||
const leaderboard = await Promise.all(
|
||||
children.map(async (child) => {
|
||||
const [tasksCompleted, pointsEarned] = await Promise.all([
|
||||
Task.countDocuments({
|
||||
assignedTo: child._id,
|
||||
status: 'approved',
|
||||
approvedAt: { $gte: startDate }
|
||||
}),
|
||||
Task.aggregate([
|
||||
{
|
||||
$match: {
|
||||
assignedTo: child._id,
|
||||
status: 'approved',
|
||||
approvedAt: { $gte: startDate }
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
totalPoints: { $sum: '$points' }
|
||||
}
|
||||
}
|
||||
])
|
||||
]);
|
||||
|
||||
return {
|
||||
id: child._id,
|
||||
name: child.name,
|
||||
avatar: child.avatar,
|
||||
age: child.age,
|
||||
level: child.level,
|
||||
tasksCompleted,
|
||||
pointsEarned: pointsEarned[0]?.totalPoints || 0,
|
||||
currentStreak: child.stats.streak.current,
|
||||
longestStreak: child.stats.streak.longest,
|
||||
totalPoints: child.points.total,
|
||||
achievements: child.achievements.length
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Nach Punkten sortieren
|
||||
leaderboard.sort((a, b) => b.pointsEarned - a.pointsEarned);
|
||||
|
||||
// Ränge zuweisen
|
||||
leaderboard.forEach((child, index) => {
|
||||
child.rank = index + 1;
|
||||
child.medal = index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : null;
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
period,
|
||||
leaderboard
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Leaderboard-Abruf-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Abrufen der Bestenliste'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/families/activity-feed
|
||||
// @desc Aktivitäts-Feed der Familie abrufen
|
||||
// @access Private
|
||||
router.get('/activity-feed', auth, async (req, res) => {
|
||||
try {
|
||||
const { limit = 20, offset = 0 } = req.query;
|
||||
|
||||
// Alle Kinder der Familie
|
||||
const children = await Child.find({
|
||||
parent: req.user.id,
|
||||
isActive: true
|
||||
});
|
||||
const childIds = children.map(child => child._id);
|
||||
|
||||
// Aktivitäten sammeln
|
||||
const activities = [];
|
||||
|
||||
// Genehmigte Aufgaben
|
||||
const approvedTasks = await Task.find({
|
||||
createdBy: req.user.id,
|
||||
status: 'approved',
|
||||
isActive: true
|
||||
})
|
||||
.populate('assignedTo', 'name avatar')
|
||||
.sort({ approvedAt: -1 })
|
||||
.limit(parseInt(limit))
|
||||
.skip(parseInt(offset));
|
||||
|
||||
approvedTasks.forEach(task => {
|
||||
activities.push({
|
||||
id: task._id,
|
||||
type: 'task_approved',
|
||||
title: `${task.assignedTo.name} hat "${task.title}" erledigt`,
|
||||
description: `+${task.points} Punkte erhalten`,
|
||||
child: task.assignedTo,
|
||||
points: task.points,
|
||||
timestamp: task.approvedAt,
|
||||
icon: '✅'
|
||||
});
|
||||
});
|
||||
|
||||
// Eingelöste Belohnungen
|
||||
const redeemedRewards = await RewardRequest.find({
|
||||
child: { $in: childIds },
|
||||
status: { $in: ['approved', 'redeemed'] }
|
||||
})
|
||||
.populate('reward', 'title cost icon')
|
||||
.populate('child', 'name avatar')
|
||||
.sort({ processedAt: -1 })
|
||||
.limit(parseInt(limit))
|
||||
.skip(parseInt(offset));
|
||||
|
||||
redeemedRewards.forEach(request => {
|
||||
activities.push({
|
||||
id: request._id,
|
||||
type: 'reward_redeemed',
|
||||
title: `${request.child.name} hat "${request.reward.title}" eingelöst`,
|
||||
description: `-${request.pointsSpent} Punkte ausgegeben`,
|
||||
child: request.child,
|
||||
points: -request.pointsSpent,
|
||||
timestamp: request.processedAt || request.requestedAt,
|
||||
icon: '🎁'
|
||||
});
|
||||
});
|
||||
|
||||
// Level-Ups (aus Achievements)
|
||||
children.forEach(child => {
|
||||
child.achievements.forEach(achievement => {
|
||||
if (achievement.name.includes('Level')) {
|
||||
activities.push({
|
||||
id: `${child._id}_${achievement._id}`,
|
||||
type: 'level_up',
|
||||
title: `${child.name} hat ein neues Level erreicht!`,
|
||||
description: achievement.name,
|
||||
child: {
|
||||
_id: child._id,
|
||||
name: child.name,
|
||||
avatar: child.avatar
|
||||
},
|
||||
timestamp: achievement.earnedAt,
|
||||
icon: '🆙'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Nach Zeitstempel sortieren
|
||||
activities.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
||||
|
||||
// Limit anwenden
|
||||
const paginatedActivities = activities.slice(
|
||||
parseInt(offset),
|
||||
parseInt(offset) + parseInt(limit)
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
count: paginatedActivities.length,
|
||||
total: activities.length,
|
||||
activities: paginatedActivities
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Activity-Feed-Abruf-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Abrufen des Aktivitäts-Feeds'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route PUT /api/families/settings
|
||||
// @desc Familien-Einstellungen aktualisieren
|
||||
// @access Private
|
||||
router.put('/settings', [
|
||||
auth,
|
||||
body('familyName')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ min: 2, max: 50 })
|
||||
.withMessage('Familienname muss zwischen 2 und 50 Zeichen lang sein')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
// Validierungsfehler prüfen
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validierungsfehler',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { familyName, preferences } = req.body;
|
||||
const updateFields = {};
|
||||
|
||||
if (familyName) updateFields.familyName = familyName.trim();
|
||||
if (preferences) updateFields.preferences = { ...req.user.preferences, ...preferences };
|
||||
|
||||
const user = await User.findByIdAndUpdate(
|
||||
req.user.id,
|
||||
{ $set: updateFields },
|
||||
{ new: true, runValidators: true }
|
||||
).select('-password');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Familien-Einstellungen wurden erfolgreich aktualisiert! ⚙️',
|
||||
user
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Einstellungs-Update-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Aktualisieren der Einstellungen'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
215
routes/public.js
Normal file
215
routes/public.js
Normal file
@ -0,0 +1,215 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const Child = require('../models/Child');
|
||||
const Task = require('../models/Task');
|
||||
const { Reward } = require('../models/Reward');
|
||||
|
||||
// @route GET /api/public/children
|
||||
// @desc Alle Kinder für öffentliche Auswahl laden (ohne Authentifizierung)
|
||||
// @access Public
|
||||
router.get('/children', async (req, res) => {
|
||||
try {
|
||||
// Lade alle aktiven Kinder (in einer echten App würde man hier nach Familie filtern)
|
||||
const children = await Child.find({ isActive: true })
|
||||
.select('name age avatar level points')
|
||||
.sort({ name: 1 });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
children
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Kinder:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Laden der Kinder'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/public/child/:id
|
||||
// @desc Einzelnes Kind für öffentlichen Zugriff laden
|
||||
// @access Public
|
||||
router.get('/child/:id', async (req, res) => {
|
||||
try {
|
||||
const child = await Child.findById(req.params.id)
|
||||
.select('name age avatar level points preferences stats');
|
||||
|
||||
if (!child) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Kind nicht gefunden'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
child
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden des Kindes:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Laden des Kindes'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/public/tasks/child/:childId
|
||||
// @desc Aufgaben für ein Kind laden (öffentlich)
|
||||
// @access Public
|
||||
router.get('/tasks/child/:childId', async (req, res) => {
|
||||
try {
|
||||
const { childId } = req.params;
|
||||
const { date } = req.query;
|
||||
|
||||
let dateFilter = {};
|
||||
if (date === 'today') {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
dateFilter = {
|
||||
dueDate: {
|
||||
$gte: today,
|
||||
$lt: tomorrow
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const tasks = await Task.find({
|
||||
assignedTo: childId,
|
||||
...dateFilter
|
||||
}).sort({ dueDate: 1, priority: -1 });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
tasks
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Aufgaben:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Laden der Aufgaben'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/public/rewards/shop/:childId
|
||||
// @desc Belohnungen für ein Kind laden (öffentlich)
|
||||
// @access Public
|
||||
router.get('/rewards/shop/:childId', async (req, res) => {
|
||||
try {
|
||||
const { childId } = req.params;
|
||||
|
||||
// Lade verfügbare Belohnungen für das Kind
|
||||
const rewards = await Reward.find({
|
||||
$or: [
|
||||
{ targetChildren: childId },
|
||||
{ targetChildren: { $size: 0 } } // Globale Belohnungen
|
||||
],
|
||||
isActive: true
|
||||
}).sort({ cost: 1 });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
rewards
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Belohnungen:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Laden der Belohnungen'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/public/tasks/:taskId/complete
|
||||
// @desc Aufgabe als erledigt markieren (öffentlich)
|
||||
// @access Public
|
||||
router.post('/tasks/:taskId/complete', async (req, res) => {
|
||||
try {
|
||||
const { taskId } = req.params;
|
||||
|
||||
const task = await Task.findById(taskId);
|
||||
if (!task) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Aufgabe nicht gefunden'
|
||||
});
|
||||
}
|
||||
|
||||
// Aufgabe als erledigt markieren
|
||||
task.status = 'completed';
|
||||
task.completedAt = new Date();
|
||||
await task.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Aufgabe "${task.title}" wurde als erledigt markiert! 🎉`,
|
||||
task
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Markieren der Aufgabe:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Markieren der Aufgabe'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/public/rewards/:rewardId/redeem
|
||||
// @desc Belohnung einlösen (öffentlich)
|
||||
// @access Public
|
||||
router.post('/rewards/:rewardId/redeem', async (req, res) => {
|
||||
try {
|
||||
const { rewardId } = req.params;
|
||||
const { childId } = req.body;
|
||||
|
||||
const reward = await Reward.findById(rewardId);
|
||||
if (!reward) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Belohnung nicht gefunden'
|
||||
});
|
||||
}
|
||||
|
||||
const child = await Child.findById(childId);
|
||||
if (!child) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Kind nicht gefunden'
|
||||
});
|
||||
}
|
||||
|
||||
// Prüfen ob genug Punkte vorhanden
|
||||
if (child.points.available < reward.cost) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Nicht genügend Punkte verfügbar'
|
||||
});
|
||||
}
|
||||
|
||||
// Punkte abziehen
|
||||
child.points.available -= reward.cost;
|
||||
child.points.spent += reward.cost;
|
||||
await child.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Belohnung "${reward.title}" wurde erfolgreich eingelöst! 🎁`,
|
||||
child: {
|
||||
points: child.points
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Einlösen der Belohnung:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Einlösen der Belohnung'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
581
routes/rewards.js
Normal file
581
routes/rewards.js
Normal file
@ -0,0 +1,581 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { Reward, RewardRequest } = require('../models/Reward');
|
||||
const Child = require('../models/Child');
|
||||
const { auth } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// @route GET /api/rewards
|
||||
// @desc Alle Belohnungen des angemeldeten Elternteils abrufen
|
||||
// @access Private
|
||||
router.get('/', auth, async (req, res) => {
|
||||
try {
|
||||
const { category, isActive = 'true' } = req.query;
|
||||
|
||||
const filter = { createdBy: req.user.id };
|
||||
if (category) filter.category = category;
|
||||
if (isActive !== 'all') filter.isActive = isActive === 'true';
|
||||
|
||||
const rewards = await Reward.find(filter)
|
||||
.sort({ isPopular: -1, sortOrder: 1, cost: 1 });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
count: rewards.length,
|
||||
rewards
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Belohnungen-Abruf-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Abrufen der Belohnungen'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/rewards/requests/all
|
||||
// @desc Alle Belohnungsanfragen des angemeldeten Elternteils abrufen
|
||||
// @access Private
|
||||
router.get('/requests/all', auth, async (req, res) => {
|
||||
try {
|
||||
const { status, childId } = req.query;
|
||||
|
||||
// Alle Kinder des Elternteils abrufen
|
||||
const children = await Child.find({ parent: req.user.id, isActive: true });
|
||||
const childIds = children.map(child => child._id);
|
||||
|
||||
const filter = { child: { $in: childIds } };
|
||||
|
||||
if (status) filter.status = status;
|
||||
if (childId) filter.child = childId;
|
||||
|
||||
const requests = await RewardRequest.find(filter)
|
||||
.populate('child', 'name avatar')
|
||||
.populate('reward', 'title cost category')
|
||||
.sort({ createdAt: -1 });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
count: requests.length,
|
||||
requests
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Belohnungsanfragen-Abruf-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Abrufen der Belohnungsanfragen'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/rewards/shop/:childId
|
||||
// @desc Verfügbare Belohnungen für ein Kind abrufen (Kinder-Shop)
|
||||
// @access Private
|
||||
router.get('/shop/:childId', auth, async (req, res) => {
|
||||
try {
|
||||
const childId = req.params.childId;
|
||||
const { category } = req.query;
|
||||
|
||||
// Prüfen ob Kind zu diesem Elternteil gehört
|
||||
const child = await Child.findOne({
|
||||
_id: childId,
|
||||
parent: req.user.id,
|
||||
isActive: true
|
||||
});
|
||||
|
||||
if (!child) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Kind nicht gefunden'
|
||||
});
|
||||
}
|
||||
|
||||
// Verfügbare Belohnungen für dieses Kind abrufen
|
||||
let rewards = await Reward.findAvailableFor(child);
|
||||
|
||||
if (category) {
|
||||
rewards = rewards.filter(reward => reward.category === category);
|
||||
}
|
||||
|
||||
// Verfügbarkeit für jede Belohnung prüfen
|
||||
const availableRewards = rewards.filter(reward => reward.isAvailableFor(child));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
child: {
|
||||
id: child._id,
|
||||
name: child.name,
|
||||
avatar: child.avatar,
|
||||
points: child.points
|
||||
},
|
||||
count: availableRewards.length,
|
||||
rewards: availableRewards
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Shop-Abruf-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Abrufen des Belohnungs-Shops'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/rewards/requests/:id/approve
|
||||
// @desc Belohnungs-Anfrage genehmigen
|
||||
// @access Private
|
||||
router.post('/requests/:id/approve', auth, async (req, res) => {
|
||||
try {
|
||||
const request = await RewardRequest.findOne({
|
||||
_id: req.params.id,
|
||||
status: 'pending'
|
||||
}).populate('child', 'name parent').populate('reward', 'title cost');
|
||||
|
||||
if (!request) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Anfrage nicht gefunden oder bereits bearbeitet'
|
||||
});
|
||||
}
|
||||
|
||||
// Prüfen ob das Kind zu diesem Elternteil gehört
|
||||
if (request.child.parent.toString() !== req.user.id) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Keine Berechtigung für diese Anfrage'
|
||||
});
|
||||
}
|
||||
|
||||
// Anfrage genehmigen
|
||||
request.status = 'approved';
|
||||
request.processedAt = new Date();
|
||||
request.processedBy = req.user.id;
|
||||
await request.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Belohnungsanfrage für ${request.reward.title} wurde genehmigt`,
|
||||
request
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Anfrage-Genehmigung-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Genehmigen der Anfrage'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/rewards/requests/:id/reject
|
||||
// @desc Belohnungs-Anfrage ablehnen
|
||||
// @access Private
|
||||
router.post('/requests/:id/reject', [
|
||||
auth,
|
||||
body('reason')
|
||||
.trim()
|
||||
.isLength({ min: 3, max: 200 })
|
||||
.withMessage('Ablehnungsgrund muss zwischen 3 und 200 Zeichen lang sein')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validierungsfehler',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { reason } = req.body;
|
||||
|
||||
const request = await RewardRequest.findOne({
|
||||
_id: req.params.id,
|
||||
status: 'pending'
|
||||
}).populate('child', 'name parent').populate('reward', 'title cost');
|
||||
|
||||
if (!request) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Anfrage nicht gefunden oder bereits bearbeitet'
|
||||
});
|
||||
}
|
||||
|
||||
// Prüfen ob das Kind zu diesem Elternteil gehört
|
||||
if (request.child.parent.toString() !== req.user.id) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Keine Berechtigung für diese Anfrage'
|
||||
});
|
||||
}
|
||||
|
||||
// Anfrage ablehnen
|
||||
request.status = 'rejected';
|
||||
request.rejectionReason = reason;
|
||||
request.processedAt = new Date();
|
||||
request.processedBy = req.user.id;
|
||||
await request.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Belohnungsanfrage für ${request.reward.title} wurde abgelehnt`,
|
||||
request
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Anfrage-Ablehnung-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Ablehnen der Anfrage'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/rewards/requests/:id/redeem
|
||||
// @desc Genehmigte Belohnungsanfrage einlösen
|
||||
// @access Private
|
||||
router.post('/requests/:id/redeem', auth, async (req, res) => {
|
||||
try {
|
||||
const request = await RewardRequest.findOne({
|
||||
_id: req.params.id,
|
||||
status: 'approved'
|
||||
}).populate('child', 'name parent points').populate('reward', 'title cost');
|
||||
|
||||
if (!request) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Genehmigte Anfrage nicht gefunden'
|
||||
});
|
||||
}
|
||||
|
||||
// Prüfen ob das Kind zu diesem Elternteil gehört
|
||||
if (request.child.parent.toString() !== req.user.id) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Keine Berechtigung für diese Anfrage'
|
||||
});
|
||||
}
|
||||
|
||||
// Anfrage als eingelöst markieren
|
||||
request.status = 'redeemed';
|
||||
request.redeemedAt = new Date();
|
||||
await request.save();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Belohnung ${request.reward.title} wurde eingelöst`,
|
||||
request
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Anfrage-Einlösung-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Einlösen der Anfrage'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/rewards/:id
|
||||
// @desc Einzelne Belohnung abrufen
|
||||
// @access Private
|
||||
router.get('/:id', auth, async (req, res) => {
|
||||
try {
|
||||
const reward = await Reward.findOne({
|
||||
_id: req.params.id,
|
||||
createdBy: req.user.id
|
||||
});
|
||||
|
||||
if (!reward) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Belohnung nicht gefunden'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
reward
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Belohnung-Abruf-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Abrufen der Belohnung'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/rewards
|
||||
// @desc Neue Belohnung erstellen
|
||||
// @access Private
|
||||
router.post('/', [
|
||||
auth,
|
||||
body('title')
|
||||
.trim()
|
||||
.isLength({ min: 3, max: 100 })
|
||||
.withMessage('Titel muss zwischen 3 und 100 Zeichen lang sein'),
|
||||
body('cost')
|
||||
.isInt({ min: 1, max: 10000 })
|
||||
.withMessage('Kosten müssen zwischen 1 und 10000 Punkten liegen'),
|
||||
body('category')
|
||||
.optional()
|
||||
.isIn([
|
||||
'screen-time', 'treats', 'toys', 'activities', 'outings',
|
||||
'privileges', 'money', 'special', 'electronics', 'books',
|
||||
'sports', 'art', 'other'
|
||||
])
|
||||
.withMessage('Ungültige Kategorie')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
// Validierungsfehler prüfen
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validierungsfehler',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
title, description, cost, category, icon, availableFor,
|
||||
ageRestriction, availability, timeRestrictions,
|
||||
requiresApproval, isInstant, tags, instructions
|
||||
} = req.body;
|
||||
|
||||
// Neue Belohnung erstellen
|
||||
const reward = new Reward({
|
||||
title: title.trim(),
|
||||
description: description?.trim(),
|
||||
cost,
|
||||
category: category || 'treats',
|
||||
icon: icon || 'gift',
|
||||
createdBy: req.user.id,
|
||||
availableFor: availableFor || [],
|
||||
ageRestriction: ageRestriction || {},
|
||||
availability: availability || { isLimited: false },
|
||||
timeRestrictions: timeRestrictions || {},
|
||||
requiresApproval: requiresApproval !== false, // Standard: true
|
||||
isInstant: isInstant || false,
|
||||
tags: tags || [],
|
||||
instructions: instructions?.trim()
|
||||
});
|
||||
|
||||
await reward.save();
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: `Neue Belohnung "${title}" wurde erstellt! 🎁`,
|
||||
reward
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Belohnung-Erstellungs-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Erstellen der Belohnung'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route PUT /api/rewards/:id
|
||||
// @desc Belohnung aktualisieren
|
||||
// @access Private
|
||||
router.put('/:id', [
|
||||
auth,
|
||||
body('title')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ min: 3, max: 100 })
|
||||
.withMessage('Titel muss zwischen 3 und 100 Zeichen lang sein'),
|
||||
body('cost')
|
||||
.optional()
|
||||
.isInt({ min: 1, max: 10000 })
|
||||
.withMessage('Kosten müssen zwischen 1 und 10000 Punkten liegen')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
// Validierungsfehler prüfen
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validierungsfehler',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const updateFields = {};
|
||||
const allowedFields = [
|
||||
'title', 'description', 'cost', 'category', 'icon',
|
||||
'availableFor', 'ageRestriction', 'availability',
|
||||
'timeRestrictions', 'requiresApproval', 'isInstant',
|
||||
'tags', 'instructions', 'isActive', 'isPopular', 'sortOrder'
|
||||
];
|
||||
|
||||
// Nur erlaubte Felder aktualisieren
|
||||
allowedFields.forEach(field => {
|
||||
if (req.body[field] !== undefined) {
|
||||
updateFields[field] = req.body[field];
|
||||
}
|
||||
});
|
||||
|
||||
const reward = await Reward.findOneAndUpdate(
|
||||
{ _id: req.params.id, createdBy: req.user.id },
|
||||
{ $set: updateFields },
|
||||
{ new: true, runValidators: true }
|
||||
);
|
||||
|
||||
if (!reward) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Belohnung nicht gefunden'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Belohnung "${reward.title}" wurde erfolgreich aktualisiert! ✅`,
|
||||
reward
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Belohnung-Update-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Aktualisieren der Belohnung'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/rewards/:id/redeem
|
||||
// @desc Belohnung einlösen (von Kind)
|
||||
// @access Private
|
||||
router.post('/:id/redeem', [
|
||||
auth,
|
||||
body('childId')
|
||||
.isMongoId()
|
||||
.withMessage('Ungültige Kind-ID')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
// Validierungsfehler prüfen
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validierungsfehler',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { childId } = req.body;
|
||||
const rewardId = req.params.id;
|
||||
|
||||
// Prüfen ob Kind zu diesem Elternteil gehört
|
||||
const child = await Child.findOne({
|
||||
_id: childId,
|
||||
parent: req.user.id,
|
||||
isActive: true
|
||||
});
|
||||
|
||||
if (!child) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Kind nicht gefunden'
|
||||
});
|
||||
}
|
||||
|
||||
// Belohnung abrufen
|
||||
const reward = await Reward.findOne({
|
||||
_id: rewardId,
|
||||
createdBy: req.user.id,
|
||||
isActive: true
|
||||
});
|
||||
|
||||
if (!reward) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Belohnung nicht gefunden'
|
||||
});
|
||||
}
|
||||
|
||||
// Verfügbarkeit prüfen
|
||||
if (!reward.isAvailableFor(child)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Diese Belohnung ist für dieses Kind nicht verfügbar'
|
||||
});
|
||||
}
|
||||
|
||||
// Punkte prüfen
|
||||
if (child.points.available < reward.cost) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `Nicht genügend Punkte! Du brauchst ${reward.cost} Punkte, hast aber nur ${child.points.available}.`
|
||||
});
|
||||
}
|
||||
|
||||
// Belohnung einlösen
|
||||
const request = await reward.redeem(childId);
|
||||
|
||||
const message = reward.requiresApproval
|
||||
? `Super! ${child.name} möchte "${reward.title}" einlösen. Warte auf die Bestätigung! 🎁`
|
||||
: `Glückwunsch! ${child.name} hat "${reward.title}" erfolgreich eingelöst! 🎉`;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message,
|
||||
request: {
|
||||
id: request._id,
|
||||
status: request.status,
|
||||
pointsSpent: request.pointsSpent,
|
||||
requestedAt: request.requestedAt
|
||||
},
|
||||
child: {
|
||||
id: child._id,
|
||||
name: child.name,
|
||||
points: {
|
||||
available: child.points.available - reward.cost,
|
||||
total: child.points.total,
|
||||
spent: child.points.spent + reward.cost
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Belohnung-Einlösungs-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || 'Server-Fehler beim Einlösen der Belohnung'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route DELETE /api/rewards/:id
|
||||
// @desc Belohnung löschen (soft delete)
|
||||
// @access Private
|
||||
router.delete('/:id', auth, async (req, res) => {
|
||||
try {
|
||||
const reward = await Reward.findOneAndUpdate(
|
||||
{ _id: req.params.id, createdBy: req.user.id },
|
||||
{ $set: { isActive: false } },
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
if (!reward) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Belohnung nicht gefunden'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Belohnung "${reward.title}" wurde gelöscht`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Belohnung-Löschungs-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Löschen der Belohnung'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
module.exports = router;
|
||||
561
routes/tasks.js
Normal file
561
routes/tasks.js
Normal file
@ -0,0 +1,561 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const Task = require('../models/Task');
|
||||
const Child = require('../models/Child');
|
||||
const { auth } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// @route GET /api/tasks
|
||||
// @desc Alle Aufgaben des angemeldeten Elternteils abrufen
|
||||
// @access Private
|
||||
router.get('/', auth, async (req, res) => {
|
||||
try {
|
||||
const { status, childId, date, category } = req.query;
|
||||
|
||||
// Filter aufbauen
|
||||
const filter = { createdBy: req.user.id, isActive: true };
|
||||
|
||||
if (status) filter.status = status;
|
||||
if (childId) filter.assignedTo = childId;
|
||||
if (category) filter.category = category;
|
||||
|
||||
// Datum-Filter
|
||||
if (date) {
|
||||
const targetDate = new Date(date);
|
||||
targetDate.setHours(0, 0, 0, 0);
|
||||
const nextDay = new Date(targetDate);
|
||||
nextDay.setDate(nextDay.getDate() + 1);
|
||||
|
||||
filter.dueDate = { $gte: targetDate, $lt: nextDay };
|
||||
}
|
||||
|
||||
const tasks = await Task.find(filter)
|
||||
.populate('assignedTo', 'name avatar age')
|
||||
.populate('approvedBy', 'parentName')
|
||||
.sort({ dueDate: 1, priority: -1, createdAt: -1 });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
count: tasks.length,
|
||||
tasks
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Aufgaben-Abruf-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Abrufen der Aufgaben'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/tasks/child/:childId
|
||||
// @desc Aufgaben für ein bestimmtes Kind abrufen (Kinder-Ansicht)
|
||||
// @access Private
|
||||
router.get('/child/:childId', auth, async (req, res) => {
|
||||
try {
|
||||
const { status = 'pending', date } = req.query;
|
||||
const childId = req.params.childId;
|
||||
|
||||
// Prüfen ob Kind zu diesem Elternteil gehört
|
||||
const child = await Child.findOne({
|
||||
_id: childId,
|
||||
parent: req.user.id,
|
||||
isActive: true
|
||||
});
|
||||
|
||||
if (!child) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Kind nicht gefunden'
|
||||
});
|
||||
}
|
||||
|
||||
let tasks;
|
||||
|
||||
if (date === 'today') {
|
||||
// Heutige Aufgaben
|
||||
tasks = await Task.findTodayTasks(childId);
|
||||
} else {
|
||||
// Alle Aufgaben mit Filter
|
||||
const filter = {
|
||||
assignedTo: childId,
|
||||
isActive: true
|
||||
};
|
||||
|
||||
if (status !== 'all') {
|
||||
filter.status = status;
|
||||
}
|
||||
|
||||
tasks = await Task.find(filter)
|
||||
.sort({ dueDate: 1, priority: -1, createdAt: -1 });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
child: {
|
||||
id: child._id,
|
||||
name: child.name,
|
||||
avatar: child.avatar,
|
||||
points: child.points
|
||||
},
|
||||
count: tasks.length,
|
||||
tasks
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Kind-Aufgaben-Abruf-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Abrufen der Aufgaben'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/tasks/:id
|
||||
// @desc Einzelne Aufgabe abrufen
|
||||
// @access Private
|
||||
router.get('/:id', auth, async (req, res) => {
|
||||
try {
|
||||
const task = await Task.findOne({
|
||||
_id: req.params.id,
|
||||
createdBy: req.user.id,
|
||||
isActive: true
|
||||
})
|
||||
.populate('assignedTo', 'name avatar age')
|
||||
.populate('approvedBy', 'parentName');
|
||||
|
||||
if (!task) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Aufgabe nicht gefunden'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
task
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Aufgabe-Abruf-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Abrufen der Aufgabe'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/tasks
|
||||
// @desc Neue Aufgabe erstellen
|
||||
// @access Private
|
||||
router.post('/', [
|
||||
auth,
|
||||
body('title')
|
||||
.trim()
|
||||
.isLength({ min: 3, max: 100 })
|
||||
.withMessage('Titel muss zwischen 3 und 100 Zeichen lang sein'),
|
||||
body('assignedTo')
|
||||
.isMongoId()
|
||||
.withMessage('Ungültige Kind-ID'),
|
||||
body('dueDate')
|
||||
.isISO8601()
|
||||
.withMessage('Ungültiges Fälligkeitsdatum'),
|
||||
body('points')
|
||||
.isInt({ min: 1, max: 500 })
|
||||
.withMessage('Punkte müssen zwischen 1 und 500 liegen'),
|
||||
body('category')
|
||||
.optional()
|
||||
.isIn(['household', 'personal', 'homework', 'chores', 'hygiene', 'pets', 'garden', 'helping', 'learning', 'exercise', 'other'])
|
||||
.withMessage('Ungültige Kategorie'),
|
||||
body('difficulty')
|
||||
.optional()
|
||||
.isIn(['easy', 'medium', 'hard'])
|
||||
.withMessage('Ungültige Schwierigkeit'),
|
||||
body('priority')
|
||||
.optional()
|
||||
.isIn(['low', 'normal', 'high', 'urgent'])
|
||||
.withMessage('Ungültige Priorität')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
// Validierungsfehler prüfen
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validierungsfehler',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
title, description, assignedTo, dueDate, points,
|
||||
category, difficulty, priority, icon, estimatedTime,
|
||||
recurring, tags, notes
|
||||
} = req.body;
|
||||
|
||||
// Prüfen ob Kind zu diesem Elternteil gehört
|
||||
const child = await Child.findOne({
|
||||
_id: assignedTo,
|
||||
parent: req.user.id,
|
||||
isActive: true
|
||||
});
|
||||
|
||||
if (!child) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Kind nicht gefunden oder gehört nicht zu diesem Account'
|
||||
});
|
||||
}
|
||||
|
||||
// Neue Aufgabe erstellen
|
||||
const task = new Task({
|
||||
title: title.trim(),
|
||||
description: description?.trim(),
|
||||
assignedTo,
|
||||
createdBy: req.user.id,
|
||||
dueDate: new Date(dueDate),
|
||||
points,
|
||||
category: category || 'household',
|
||||
difficulty: difficulty || 'easy',
|
||||
priority: priority || 'normal',
|
||||
icon: icon || 'task',
|
||||
estimatedTime,
|
||||
recurring: recurring || { isRecurring: false },
|
||||
tags: tags || [],
|
||||
notes: notes?.trim()
|
||||
});
|
||||
|
||||
await task.save();
|
||||
|
||||
// Aufgabe mit Kind-Daten laden
|
||||
await task.populate('assignedTo', 'name avatar age');
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: `Neue Aufgabe "${title}" wurde für ${child.name} erstellt! 📝`,
|
||||
task
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Aufgabe-Erstellungs-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Erstellen der Aufgabe'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route PUT /api/tasks/:id
|
||||
// @desc Aufgabe aktualisieren
|
||||
// @access Private
|
||||
router.put('/:id', [
|
||||
auth,
|
||||
body('title')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ min: 3, max: 100 })
|
||||
.withMessage('Titel muss zwischen 3 und 100 Zeichen lang sein'),
|
||||
body('points')
|
||||
.optional()
|
||||
.isInt({ min: 1, max: 500 })
|
||||
.withMessage('Punkte müssen zwischen 1 und 500 liegen')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
// Validierungsfehler prüfen
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validierungsfehler',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const updateFields = {};
|
||||
const allowedFields = [
|
||||
'title', 'description', 'dueDate', 'points', 'category',
|
||||
'difficulty', 'priority', 'icon', 'estimatedTime', 'tags', 'notes'
|
||||
];
|
||||
|
||||
// Nur erlaubte Felder aktualisieren
|
||||
allowedFields.forEach(field => {
|
||||
if (req.body[field] !== undefined) {
|
||||
updateFields[field] = req.body[field];
|
||||
}
|
||||
});
|
||||
|
||||
const task = await Task.findOneAndUpdate(
|
||||
{
|
||||
_id: req.params.id,
|
||||
createdBy: req.user.id,
|
||||
isActive: true,
|
||||
status: { $in: ['pending', 'completed'] } // Nur nicht-genehmigte Aufgaben änderbar
|
||||
},
|
||||
{ $set: updateFields },
|
||||
{ new: true, runValidators: true }
|
||||
).populate('assignedTo', 'name avatar age');
|
||||
|
||||
if (!task) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Aufgabe nicht gefunden oder kann nicht bearbeitet werden'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Aufgabe "${task.title}" wurde erfolgreich aktualisiert! ✅`,
|
||||
task
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Aufgabe-Update-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Aktualisieren der Aufgabe'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/tasks/:id/complete
|
||||
// @desc Aufgabe als erledigt markieren (von Kind)
|
||||
// @access Private
|
||||
router.post('/:id/complete', auth, async (req, res) => {
|
||||
try {
|
||||
const task = await Task.findOne({
|
||||
_id: req.params.id,
|
||||
isActive: true,
|
||||
status: 'pending'
|
||||
}).populate('assignedTo', 'name avatar');
|
||||
|
||||
if (!task) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Aufgabe nicht gefunden oder bereits erledigt'
|
||||
});
|
||||
}
|
||||
|
||||
// Prüfen ob Kind zu diesem Elternteil gehört
|
||||
const child = await Child.findOne({
|
||||
_id: task.assignedTo._id,
|
||||
parent: req.user.id,
|
||||
isActive: true
|
||||
});
|
||||
|
||||
if (!child) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Keine Berechtigung für diese Aufgabe'
|
||||
});
|
||||
}
|
||||
|
||||
await task.markCompleted();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Super! "${task.title}" wurde als erledigt markiert! 🎉 Warte auf die Bestätigung deiner Eltern.`,
|
||||
task: {
|
||||
id: task._id,
|
||||
title: task.title,
|
||||
status: task.status,
|
||||
points: task.points,
|
||||
completedAt: task.completedAt
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Aufgabe-Abschluss-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Markieren der Aufgabe als erledigt'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/tasks/:id/approve
|
||||
// @desc Aufgabe genehmigen (von Eltern)
|
||||
// @access Private
|
||||
router.post('/:id/approve', auth, async (req, res) => {
|
||||
try {
|
||||
const task = await Task.findOne({
|
||||
_id: req.params.id,
|
||||
createdBy: req.user.id,
|
||||
isActive: true,
|
||||
status: 'completed'
|
||||
}).populate('assignedTo', 'name avatar');
|
||||
|
||||
if (!task) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Aufgabe nicht gefunden oder nicht zur Genehmigung bereit'
|
||||
});
|
||||
}
|
||||
|
||||
await task.approve(req.user.id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Aufgabe "${task.title}" wurde genehmigt! ${task.assignedTo.name} erhält ${task.points} Punkte! 🌟`,
|
||||
task: {
|
||||
id: task._id,
|
||||
title: task.title,
|
||||
status: task.status,
|
||||
points: task.points,
|
||||
approvedAt: task.approvedAt,
|
||||
child: task.assignedTo
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Aufgabe-Genehmigungs-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Genehmigen der Aufgabe'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/tasks/:id/reject
|
||||
// @desc Aufgabe ablehnen (von Eltern)
|
||||
// @access Private
|
||||
router.post('/:id/reject', [
|
||||
auth,
|
||||
body('reason')
|
||||
.trim()
|
||||
.isLength({ min: 3, max: 200 })
|
||||
.withMessage('Ablehnungsgrund muss zwischen 3 und 200 Zeichen lang sein')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
// Validierungsfehler prüfen
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validierungsfehler',
|
||||
errors: errors.array()
|
||||
});
|
||||
}
|
||||
|
||||
const { reason } = req.body;
|
||||
|
||||
const task = await Task.findOne({
|
||||
_id: req.params.id,
|
||||
createdBy: req.user.id,
|
||||
isActive: true,
|
||||
status: 'completed'
|
||||
}).populate('assignedTo', 'name avatar');
|
||||
|
||||
if (!task) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Aufgabe nicht gefunden oder nicht zur Ablehnung bereit'
|
||||
});
|
||||
}
|
||||
|
||||
await task.reject(reason.trim(), req.user.id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Aufgabe "${task.title}" wurde abgelehnt. ${task.assignedTo.name} kann es nochmal versuchen.`,
|
||||
task: {
|
||||
id: task._id,
|
||||
title: task.title,
|
||||
status: task.status,
|
||||
rejectionReason: task.rejectionReason,
|
||||
child: task.assignedTo
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Aufgabe-Ablehnungs-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Ablehnen der Aufgabe'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route DELETE /api/tasks/:id
|
||||
// @desc Aufgabe löschen (soft delete)
|
||||
// @access Private
|
||||
router.delete('/:id', auth, async (req, res) => {
|
||||
try {
|
||||
const task = await Task.findOneAndUpdate(
|
||||
{
|
||||
_id: req.params.id,
|
||||
createdBy: req.user.id,
|
||||
isActive: true,
|
||||
status: { $in: ['pending', 'rejected'] } // Nur nicht-erledigte Aufgaben löschbar
|
||||
},
|
||||
{ $set: { isActive: false } },
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
if (!task) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Aufgabe nicht gefunden oder kann nicht gelöscht werden'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Aufgabe "${task.title}" wurde gelöscht`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Aufgabe-Löschungs-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Löschen der Aufgabe'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/tasks/stats/overview
|
||||
// @desc Aufgaben-Statistiken für Dashboard
|
||||
// @access Private
|
||||
router.get('/stats/overview', auth, async (req, res) => {
|
||||
try {
|
||||
const [totalTasks, pendingTasks, completedTasks, approvedTasks, overdueTasks] = await Promise.all([
|
||||
Task.countDocuments({ createdBy: req.user.id, isActive: true }),
|
||||
Task.countDocuments({ createdBy: req.user.id, status: 'pending', isActive: true }),
|
||||
Task.countDocuments({ createdBy: req.user.id, status: 'completed', isActive: true }),
|
||||
Task.countDocuments({ createdBy: req.user.id, status: 'approved', isActive: true }),
|
||||
Task.countDocuments({
|
||||
createdBy: req.user.id,
|
||||
status: 'pending',
|
||||
dueDate: { $lt: new Date() },
|
||||
isActive: true
|
||||
})
|
||||
]);
|
||||
|
||||
// Heutige Aufgaben
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
const todayTasks = await Task.countDocuments({
|
||||
createdBy: req.user.id,
|
||||
dueDate: { $gte: today, $lt: tomorrow },
|
||||
isActive: true
|
||||
});
|
||||
|
||||
const stats = {
|
||||
total: totalTasks,
|
||||
pending: pendingTasks,
|
||||
completed: completedTasks,
|
||||
approved: approvedTasks,
|
||||
overdue: overdueTasks,
|
||||
today: todayTasks,
|
||||
completionRate: totalTasks > 0 ? Math.round((approvedTasks / totalTasks) * 100) : 0
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
stats
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Aufgaben-Statistik-Fehler:', error.message);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Server-Fehler beim Abrufen der Statistiken'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
63
server.js
Normal file
63
server.js
Normal file
@ -0,0 +1,63 @@
|
||||
const express = require('express');
|
||||
const mongoose = require('mongoose');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
require('dotenv').config();
|
||||
|
||||
const app = express();
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// MongoDB Verbindung
|
||||
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/familien-held';
|
||||
|
||||
mongoose.connect(MONGODB_URI, {
|
||||
useNewUrlParser: true,
|
||||
useUnifiedTopology: true,
|
||||
})
|
||||
.then(() => console.log('✅ MongoDB verbunden'))
|
||||
.catch(err => console.error('❌ MongoDB Verbindungsfehler:', err));
|
||||
|
||||
// API Routes
|
||||
app.use('/api/auth', require('./routes/auth'));
|
||||
app.use('/api/child-auth', require('./routes/childAuth'));
|
||||
app.use('/api/families', require('./routes/families'));
|
||||
app.use('/api/children', require('./routes/children'));
|
||||
app.use('/api/tasks', require('./routes/tasks'));
|
||||
app.use('/api/rewards', require('./routes/rewards'));
|
||||
app.use('/api/public', require('./routes/public'));
|
||||
|
||||
// Serve static files from React app in production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
app.use(express.static(path.join(__dirname, 'client/build')));
|
||||
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'client/build', 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
// Basis Route für Entwicklung
|
||||
app.get('/', (req, res) => {
|
||||
res.json({
|
||||
message: '🎉 Familien-Held API läuft!',
|
||||
version: '1.0.0',
|
||||
endpoints: [
|
||||
'/api/auth - Eltern-Authentifizierung',
|
||||
'/api/child-auth - Kinder-Authentifizierung',
|
||||
'/api/families - Familien-Management',
|
||||
'/api/children - Kinder-Profile',
|
||||
'/api/tasks - Aufgaben-Verwaltung',
|
||||
'/api/rewards - Belohnungs-System'
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 5000;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🚀 Server läuft auf Port ${PORT}`);
|
||||
console.log(`📱 Familien-Held API bereit!`);
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
Loading…
x
Reference in New Issue
Block a user