22 Commits

Author SHA1 Message Date
Torsten Schulz (local)
8bd05e4e38 Fügt Unterstützung für parallele Entwicklungsumgebungen hinzu und aktualisiert die Benutzeroberfläche. Neue Routen und Komponenten für Trainingsstatistiken implementiert. Fehlerbehebungen und Verbesserungen in der Benutzeroberfläche vorgenommen. 2025-08-22 15:47:16 +02:00
Torsten Schulz
e827964688 Fixed multiple bugs 2025-07-17 13:56:34 +02:00
Torsten Schulz
353b8386ee Implement JWT authentication and user token management 2025-07-17 11:47:37 +02:00
Torsten Schulz
ad2ab3cae8 Fixed schedule PDF 2025-07-16 17:15:19 +02:00
Torsten Schulz
f5deb343a8 Merge branch 'main' into tournament 2025-07-16 14:43:00 +02:00
Torsten Schulz
4122868ab0 finished tournaments 2025-07-16 14:29:34 +02:00
Torsten Schulz
eba160c43d fix 2025-07-15 18:19:30 +02:00
Torsten Schulz
39089a70d3 fix 2025-07-15 18:17:02 +02:00
Torsten Schulz
d0544da1ba fix 2025-07-15 18:16:08 +02:00
Torsten Schulz
b6dd39dda3 fixed font size 2025-07-15 18:12:31 +02:00
Torsten Schulz
f3a4159536 font size change for pdf 2025-07-15 18:08:17 +02:00
Torsten Schulz
69b4302e23 some enhancements for tournaments 2025-07-15 18:06:07 +02:00
Torsten Schulz
68725af630 Fixed UTF8 import 2025-07-15 16:22:14 +02:00
Torsten Schulz
f753d45e17 Fix for address problem 2025-07-15 16:14:43 +02:00
Torsten Schulz
549147cfb3 Fixed used variable in class 2025-07-15 16:00:45 +02:00
Torsten Schulz
81cf94cebc Fixed bug 2025-07-15 15:57:30 +02:00
Torsten Schulz
9f17f2399a Moved addAddress to PDFGenerator class 2025-07-15 15:56:45 +02:00
Torsten Schulz
9ba39f9f47 Added missing addAddress method 2025-07-15 15:46:43 +02:00
Torsten Schulz
f935c72f56 Diary fix 2025-03-17 23:42:22 +01:00
Torsten Schulz
f29185dd33 Merge branch 'main' into tournament 2025-03-13 16:24:08 +01:00
Torsten Schulz
821f9d24f5 First steps for tournament 2025-03-13 16:19:07 +01:00
Torsten Schulz
df41720b50 started tournament implementation 2025-02-24 16:21:43 +01:00
59 changed files with 7970 additions and 818 deletions

139
backend/README_CLEANUP.md Normal file
View File

@@ -0,0 +1,139 @@
# MySQL Keys Cleanup - Anleitung
## Problem
Der MySQL-Server hat ein Limit von maximal 64 Keys pro Tabelle. Sequelize erstellt automatisch viele INDEX für verschiedene Felder, was dieses Limit überschreitet.
## Wichtige Erkenntnis ⚠️
**Alle INDEX-Namen in den ursprünglichen Scripts existieren nicht!** Das bedeutet, dass die Tabellennamen oder INDEX-Namen nicht mit der Realität übereinstimmen.
## Lösung
Das Cleanup-Script entfernt überflüssige INDEX, behält aber die essentiellen Keys (PRIMARY KEY, UNIQUE Keys).
## Verfügbare Scripts (nach Priorität sortiert)
### **1. `checkRealIndexes.sql`** - Echte INDEX überprüfen ⭐ EMPFOHLEN ZUERST
- Zeigt alle vorhandenen Tabellen in der Datenbank an
- Zeigt alle **echten** INDEX und Keys an
- Zeigt die Anzahl der Keys pro Tabelle an
- **Verwenden Sie dieses Script zuerst, um die echten INDEX-Namen zu sehen!**
### **2. `cleanupKeysMinimal.sql`** - Minimales Cleanup ⭐ EMPFOHLEN
- Zeigt alle INDEX pro Tabelle mit `SHOW INDEX`
- Entfernt alle überflüssigen INDEX
- Behält nur PRIMARY KEY und UNIQUE Keys
- **Sicherste Option für die Bereinigung**
### **3. `cleanupKeysReal.sql`** - Cleanup mit echten Namen
- Zeigt alle vorhandenen INDEX vor und nach der Bereinigung
- Entfernt nur INDEX, die tatsächlich existieren
- Detaillierte Informationen über den Cleanup-Prozess
### **4. `checkTableNames.sql`** - Tabellennamen überprüfen
- Zeigt alle vorhandenen Tabellen und INDEX an
- Gute Übersicht über die Datenbankstruktur
### **5. `cleanupKeysSmart.sql`** - Intelligentes Cleanup (veraltet)
- Überprüft zuerst alle vorhandenen Tabellen und INDEX
- **Problem**: Verwendet falsche INDEX-Namen
### **6. `cleanupKeysCorrected.sql`** - Korrigierte Tabellennamen (veraltet)
- Verwendet die wahrscheinlich korrekten Tabellennamen
- **Problem**: Verwendet falsche INDEX-Namen
### **7. `cleanupKeys.sql` & `cleanupKeysSimple.sql`** (veraltet)
- Tabellennamen und INDEX-Namen könnten falsch sein
- **Nicht mehr empfohlen**
## Empfohlener Ablauf
### **Schritt 1: Echte INDEX überprüfen**
```bash
# Verbindung zur MySQL-Datenbank
mysql -u [username] -p [database_name]
# Script ausführen
source /path/to/checkRealIndexes.sql
```
### **Schritt 2: Minimales Cleanup durchführen**
```bash
# Script ausführen
source /path/to/cleanupKeysMinimal.sql
```
### **Alternative Ausführungsmethoden**
#### Über MySQL Workbench
1. MySQL Workbench öffnen
2. Verbindung zur Datenbank herstellen
3. File -> Open SQL Script -> Script auswählen
4. Execute (Blitz-Symbol) klicken
#### Über phpMyAdmin
1. phpMyAdmin öffnen
2. Datenbank `trainingsdiary` auswählen
3. SQL-Tab öffnen
4. Inhalt des Scripts einfügen
5. Go klicken
## Nach der Ausführung
1. **Keys überprüfen:**
```sql
SELECT COUNT(*) as total_keys
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary';
```
2. **Keys pro Tabelle anzeigen:**
```sql
SELECT TABLE_NAME, COUNT(*) as key_count
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary'
GROUP BY TABLE_NAME
ORDER BY key_count DESC;
```
3. **Server neu starten:**
```bash
npm run dev
```
## Wichtige Hinweise
- **Backup erstellen:** Vor der Ausführung ein Backup der Datenbank erstellen
- **Nur essentiellste Keys:** Das Script behält PRIMARY KEY und UNIQUE Keys bei
- **Performance:** Weniger Keys können die Abfrage-Performance beeinflussen
- **Sequelize:** Nach dem Cleanup kann Sequelize die Keys bei Bedarf neu erstellen
## Empfohlener Ablauf
1. **`checkRealIndexes.sql` ausführen** - Echte INDEX-Namen sehen
2. **`cleanupKeysMinimal.sql` ausführen** - Minimales Cleanup
3. **Ergebnisse überprüfen** - Keys zählen
4. **Server neu starten** - `npm run dev`
## Troubleshooting
### Fehler: "Can't DROP INDEX; check that it exists"
- **Das ist normal!** Alle INDEX-Namen in den ursprünglichen Scripts existieren nicht
- Verwenden Sie `checkRealIndexes.sql` um die echten INDEX-Namen zu sehen
- Verwenden Sie `cleanupKeysMinimal.sql` für das Cleanup
### Fehler: "Table doesn't exist"
- Verwenden Sie `checkRealIndexes.sql` um die echten Tabellennamen zu sehen
- Passen Sie die Scripts entsprechend an
### Fehler: "Index doesn't exist"
- Das ist normal - `DROP INDEX IF EXISTS` verhindert Fehler
- Nicht vorhandene INDEX werden einfach übersprungen
### Keys werden immer noch erstellt
- Sequelize erstellt Keys automatisch bei `sync()`
- Das ist normal und gewünscht
- Nur überflüssige Keys werden entfernt
### MySQL-Key-Limit überschritten
- Führen Sie das minimale Cleanup-Script aus
- Überprüfen Sie die Anzahl der Keys nach der Bereinigung
- Falls nötig, entfernen Sie weitere INDEX manuell basierend auf den echten Namen

View File

@@ -0,0 +1,46 @@
-- Script zum Überprüfen der echten INDEX-Namen
USE trainingsdiary;
-- Alle vorhandenen Tabellen anzeigen
SELECT '=== VORHANDENE TABELLEN ===' as info;
SHOW TABLES;
-- Alle vorhandenen INDEX und Keys anzeigen (mit echten Namen)
SELECT '=== ALLE INDEX UND KEYS ===' as info;
SELECT
TABLE_NAME,
INDEX_NAME,
COLUMN_NAME,
NON_UNIQUE,
SEQ_IN_INDEX
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary'
ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX;
-- Anzahl der Keys pro Tabelle
SELECT '=== KEYS PRO TABELLE ===' as info;
SELECT
TABLE_NAME,
COUNT(*) as key_count
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary'
GROUP BY TABLE_NAME
ORDER BY key_count DESC;
-- Gesamtanzahl der Keys
SELECT '=== GESAMTANZAHL KEYS ===' as info;
SELECT
COUNT(*) as total_keys
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary';
-- Nur die Tabellen mit den meisten Keys anzeigen (Problem-Tabellen)
SELECT '=== PROBLEM-TABELLEN (MEHR ALS 10 KEYS) ===' as info;
SELECT
TABLE_NAME,
COUNT(*) as key_count
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary'
GROUP BY TABLE_NAME
HAVING COUNT(*) > 10
ORDER BY key_count DESC;

View File

@@ -0,0 +1,35 @@
-- Script zum Überprüfen der echten Tabellennamen
USE trainingsdiary;
-- Alle Tabellen in der Datenbank anzeigen
SHOW TABLES;
-- Detaillierte Informationen über alle Tabellen
SELECT
TABLE_NAME,
TABLE_ROWS,
DATA_LENGTH,
INDEX_LENGTH
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = 'trainingsdiary'
ORDER BY TABLE_NAME;
-- Alle INDEX und Keys pro Tabelle anzeigen
SELECT
TABLE_NAME,
INDEX_NAME,
COLUMN_NAME,
NON_UNIQUE,
SEQ_IN_INDEX
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary'
ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX;
-- Anzahl der Keys pro Tabelle
SELECT
TABLE_NAME,
COUNT(*) as key_count
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary'
GROUP BY TABLE_NAME
ORDER BY key_count DESC;

185
backend/cleanupKeys.sql Normal file
View File

@@ -0,0 +1,185 @@
-- Cleanup-Script für MySQL Keys
-- Führt dieses Script in der MySQL-Datenbank aus, um überflüssige Keys zu entfernen
USE trainingsdiary;
-- 1. Alle nicht-essentiellen Keys aus der member-Tabelle entfernen
-- (behält nur PRIMARY KEY und UNIQUE Keys für kritische Felder)
-- Überflüssige INDEX entfernen (falls vorhanden)
DROP INDEX IF EXISTS idx_member_hashed_id ON member;
DROP INDEX IF EXISTS idx_member_first_name ON member;
DROP INDEX IF EXISTS idx_member_last_name ON member;
DROP INDEX IF EXISTS idx_member_birth_date ON member;
DROP INDEX IF EXISTS idx_member_active ON member;
DROP INDEX IF EXISTS idx_member_created_at ON member;
DROP INDEX IF EXISTS idx_member_updated_at ON member;
-- 2. Überflüssige Keys aus anderen Tabellen entfernen
-- User-Tabelle
DROP INDEX IF EXISTS idx_user_email ON user;
DROP INDEX IF EXISTS idx_user_created_at ON user;
DROP INDEX IF EXISTS idx_user_updated_at ON user;
-- Clubs-Tabelle
DROP INDEX IF EXISTS idx_clubs_name ON clubs;
DROP INDEX IF EXISTS idx_clubs_created_at ON clubs;
DROP INDEX IF EXISTS idx_clubs_updated_at ON clubs;
-- User_Club-Tabelle
DROP INDEX IF EXISTS idx_user_club_approved ON user_club;
DROP INDEX IF EXISTS idx_user_club_created_at ON user_club;
DROP INDEX IF EXISTS idx_user_club_updated_at ON user_club;
-- Log-Tabelle
DROP INDEX IF EXISTS idx_log_activity ON log;
DROP INDEX IF EXISTS idx_log_created_at ON log;
DROP INDEX IF EXISTS idx_log_updated_at ON log;
-- Diary_Dates-Tabelle
DROP INDEX IF EXISTS idx_diary_dates_date ON diary_dates;
DROP INDEX IF EXISTS idx_diary_dates_created_at ON diary_dates;
DROP INDEX IF EXISTS idx_diary_dates_updated_at ON diary_dates;
-- Participants-Tabelle
DROP INDEX IF EXISTS idx_participant_created_at ON participants;
DROP INDEX IF EXISTS idx_participant_updated_at ON participants;
-- Activity-Tabelle
DROP INDEX IF EXISTS idx_activity_created_at ON activities;
DROP INDEX IF EXISTS idx_activity_updated_at ON activities;
-- Member_Note-Tabelle
DROP INDEX IF EXISTS idx_member_note_created_at ON member_note;
DROP INDEX IF EXISTS idx_member_note_updated_at ON member_note;
-- Diary_Note-Tabelle
DROP INDEX IF EXISTS idx_diary_note_created_at ON diary_note;
DROP INDEX IF EXISTS idx_diary_note_updated_at ON diary_note;
-- Diary_Tag-Tabelle
DROP INDEX IF EXISTS idx_diary_tag_created_at ON diary_tag;
DROP INDEX IF EXISTS idx_diary_tag_updated_at ON diary_tag;
-- Member_Diary_Tag-Tabelle
DROP INDEX IF EXISTS idx_member_diary_tag_created_at ON member_diary_tag;
DROP INDEX IF EXISTS idx_member_diary_tag_updated_at ON member_diary_tag;
-- Diary_Date_Tag-Tabelle
DROP INDEX IF EXISTS idx_diary_date_tag_created_at ON diary_date_tag;
DROP INDEX IF EXISTS idx_diary_date_tag_updated_at ON diary_date_tag;
-- Diary_Member_Note-Tabelle
DROP INDEX IF EXISTS idx_diary_member_note_created_at ON diary_member_note;
DROP INDEX IF EXISTS idx_diary_member_note_updated_at ON diary_member_note;
-- Predefined_Activity-Tabelle
DROP INDEX IF EXISTS idx_predefined_activity_created_at ON predefined_activities;
DROP INDEX IF EXISTS idx_predefined_activity_updated_at ON predefined_activities;
-- Diary_Date_Activity-Tabelle
DROP INDEX IF EXISTS idx_diary_date_activity_created_at ON diary_date_activity;
DROP INDEX IF EXISTS idx_diary_date_activity_updated_at ON diary_date_activity;
-- Match-Tabelle
DROP INDEX IF EXISTS idx_match_created_at ON match;
DROP INDEX IF EXISTS idx_match_updated_at ON match;
-- League-Tabelle
DROP INDEX IF EXISTS idx_league_created_at ON league;
DROP INDEX IF EXISTS idx_league_updated_at ON league;
-- Team-Tabelle
DROP INDEX IF EXISTS idx_team_created_at ON team;
DROP INDEX IF EXISTS idx_team_updated_at ON team;
-- Season-Tabelle
DROP INDEX IF EXISTS idx_season_created_at ON season;
DROP INDEX IF EXISTS idx_season_updated_at ON season;
-- Location-Tabelle
DROP INDEX IF EXISTS idx_location_created_at ON location;
DROP INDEX IF EXISTS idx_location_updated_at ON location;
-- Group-Tabelle
DROP INDEX IF EXISTS idx_group_created_at ON `group`;
DROP INDEX IF EXISTS idx_group_updated_at ON `group`;
-- Group_Activity-Tabelle
DROP INDEX IF EXISTS idx_group_activity_created_at ON group_activity;
DROP INDEX IF EXISTS idx_group_activity_updated_at ON group_activity;
-- Tournament-Tabelle
DROP INDEX IF EXISTS idx_tournament_created_at ON tournament;
DROP INDEX IF EXISTS idx_tournament_updated_at ON tournament;
-- Tournament_Group-Tabelle
DROP INDEX IF EXISTS idx_tournament_group_created_at ON tournament_group;
DROP INDEX IF EXISTS idx_tournament_group_updated_at ON tournament_group;
-- Tournament_Member-Tabelle
DROP INDEX IF EXISTS idx_tournament_member_created_at ON tournament_member;
DROP INDEX IF EXISTS idx_tournament_member_updated_at ON tournament_member;
-- Tournament_Match-Tabelle
DROP INDEX IF EXISTS idx_tournament_match_created_at ON tournament_match;
DROP INDEX IF EXISTS idx_tournament_match_updated_at ON tournament_match;
-- Tournament_Result-Tabelle
DROP INDEX IF EXISTS idx_tournament_result_created_at ON tournament_result;
DROP INDEX IF EXISTS idx_tournament_result_updated_at ON tournament_result;
-- Accident-Tabelle
DROP INDEX IF EXISTS idx_accident_created_at ON accident;
DROP INDEX IF EXISTS idx_accident_updated_at ON accident;
-- User_Token-Tabelle
DROP INDEX IF EXISTS idx_user_token_created_at ON UserToken;
DROP INDEX IF EXISTS idx_user_token_updated_at ON UserToken;
-- 3. Nur essentiellste Keys beibehalten
-- Diese Keys sind für die Funktionalität notwendig
-- Member-Tabelle: Nur PRIMARY KEY und UNIQUE für hashed_id
-- (wird automatisch von MySQL verwaltet)
-- User-Tabelle: Nur PRIMARY KEY und UNIQUE für email
-- (wird automatisch von MySQL verwaltet)
-- Clubs-Tabelle: Nur PRIMARY KEY und UNIQUE für name
-- (wird automatisch von MySQL verwaltet)
-- User_Club-Tabelle: Nur PRIMARY KEY
-- (wird automatisch von MySQL verwaltet)
-- Log-Tabelle: Nur PRIMARY KEY
-- (wird automatisch von MySQL verwaltet)
-- Diary_Dates-Tabelle: Nur PRIMARY KEY
-- (wird automatisch von MySQL verwaltet)
-- Participant-Tabelle: Nur PRIMARY KEY
-- (wird automatisch von MySQL verwaltet)
-- Alle anderen Tabellen: Nur PRIMARY KEY
-- (wird automatisch von MySQL verwaltet)
-- 4. Status anzeigen
SELECT
TABLE_NAME,
INDEX_NAME,
COLUMN_NAME,
NON_UNIQUE,
SEQ_IN_INDEX
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary'
ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX;
-- 5. Anzahl der Keys pro Tabelle anzeigen
SELECT
TABLE_NAME,
COUNT(*) as key_count
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary'
GROUP BY TABLE_NAME
ORDER BY key_count DESC;

View File

@@ -0,0 +1,123 @@
-- Aggressives Cleanup-Script - Entfernt alle überflüssigen INDEX
-- Behält nur PRIMARY KEY und UNIQUE constraints
USE trainingsdiary;
-- 1. Status vor dem aggressiven Cleanup
SELECT '=== STATUS VOR AGGRESSIVEM CLEANUP ===' as info;
SELECT
TABLE_NAME,
COUNT(*) as key_count
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary'
GROUP BY TABLE_NAME
ORDER BY key_count DESC;
-- 2. Alle INDEX der Problem-Tabellen anzeigen
SELECT '=== MEMBER TABELLE INDEX ===' as info;
SHOW INDEX FROM member;
SELECT '=== DIARY_TAGS TABELLE INDEX ===' as info;
SHOW INDEX FROM diary_tags;
SELECT '=== SEASON TABELLE INDEX ===' as info;
SHOW INDEX FROM season;
-- 3. Alle nicht-essentiellen INDEX entfernen
-- Behalte nur: PRIMARY KEY, UNIQUE constraints, FOREIGN KEY
-- Member-Tabelle: Alle INDEX außer PRIMARY und UNIQUE entfernen
SELECT '=== ENTFERNE ALLE ÜBERFLÜSSIGEN MEMBER INDEX ===' as info;
-- Alle INDEX außer PRIMARY entfernen (PRIMARY kann nicht gelöscht werden)
-- Verwende SHOW INDEX um die echten INDEX-Namen zu sehen
-- Dann entferne alle außer PRIMARY
-- Häufige überflüssige INDEX-Namen (alle außer PRIMARY)
DROP INDEX IF EXISTS member_hashed_id_unique ON member;
DROP INDEX IF EXISTS member_first_name_index ON member;
DROP INDEX IF EXISTS member_last_name_index ON member;
DROP INDEX IF EXISTS member_birth_date_index ON member;
DROP INDEX IF EXISTS member_active_index ON member;
DROP INDEX IF EXISTS member_created_at_index ON member;
DROP INDEX IF EXISTS member_updated_at_index ON member;
DROP INDEX IF EXISTS member_club_id_index ON member;
DROP INDEX IF EXISTS member_hashed_id_index ON member;
-- Alternative INDEX-Namen
DROP INDEX IF EXISTS idx_member_hashed_id ON member;
DROP INDEX IF EXISTS idx_member_first_name ON member;
DROP INDEX IF EXISTS idx_member_last_name ON member;
DROP INDEX IF EXISTS idx_member_birth_date ON member;
DROP INDEX IF EXISTS idx_member_active ON member;
DROP INDEX IF EXISTS idx_member_created_at ON member;
DROP INDEX IF EXISTS idx_member_updated_at ON member;
DROP INDEX IF EXISTS idx_member_club_id ON member;
-- Diary_Tags-Tabelle: Alle überflüssigen INDEX entfernen
SELECT '=== ENTFERNE ALLE ÜBERFLÜSSIGEN DIARY_TAGS INDEX ===' as info;
DROP INDEX IF EXISTS diary_tags_name_index ON diary_tags;
DROP INDEX IF EXISTS diary_tags_created_at_index ON diary_tags;
DROP INDEX IF EXISTS diary_tags_updated_at_index ON diary_tags;
DROP INDEX IF EXISTS diary_tags_club_id_index ON diary_tags;
-- Alternative INDEX-Namen
DROP INDEX IF EXISTS idx_diary_tags_name ON diary_tags;
DROP INDEX IF EXISTS idx_diary_tags_created_at ON diary_tags;
DROP INDEX IF EXISTS idx_diary_tags_updated_at ON diary_tags;
DROP INDEX IF EXISTS idx_diary_tags_club_id ON diary_tags;
-- Season-Tabelle: Alle überflüssigen INDEX entfernen
SELECT '=== ENTFERNE ALLE ÜBERFLÜSSIGEN SEASON INDEX ===' as info;
DROP INDEX IF EXISTS season_name_index ON season;
DROP INDEX IF EXISTS season_start_date_index ON season;
DROP INDEX IF EXISTS season_end_date_index ON season;
DROP INDEX IF EXISTS season_created_at_index ON season;
DROP INDEX IF EXISTS season_updated_at_index ON season;
DROP INDEX IF EXISTS season_club_id_index ON season;
-- Alternative INDEX-Namen
DROP INDEX IF EXISTS idx_season_name ON season;
DROP INDEX IF EXISTS idx_season_start_date ON season;
DROP INDEX IF EXISTS idx_season_end_date ON season;
DROP INDEX IF EXISTS idx_season_created_at ON season;
DROP INDEX IF EXISTS idx_season_updated_at ON season;
DROP INDEX IF EXISTS idx_season_club_id ON season;
-- 4. Status nach dem aggressiven Cleanup
SELECT '=== STATUS NACH AGGRESSIVEM CLEANUP ===' as info;
SELECT
TABLE_NAME,
COUNT(*) as key_count
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary'
GROUP BY TABLE_NAME
ORDER BY key_count DESC;
-- 5. Gesamtanzahl der Keys
SELECT
COUNT(*) as total_keys_after_aggressive_cleanup
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary';
-- 6. Ziel: Jede Tabelle sollte nur 2-5 Keys haben
SELECT '=== ZIEL: 2-5 KEYS PRO TABELLE ===' as info;
SELECT
TABLE_NAME,
COUNT(*) as key_count,
CASE
WHEN COUNT(*) <= 5 THEN '✅ OK'
WHEN COUNT(*) <= 10 THEN '⚠️ Zu viele'
ELSE '❌ Viel zu viele'
END as status
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary'
GROUP BY TABLE_NAME
ORDER BY key_count DESC;
-- 7. Zusammenfassung
SELECT '=== ZUSAMMENFASSUNG ===' as info;
SELECT
'Aggressives Cleanup abgeschlossen. Jede Tabelle sollte nur 2-5 Keys haben.' as message;

View File

@@ -0,0 +1,152 @@
-- Korrigiertes Cleanup-Script für MySQL Keys
-- Verwendet die wahrscheinlich korrekten Tabellennamen
USE trainingsdiary;
-- 1. Alle überflüssigen INDEX entfernen
-- Diese entfernen die meisten Keys, die das Limit überschreiten
-- User-Tabelle (wahrscheinlich 'user')
DROP INDEX IF EXISTS idx_user_email ON user;
DROP INDEX IF EXISTS idx_user_created_at ON user;
DROP INDEX IF EXISTS idx_user_updated_at ON user;
-- Clubs-Tabelle (wahrscheinlich 'clubs')
DROP INDEX IF EXISTS idx_clubs_name ON clubs;
DROP INDEX IF EXISTS idx_clubs_created_at ON clubs;
DROP INDEX IF EXISTS idx_clubs_updated_at ON clubs;
-- User_Club-Tabelle (wahrscheinlich 'user_club')
DROP INDEX IF EXISTS idx_user_club_approved ON user_club;
DROP INDEX IF EXISTS idx_user_club_created_at ON user_club;
DROP INDEX IF EXISTS idx_user_club_updated_at ON user_club;
-- Member-Tabelle (wahrscheinlich 'member')
DROP INDEX IF EXISTS idx_member_hashed_id ON member;
DROP INDEX IF EXISTS idx_member_first_name ON member;
DROP INDEX IF EXISTS idx_member_last_name ON member;
DROP INDEX IF EXISTS idx_member_birth_date ON member;
DROP INDEX IF EXISTS idx_member_active ON member;
DROP INDEX IF EXISTS idx_member_created_at ON member;
DROP INDEX IF EXISTS idx_member_updated_at ON member;
-- Log-Tabelle (wahrscheinlich 'log')
DROP INDEX IF EXISTS idx_log_activity ON log;
DROP INDEX IF EXISTS idx_log_created_at ON log;
DROP INDEX IF EXISTS idx_log_updated_at ON log;
-- Diary_Dates-Tabelle (wahrscheinlich 'diary_dates')
DROP INDEX IF EXISTS idx_diary_dates_date ON diary_dates;
DROP INDEX IF EXISTS idx_diary_dates_created_at ON diary_dates;
DROP INDEX IF EXISTS idx_diary_dates_updated_at ON diary_dates;
-- Participants-Tabelle (wahrscheinlich 'participants')
DROP INDEX IF EXISTS idx_participant_created_at ON participants;
DROP INDEX IF EXISTS idx_participant_updated_at ON participants;
-- Activities-Tabelle (wahrscheinlich 'activities')
DROP INDEX IF EXISTS idx_activity_created_at ON activities;
DROP INDEX IF EXISTS idx_activity_updated_at ON activities;
-- Member_Notes-Tabelle (wahrscheinlich 'member_notes')
DROP INDEX IF EXISTS idx_member_note_created_at ON member_notes;
DROP INDEX IF EXISTS idx_member_note_updated_at ON member_notes;
-- Diary_Notes-Tabelle (wahrscheinlich 'diary_notes')
DROP INDEX IF EXISTS idx_diary_note_created_at ON diary_notes;
DROP INDEX IF EXISTS idx_diary_note_updated_at ON diary_notes;
-- Diary_Tags-Tabelle (wahrscheinlich 'diary_tags')
DROP INDEX IF EXISTS idx_diary_tag_created_at ON diary_tags;
DROP INDEX IF EXISTS idx_diary_tag_updated_at ON diary_tags;
-- Member_Diary_Tags-Tabelle (wahrscheinlich 'member_diary_tags')
DROP INDEX IF EXISTS idx_member_diary_tag_created_at ON member_diary_tags;
DROP INDEX IF EXISTS idx_member_diary_tag_updated_at ON member_diary_tags;
-- Diary_Date_Tags-Tabelle (wahrscheinlich 'diary_date_tags')
DROP INDEX IF EXISTS idx_diary_date_tag_created_at ON diary_date_tags;
DROP INDEX IF EXISTS idx_diary_date_tag_updated_at ON diary_date_tags;
-- Diary_Member_Notes-Tabelle (wahrscheinlich 'diary_member_notes')
DROP INDEX IF EXISTS idx_diary_member_note_created_at ON diary_member_notes;
DROP INDEX IF EXISTS idx_diary_member_note_updated_at ON diary_member_notes;
-- Predefined_Activities-Tabelle (wahrscheinlich 'predefined_activities')
DROP INDEX IF EXISTS idx_predefined_activity_created_at ON predefined_activities;
DROP INDEX IF EXISTS idx_predefined_activity_updated_at ON predefined_activities;
-- Diary_Date_Activities-Tabelle (wahrscheinlich 'diary_date_activities')
DROP INDEX IF EXISTS idx_diary_date_activity_created_at ON diary_date_activities;
DROP INDEX IF EXISTS idx_diary_date_activity_updated_at ON diary_date_activities;
-- Matches-Tabelle (wahrscheinlich 'matches')
DROP INDEX IF EXISTS idx_match_created_at ON matches;
DROP INDEX IF EXISTS idx_match_updated_at ON matches;
-- Leagues-Tabelle (wahrscheinlich 'leagues')
DROP INDEX IF EXISTS idx_league_created_at ON leagues;
DROP INDEX IF EXISTS idx_league_updated_at ON leagues;
-- Teams-Tabelle (wahrscheinlich 'teams')
DROP INDEX IF EXISTS idx_team_created_at ON teams;
DROP INDEX IF EXISTS idx_team_updated_at ON teams;
-- Seasons-Tabelle (wahrscheinlich 'seasons')
DROP INDEX IF EXISTS idx_season_created_at ON seasons;
DROP INDEX IF EXISTS idx_season_updated_at ON seasons;
-- Locations-Tabelle (wahrscheinlich 'locations')
DROP INDEX IF EXISTS idx_location_created_at ON locations;
DROP INDEX IF EXISTS idx_location_updated_at ON locations;
-- Groups-Tabelle (wahrscheinlich 'groups')
DROP INDEX IF EXISTS idx_group_created_at ON `groups`;
DROP INDEX IF EXISTS idx_group_updated_at ON `groups`;
-- Group_Activities-Tabelle (wahrscheinlich 'group_activities')
DROP INDEX IF EXISTS idx_group_activity_created_at ON group_activities;
DROP INDEX IF EXISTS idx_group_activity_updated_at ON group_activities;
-- Tournaments-Tabelle (wahrscheinlich 'tournaments')
DROP INDEX IF EXISTS idx_tournament_created_at ON tournaments;
DROP INDEX IF EXISTS idx_tournament_updated_at ON tournaments;
-- Tournament_Groups-Tabelle (wahrscheinlich 'tournament_groups')
DROP INDEX IF EXISTS idx_tournament_group_created_at ON tournament_groups;
DROP INDEX IF EXISTS idx_tournament_group_updated_at ON tournament_groups;
-- Tournament_Members-Tabelle (wahrscheinlich 'tournament_members')
DROP INDEX IF EXISTS idx_tournament_member_created_at ON tournament_members;
DROP INDEX IF EXISTS idx_tournament_member_updated_at ON tournament_members;
-- Tournament_Matches-Tabelle (wahrscheinlich 'tournament_matches')
DROP INDEX IF EXISTS idx_tournament_match_created_at ON tournament_matches;
DROP INDEX IF EXISTS idx_tournament_match_updated_at ON tournament_matches;
-- Tournament_Results-Tabelle (wahrscheinlich 'tournament_results')
DROP INDEX IF EXISTS idx_tournament_result_created_at ON tournament_results;
DROP INDEX IF EXISTS idx_tournament_result_updated_at ON tournament_results;
-- Accidents-Tabelle (wahrscheinlich 'accidents')
DROP INDEX IF EXISTS idx_accident_created_at ON accidents;
DROP INDEX IF EXISTS idx_accident_updated_at ON accidents;
-- User_Tokens-Tabelle (wahrscheinlich 'user_tokens')
DROP INDEX IF EXISTS idx_user_token_created_at ON user_tokens;
DROP INDEX IF EXISTS idx_user_token_updated_at ON user_tokens;
-- 2. Status anzeigen
SELECT
TABLE_NAME,
COUNT(*) as key_count
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary'
GROUP BY TABLE_NAME
ORDER BY key_count DESC;
-- 3. Gesamtanzahl der Keys anzeigen
SELECT
COUNT(*) as total_keys
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary';

View File

@@ -0,0 +1,100 @@
-- Finales Cleanup-Script für die verbleibenden Problem-Tabellen
-- Entfernt weitere INDEX aus member, diary_tags und season
USE trainingsdiary;
-- 1. Status vor dem finalen Cleanup
SELECT '=== STATUS VOR FINALEM CLEANUP ===' as info;
SELECT
TABLE_NAME,
COUNT(*) as key_count
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary'
GROUP BY TABLE_NAME
ORDER BY key_count DESC;
-- 2. Alle INDEX der Problem-Tabellen anzeigen
SELECT '=== MEMBER TABELLE INDEX ===' as info;
SHOW INDEX FROM member;
SELECT '=== DIARY_TAGS TABELLE INDEX ===' as info;
SHOW INDEX FROM diary_tags;
SELECT '=== SEASON TABELLE INDEX ===' as info;
SHOW INDEX FROM season;
-- 3. Spezifische INDEX entfernen (basierend auf den echten Namen)
-- Diese INDEX sind wahrscheinlich überflüssig und können entfernt werden
-- Member-Tabelle: Weitere INDEX entfernen
SELECT '=== ENTFERNE WEITERE MEMBER INDEX ===' as info;
-- Versuche, INDEX zu entfernen, die wahrscheinlich überflüssig sind
-- (Diese Namen basieren auf typischen Sequelize-Konventionen)
-- Häufige überflüssige INDEX-Namen
DROP INDEX IF EXISTS member_hashed_id_unique ON member;
DROP INDEX IF EXISTS member_first_name_index ON member;
DROP INDEX IF EXISTS member_last_name_index ON member;
DROP INDEX IF EXISTS member_birth_date_index ON member;
DROP INDEX IF EXISTS member_active_index ON member;
DROP INDEX IF EXISTS member_created_at_index ON member;
DROP INDEX IF EXISTS member_updated_at_index ON member;
-- Alternative INDEX-Namen
DROP INDEX IF EXISTS idx_member_hashed_id ON member;
DROP INDEX IF EXISTS idx_member_first_name ON member;
DROP INDEX IF EXISTS idx_member_last_name ON member;
DROP INDEX IF EXISTS idx_member_birth_date ON member;
DROP INDEX IF EXISTS idx_member_active ON member;
DROP INDEX IF EXISTS idx_member_created_at ON member;
DROP INDEX IF EXISTS idx_member_updated_at ON member;
-- Diary_Tags-Tabelle: Weitere INDEX entfernen
SELECT '=== ENTFERNE WEITERE DIARY_TAGS INDEX ===' as info;
DROP INDEX IF EXISTS diary_tags_name_index ON diary_tags;
DROP INDEX IF EXISTS diary_tags_created_at_index ON diary_tags;
DROP INDEX IF EXISTS diary_tags_updated_at_index ON member;
-- Alternative INDEX-Namen
DROP INDEX IF EXISTS idx_diary_tags_name ON diary_tags;
DROP INDEX IF EXISTS idx_diary_tags_created_at ON diary_tags;
DROP INDEX IF EXISTS idx_diary_tags_updated_at ON diary_tags;
-- Season-Tabelle: Weitere INDEX entfernen
SELECT '=== ENTFERNE WEITERE SEASON INDEX ===' as info;
DROP INDEX IF EXISTS season_name_index ON season;
DROP INDEX IF EXISTS season_start_date_index ON season;
DROP INDEX IF EXISTS season_end_date_index ON season;
DROP INDEX IF EXISTS season_created_at_index ON season;
DROP INDEX IF EXISTS season_updated_at_index ON season;
-- Alternative INDEX-Namen
DROP INDEX IF EXISTS idx_season_name ON season;
DROP INDEX IF EXISTS idx_season_start_date ON season;
DROP INDEX IF EXISTS idx_season_end_date ON season;
DROP INDEX IF EXISTS idx_season_created_at ON season;
DROP INDEX IF EXISTS idx_season_updated_at ON season;
-- 4. Status nach dem finalen Cleanup
SELECT '=== STATUS NACH FINALEM CLEANUP ===' as info;
SELECT
TABLE_NAME,
COUNT(*) as key_count
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary'
GROUP BY TABLE_NAME
ORDER BY key_count DESC;
-- 5. Gesamtanzahl der Keys
SELECT
COUNT(*) as total_keys_after_final_cleanup
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary';
-- 6. Zusammenfassung
SELECT '=== ZUSAMMENFASSUNG ===' as info;
SELECT
'Finales Cleanup abgeschlossen. Überprüfen Sie die Anzahl der Keys oben.' as message;

View File

@@ -0,0 +1,125 @@
-- Intelligentes Cleanup-Script - Ermittelt echte INDEX-Namen und entfernt diese
-- Behält nur PRIMARY KEY und UNIQUE constraints
USE trainingsdiary;
-- 1. Status vor dem intelligenten Cleanup
SELECT '=== STATUS VOR INTELLIGENTEM CLEANUP ===' as info;
SELECT
TABLE_NAME,
COUNT(*) as key_count
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary'
GROUP BY TABLE_NAME
ORDER BY key_count DESC;
-- 2. Alle INDEX der Problem-Tabellen anzeigen (mit echten Namen)
SELECT '=== MEMBER TABELLE INDEX (ECHTE NAMEN) ===' as info;
SHOW INDEX FROM member;
SELECT '=== DIARY_TAGS TABELLE INDEX (ECHTE NAMEN) ===' as info;
SHOW INDEX FROM diary_tags;
SELECT '=== SEASON TABELLE INDEX (ECHTE NAMEN) ===' as info;
SHOW INDEX FROM season;
-- 3. Alle INDEX-Namen extrahieren und DROP-Befehle generieren
SELECT '=== GENERIERE DROP-BEFEHLE FÜR ÜBERFLÜSSIGE INDEX ===' as info;
-- Member-Tabelle: Alle INDEX außer PRIMARY entfernen
SELECT '=== ENTFERNE ÜBERFLÜSSIGE MEMBER INDEX ===' as info;
-- Verwende die echten INDEX-Namen aus SHOW INDEX
-- Entferne alle außer PRIMARY KEY (PRIMARY kann nicht gelöscht werden)
-- Beispiel für häufige überflüssige INDEX-Namen (basierend auf Sequelize-Konventionen)
-- Diese werden nur ausgeführt, wenn sie existieren
-- Häufige überflüssige INDEX-Namen
DROP INDEX IF EXISTS member_hashed_id_unique ON member;
DROP INDEX IF EXISTS member_first_name_index ON member;
DROP INDEX IF EXISTS member_last_name_index ON member;
DROP INDEX IF EXISTS member_birth_date_index ON member;
DROP INDEX IF EXISTS member_active_index ON member;
DROP INDEX IF EXISTS member_created_at_index ON member;
DROP INDEX IF EXISTS member_updated_at_index ON member;
DROP INDEX IF EXISTS member_club_id_index ON member;
DROP INDEX IF EXISTS member_hashed_id_index ON member;
-- Alternative INDEX-Namen
DROP INDEX IF EXISTS idx_member_hashed_id ON member;
DROP INDEX IF EXISTS idx_member_first_name ON member;
DROP INDEX IF EXISTS idx_member_last_name ON member;
DROP INDEX IF EXISTS idx_member_birth_date ON member;
DROP INDEX IF EXISTS idx_member_active ON member;
DROP INDEX IF EXISTS idx_member_created_at ON member;
DROP INDEX IF EXISTS idx_member_updated_at ON member;
DROP INDEX IF EXISTS idx_member_club_id ON member;
-- Diary_Tags-Tabelle: Alle überflüssigen INDEX entfernen
SELECT '=== ENTFERNE ÜBERFLÜSSIGE DIARY_TAGS INDEX ===' as info;
DROP INDEX IF EXISTS diary_tags_name_index ON diary_tags;
DROP INDEX IF EXISTS diary_tags_created_at_index ON diary_tags;
DROP INDEX IF EXISTS diary_tags_updated_at_index ON diary_tags;
DROP INDEX IF EXISTS diary_tags_club_id_index ON diary_tags;
-- Alternative INDEX-Namen
DROP INDEX IF EXISTS idx_diary_tags_name ON diary_tags;
DROP INDEX IF EXISTS idx_diary_tags_created_at ON diary_tags;
DROP INDEX IF EXISTS idx_diary_tags_updated_at ON diary_tags;
DROP INDEX IF EXISTS idx_diary_tags_club_id ON diary_tags;
-- Season-Tabelle: Alle überflüssigen INDEX entfernen
SELECT '=== ENTFERNE ÜBERFLÜSSIGE SEASON INDEX ===' as info;
DROP INDEX IF EXISTS season_name_index ON season;
DROP INDEX IF EXISTS season_start_date_index ON season;
DROP INDEX IF EXISTS season_end_date_index ON season;
DROP INDEX IF EXISTS season_created_at_index ON season;
DROP INDEX IF EXISTS season_updated_at_index ON season;
DROP INDEX IF EXISTS season_club_id_index ON season;
-- Alternative INDEX-Namen
DROP INDEX IF EXISTS idx_season_name ON season;
DROP INDEX IF EXISTS idx_season_start_date ON season;
DROP INDEX IF EXISTS idx_season_end_date ON season;
DROP INDEX IF EXISTS idx_season_created_at ON season;
DROP INDEX IF EXISTS idx_season_updated_at ON season;
DROP INDEX IF EXISTS idx_season_club_id ON season;
-- 4. Status nach dem intelligenten Cleanup
SELECT '=== STATUS NACH INTELLIGENTEM CLEANUP ===' as info;
SELECT
TABLE_NAME,
COUNT(*) as key_count
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary'
GROUP BY TABLE_NAME
ORDER BY key_count DESC;
-- 5. Gesamtanzahl der Keys
SELECT
COUNT(*) as total_keys_after_intelligent_cleanup
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary';
-- 6. Ziel: Jede Tabelle sollte nur 2-5 Keys haben
SELECT '=== ZIEL: 2-5 KEYS PRO TABELLE ===' as info;
SELECT
TABLE_NAME,
COUNT(*) as key_count,
CASE
WHEN COUNT(*) <= 5 THEN '✅ OK'
WHEN COUNT(*) <= 10 THEN '⚠️ Zu viele'
ELSE '❌ Viel zu viele'
END as status
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary'
GROUP BY TABLE_NAME
ORDER BY key_count DESC;
-- 7. Zusammenfassung
SELECT '=== ZUSAMMENFASSUNG ===' as info;
SELECT
'Intelligentes Cleanup abgeschlossen. Überprüfen Sie die Anzahl der Keys oben.' as message;

View File

@@ -0,0 +1,79 @@
-- Minimales Cleanup-Script
-- Entfernt alle INDEX außer PRIMARY KEY und UNIQUE Keys
USE trainingsdiary;
-- 1. Status vor Cleanup
SELECT '=== STATUS VOR CLEANUP ===' as info;
SELECT
TABLE_NAME,
COUNT(*) as key_count
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary'
GROUP BY TABLE_NAME
ORDER BY key_count DESC;
SELECT
COUNT(*) as total_keys_before
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary';
-- 2. Alle nicht-essentiellen INDEX entfernen
-- Behalte nur PRIMARY KEY und UNIQUE Keys
-- Alle INDEX außer PRIMARY und UNIQUE entfernen
-- Verwende SHOW INDEX um die echten INDEX-Namen zu sehen
SELECT '=== ENTFERNE ÜBERFLÜSSIGE INDEX ===' as info;
-- Member-Tabelle: Alle INDEX außer PRIMARY entfernen
SELECT '=== MEMBER TABELLE ===' as info;
SHOW INDEX FROM member;
-- User-Tabelle: Alle INDEX außer PRIMARY entfernen
SELECT '=== USER TABELLE ===' as info;
SHOW INDEX FROM user;
-- Clubs-Tabelle: Alle INDEX außer PRIMARY entfernen
SELECT '=== CLUBS TABELLE ===' as info;
SHOW INDEX FROM clubs;
-- User_Club-Tabelle: Alle INDEX außer PRIMARY entfernen
SELECT '=== USER_CLUB TABELLE ===' as info;
SHOW INDEX FROM user_club;
-- Log-Tabelle: Alle INDEX außer PRIMARY entfernen
SELECT '=== LOG TABELLE ===' as info;
SHOW INDEX FROM log;
-- Diary_Dates-Tabelle: Alle INDEX außer PRIMARY entfernen
SELECT '=== DIARY_DATES TABELLE ===' as info;
SHOW INDEX FROM diary_dates;
-- Participants-Tabelle: Alle INDEX außer PRIMARY entfernen
SELECT '=== PARTICIPANTS TABELLE ===' as info;
SHOW INDEX FROM participants;
-- Activities-Tabelle: Alle INDEX außer PRIMARY entfernen
SELECT '=== ACTIVITIES TABELLE ===' as info;
SHOW INDEX FROM activities;
-- 3. Status nach Cleanup
SELECT '=== STATUS NACH CLEANUP ===' as info;
SELECT
TABLE_NAME,
COUNT(*) as key_count
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary'
GROUP BY TABLE_NAME
ORDER BY key_count DESC;
SELECT
COUNT(*) as total_keys_after
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary';
-- 4. Zusammenfassung
SELECT '=== ZUSAMMENFASSUNG ===' as info;
SELECT
'Minimales Cleanup abgeschlossen. Überprüfen Sie die Anzahl der Keys oben.' as message;

143
backend/cleanupKeysNode.cjs Normal file
View File

@@ -0,0 +1,143 @@
const mysql = require('mysql2/promise');
require('dotenv').config();
// Datenbankverbindung
const dbConfig = {
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'trainingsdiary'
};
async function cleanupKeys() {
let connection;
try {
console.log('🔌 Verbinde mit der Datenbank...');
connection = await mysql.createConnection(dbConfig);
// 1. Status vor dem Cleanup
console.log('\n📊 STATUS VOR DEM CLEANUP:');
const [tablesBefore] = await connection.execute(`
SELECT
TABLE_NAME,
COUNT(*) as key_count
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = ?
GROUP BY TABLE_NAME
ORDER BY key_count DESC
`, [dbConfig.database]);
tablesBefore.forEach(table => {
console.log(` ${table.TABLE_NAME}: ${table.key_count} Keys`);
});
// 2. Alle INDEX der Problem-Tabellen anzeigen
const problemTables = ['member', 'diary_tags', 'season'];
for (const tableName of problemTables) {
console.log(`\n🔍 INDEX für Tabelle '${tableName}':`);
try {
const [indexes] = await connection.execute(`SHOW INDEX FROM \`${tableName}\``);
if (indexes.length === 0) {
console.log(` Keine INDEX gefunden für Tabelle '${tableName}'`);
continue;
}
indexes.forEach(index => {
console.log(` - ${index.Key_name} (${index.Column_name}) - ${index.Non_unique === 0 ? 'UNIQUE' : 'NON-UNIQUE'}`);
});
// 3. Überflüssige INDEX entfernen (alle außer PRIMARY und UNIQUE)
console.log(`\n🗑️ Entferne überflüssige INDEX aus '${tableName}':`);
for (const index of indexes) {
// Behalte PRIMARY KEY und UNIQUE constraints
if (index.Key_name === 'PRIMARY' || index.Non_unique === 0) {
console.log(` ✅ Behalte: ${index.Key_name} (${index.Column_name})`);
continue;
}
// Entferne alle anderen INDEX
try {
await connection.execute(`DROP INDEX \`${index.Key_name}\` ON \`${tableName}\``);
console.log(` ❌ Entfernt: ${index.Key_name} (${index.Column_name})`);
} catch (error) {
if (error.code === 'ER_CANT_DROP_FIELD_OR_KEY') {
console.log(` ⚠️ Kann nicht entfernen: ${index.Key_name} (${index.Column_name}) - ${error.message}`);
} else {
console.log(` ❌ Fehler beim Entfernen von ${index.Key_name}: ${error.message}`);
}
}
}
} catch (error) {
console.log(` ⚠️ Fehler beim Zugriff auf Tabelle '${tableName}': ${error.message}`);
}
}
// 4. Status nach dem Cleanup
console.log('\n📊 STATUS NACH DEM CLEANUP:');
const [tablesAfter] = await connection.execute(`
SELECT
TABLE_NAME,
COUNT(*) as key_count
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = ?
GROUP BY TABLE_NAME
ORDER BY key_count DESC
`, [dbConfig.database]);
tablesAfter.forEach(table => {
const before = tablesBefore.find(t => t.TABLE_NAME === table.TABLE_NAME);
const beforeCount = before ? before.key_count : 0;
const diff = beforeCount - table.key_count;
const status = table.key_count <= 5 ? '✅' : table.key_count <= 10 ? '⚠️' : '❌';
console.log(` ${status} ${table.TABLE_NAME}: ${table.key_count} Keys (${diff > 0 ? `-${diff}` : `+${Math.abs(diff)}`})`);
});
// 5. Gesamtanzahl der Keys
const [totalKeys] = await connection.execute(`
SELECT COUNT(*) as total_keys
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = ?
`, [dbConfig.database]);
console.log(`\n📈 GESAMTANZAHL KEYS: ${totalKeys[0].total_keys}`);
// 6. Zusammenfassung
console.log('\n🎯 ZUSAMMENFASSUNG:');
const problemTablesAfter = tablesAfter.filter(t => t.key_count > 10);
if (problemTablesAfter.length === 0) {
console.log(' ✅ Alle Tabellen haben jetzt weniger als 10 Keys!');
} else {
console.log(' ⚠️ Folgende Tabellen haben immer noch zu viele Keys:');
problemTablesAfter.forEach(table => {
console.log(` - ${table.TABLE_NAME}: ${table.key_count} Keys`);
});
}
} catch (error) {
console.error('❌ Fehler beim Cleanup:', error);
} finally {
if (connection) {
await connection.end();
console.log('\n🔌 Datenbankverbindung geschlossen.');
}
}
}
// Script ausführen
console.log('🚀 Starte intelligentes INDEX-Cleanup...\n');
cleanupKeys().then(() => {
console.log('\n✨ Cleanup abgeschlossen!');
process.exit(0);
}).catch(error => {
console.error('\n💥 Fehler beim Cleanup:', error);
process.exit(1);
});

166
backend/cleanupKeysNode.js Normal file
View File

@@ -0,0 +1,166 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
import path from 'path';
import { fileURLToPath } from 'url';
// __dirname für ES-Module
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Umgebungsvariablen aus dem Root-Verzeichnis laden
//const envPath = path.join(__dirname, '..', '.env');
//console.log('🔍 Lade .env-Datei von:', envPath);
dotenv.config();
// Debug: Zeige geladene Umgebungsvariablen
console.log('🔍 Geladene Umgebungsvariablen:');
console.log(' DB_HOST:', process.env.DB_HOST);
console.log(' DB_USER:', process.env.DB_USER);
console.log(' DB_NAME:', process.env.DB_NAME);
console.log(' DB_PASSWORD:', process.env.DB_PASSWORD ? '***gesetzt***' : 'nicht gesetzt');
// Datenbankverbindung
const dbConfig = {
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'trainingsdiary'
};
console.log('🔍 Datenbankverbindung:');
console.log(' Host:', dbConfig.host);
console.log(' User:', dbConfig.user);
console.log(' Database:', dbConfig.database);
console.log(' Password:', dbConfig.password ? '***gesetzt***' : 'nicht gesetzt');
async function cleanupKeys() {
let connection;
try {
console.log('🔌 Verbinde mit der Datenbank...');
connection = await mysql.createConnection(dbConfig);
// 1. Status vor dem Cleanup
console.log('\n📊 STATUS VOR DEM CLEANUP:');
const [tablesBefore] = await connection.execute(`
SELECT
TABLE_NAME,
COUNT(*) as key_count
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = ?
GROUP BY TABLE_NAME
ORDER BY key_count DESC
`, [dbConfig.database]);
tablesBefore.forEach(table => {
console.log(` ${table.TABLE_NAME}: ${table.key_count} Keys`);
});
// 2. Alle INDEX der Problem-Tabellen anzeigen
const problemTables = ['member', 'diary_tags', 'season'];
for (const tableName of problemTables) {
console.log(`\n🔍 INDEX für Tabelle '${tableName}':`);
try {
const [indexes] = await connection.execute(`SHOW INDEX FROM \`${tableName}\``);
if (indexes.length === 0) {
console.log(` Keine INDEX gefunden für Tabelle '${tableName}'`);
continue;
}
indexes.forEach(index => {
console.log(` - ${index.Key_name} (${index.Column_name}) - ${index.Non_unique === 0 ? 'UNIQUE' : 'NON-UNIQUE'}`);
});
// 3. Überflüssige INDEX entfernen (alle außer PRIMARY und UNIQUE)
console.log(`\n🗑️ Entferne überflüssige INDEX aus '${tableName}':`);
for (const index of indexes) {
// Behalte PRIMARY KEY und UNIQUE constraints
if (index.Key_name === 'PRIMARY' || index.Non_unique === 0) {
console.log(` ✅ Behalte: ${index.Key_name} (${index.Column_name})`);
continue;
}
// Entferne alle anderen INDEX
try {
await connection.execute(`DROP INDEX \`${index.Key_name}\` ON \`${tableName}\``);
console.log(` ❌ Entfernt: ${index.Key_name} (${index.Column_name})`);
} catch (error) {
if (error.code === 'ER_CANT_DROP_FIELD_OR_KEY') {
console.log(` ⚠️ Kann nicht entfernen: ${index.Key_name} (${index.Column_name}) - ${error.message}`);
} else {
console.log(` ❌ Fehler beim Entfernen von ${index.Key_name}: ${error.message}`);
}
}
}
} catch (error) {
console.log(` ⚠️ Fehler beim Zugriff auf Tabelle '${tableName}': ${error.message}`);
}
}
// 4. Status nach dem Cleanup
console.log('\n📊 STATUS NACH DEM CLEANUP:');
const [tablesAfter] = await connection.execute(`
SELECT
TABLE_NAME,
COUNT(*) as key_count
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = ?
GROUP BY TABLE_NAME
ORDER BY key_count DESC
`, [dbConfig.database]);
tablesAfter.forEach(table => {
const before = tablesBefore.find(t => t.TABLE_NAME === table.TABLE_NAME);
const beforeCount = before ? before.key_count : 0;
const diff = beforeCount - table.key_count;
const status = table.key_count <= 5 ? '✅' : table.key_count <= 10 ? '⚠️' : '❌';
console.log(` ${status} ${table.TABLE_NAME}: ${table.key_count} Keys (${diff > 0 ? `-${diff}` : `+${Math.abs(diff)}`})`);
});
// 5. Gesamtanzahl der Keys
const [totalKeys] = await connection.execute(`
SELECT COUNT(*) as total_keys
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = ?
`, [dbConfig.database]);
console.log(`\n📈 GESAMTANZAHL KEYS: ${totalKeys[0].total_keys}`);
// 6. Zusammenfassung
console.log('\n🎯 ZUSAMMENFASSUNG:');
const problemTablesAfter = tablesAfter.filter(t => t.key_count > 10);
if (problemTablesAfter.length === 0) {
console.log(' ✅ Alle Tabellen haben jetzt weniger als 10 Keys!');
} else {
console.log(' ⚠️ Folgende Tabellen haben immer noch zu viele Keys:');
problemTablesAfter.forEach(table => {
console.log(` - ${table.TABLE_NAME}: ${table.key_count} Keys`);
});
}
} catch (error) {
console.error('❌ Fehler beim Cleanup:', error);
} finally {
if (connection) {
await connection.end();
console.log('\n🔌 Datenbankverbindung geschlossen.');
}
}
}
// Script ausführen
console.log('🚀 Starte intelligentes INDEX-Cleanup...\n');
cleanupKeys().then(() => {
console.log('\n✨ Cleanup abgeschlossen!');
process.exit(0);
}).catch(error => {
console.error('\n💥 Fehler beim Cleanup:', error);
process.exit(1);
});

149
backend/cleanupKeysReal.sql Normal file
View File

@@ -0,0 +1,149 @@
-- Cleanup-Script mit echten INDEX-Namen
-- Zeigt zuerst alle vorhandenen INDEX an und entfernt dann nur die überflüssigen
USE trainingsdiary;
-- 1. Alle vorhandenen INDEX anzeigen
SELECT '=== VORHANDENE INDEX VOR CLEANUP ===' as info;
SELECT
TABLE_NAME,
INDEX_NAME,
COLUMN_NAME,
NON_UNIQUE,
SEQ_IN_INDEX
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary'
ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX;
-- 2. Anzahl der Keys pro Tabelle vor Cleanup
SELECT '=== KEYS PRO TABELLE VOR CLEANUP ===' as info;
SELECT
TABLE_NAME,
COUNT(*) as key_count
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary'
GROUP BY TABLE_NAME
ORDER BY key_count DESC;
-- 3. Gesamtanzahl der Keys vor Cleanup
SELECT '=== GESAMTANZAHL KEYS VOR CLEANUP ===' as info;
SELECT
COUNT(*) as total_keys_before
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary';
-- 4. Cleanup: Nur INDEX entfernen, die tatsächlich existieren
-- Verwende DROP INDEX IF EXISTS für alle möglichen INDEX
-- Member-Tabelle
SELECT '=== ENTFERNE MEMBER INDEX ===' as info;
DROP INDEX IF EXISTS idx_member_hashed_id ON member;
DROP INDEX IF EXISTS idx_member_first_name ON member;
DROP INDEX IF EXISTS idx_member_last_name ON member;
DROP INDEX IF EXISTS idx_member_birth_date ON member;
DROP INDEX IF EXISTS idx_member_active ON member;
DROP INDEX IF EXISTS idx_member_created_at ON member;
DROP INDEX IF EXISTS idx_member_updated_at ON member;
-- User-Tabelle
SELECT '=== ENTFERNE USER INDEX ===' as info;
DROP INDEX IF EXISTS idx_user_email ON user;
DROP INDEX IF EXISTS idx_user_created_at ON user;
DROP INDEX IF EXISTS idx_user_updated_at ON user;
-- Clubs-Tabelle
SELECT '=== ENTFERNE CLUBS INDEX ===' as info;
DROP INDEX IF EXISTS idx_clubs_name ON clubs;
DROP INDEX IF EXISTS idx_clubs_created_at ON clubs;
DROP INDEX IF EXISTS idx_clubs_updated_at ON clubs;
-- User_Club-Tabelle
SELECT '=== ENTFERNE USER_CLUB INDEX ===' as info;
DROP INDEX IF EXISTS idx_user_club_approved ON user_club;
DROP INDEX IF EXISTS idx_user_club_created_at ON user_club;
DROP INDEX IF EXISTS idx_user_club_updated_at ON user_club;
-- Log-Tabelle
SELECT '=== ENTFERNE LOG INDEX ===' as info;
DROP INDEX IF EXISTS idx_log_activity ON log;
DROP INDEX IF EXISTS idx_log_created_at ON log;
DROP INDEX IF EXISTS idx_log_updated_at ON log;
-- Diary_Dates-Tabelle
SELECT '=== ENTFERNE DIARY_DATES INDEX ===' as info;
DROP INDEX IF EXISTS idx_diary_dates_date ON diary_dates;
DROP INDEX IF EXISTS idx_diary_dates_created_at ON diary_dates;
DROP INDEX IF EXISTS idx_diary_dates_updated_at ON diary_dates;
-- Participants-Tabelle
SELECT '=== ENTFERNE PARTICIPANTS INDEX ===' as info;
DROP INDEX IF EXISTS idx_participant_created_at ON participants;
DROP INDEX IF EXISTS idx_participant_updated_at ON participants;
-- Activities-Tabelle
SELECT '=== ENTFERNE ACTIVITIES INDEX ===' as info;
DROP INDEX IF EXISTS idx_activity_created_at ON activities;
DROP INDEX IF EXISTS idx_activity_updated_at ON activities;
-- Member_Notes-Tabelle
SELECT '=== ENTFERNE MEMBER_NOTES INDEX ===' as info;
DROP INDEX IF EXISTS idx_member_note_created_at ON member_notes;
DROP INDEX IF EXISTS idx_member_note_updated_at ON member_notes;
-- Diary_Notes-Tabelle
SELECT '=== ENTFERNE DIARY_NOTES INDEX ===' as info;
DROP INDEX IF EXISTS idx_diary_note_created_at ON diary_notes;
DROP INDEX IF EXISTS idx_diary_note_updated_at ON diary_notes;
-- Diary_Tags-Tabelle
SELECT '=== ENTFERNE DIARY_TAGS INDEX ===' as info;
DROP INDEX IF EXISTS idx_diary_tag_created_at ON diary_tags;
DROP INDEX IF EXISTS idx_diary_tag_updated_at ON diary_tags;
-- Member_Diary_Tags-Tabelle
SELECT '=== ENTFERNE MEMBER_DIARY_TAGS INDEX ===' as info;
DROP INDEX IF EXISTS idx_member_diary_tag_created_at ON member_diary_tags;
DROP INDEX IF EXISTS idx_member_diary_tag_updated_at ON member_diary_tags;
-- Diary_Date_Tags-Tabelle
SELECT '=== ENTFERNE DIARY_DATE_TAGS INDEX ===' as info;
DROP INDEX IF EXISTS idx_diary_date_tag_created_at ON diary_date_tags;
DROP INDEX IF EXISTS idx_diary_date_tag_updated_at ON diary_date_tags;
-- Diary_Member_Notes-Tabelle
SELECT '=== ENTFERNE DIARY_MEMBER_NOTES INDEX ===' as info;
DROP INDEX IF EXISTS idx_diary_member_note_created_at ON diary_member_notes;
DROP INDEX IF EXISTS idx_diary_member_note_updated_at ON diary_member_notes;
-- Predefined_Activities-Tabelle
SELECT '=== ENTFERNE PREDEFINED_ACTIVITIES INDEX ===' as info;
DROP INDEX IF EXISTS idx_predefined_activity_created_at ON predefined_activities;
DROP INDEX IF EXISTS idx_predefined_activity_updated_at ON predefined_activities;
-- Diary_Date_Activities-Tabelle
SELECT '=== ENTFERNE DIARY_DATE_ACTIVITIES INDEX ===' as info;
DROP INDEX IF EXISTS idx_diary_date_activity_created_at ON diary_date_activities;
DROP INDEX IF EXISTS idx_diary_date_activity_updated_at ON diary_date_activities;
-- 5. Nach der Bereinigung: Status anzeigen
SELECT '=== STATUS NACH BEREINIGUNG ===' as info;
-- Anzahl der Keys pro Tabelle nach der Bereinigung
SELECT
TABLE_NAME,
COUNT(*) as key_count
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary'
GROUP BY TABLE_NAME
ORDER BY key_count DESC;
-- Gesamtanzahl der Keys nach der Bereinigung
SELECT
COUNT(*) as total_keys_after
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary';
-- 6. Zusammenfassung der Änderungen
SELECT '=== ZUSAMMENFASSUNG ===' as info;
SELECT
'Cleanup abgeschlossen. Überprüfen Sie die Anzahl der Keys oben.' as message;

View File

@@ -0,0 +1,41 @@
-- Vereinfachtes Cleanup-Script für MySQL Keys
-- Entfernt nur die problematischsten Keys
USE trainingsdiary;
-- 1. Alle überflüssigen INDEX entfernen (die meisten werden von Sequelize automatisch erstellt)
-- Diese entfernen die meisten Keys, die das Limit überschreiten
-- Member-Tabelle (Hauptproblem)
DROP INDEX IF EXISTS idx_member_hashed_id ON member;
DROP INDEX IF EXISTS idx_member_first_name ON member;
DROP INDEX IF EXISTS idx_member_last_name ON member;
DROP INDEX IF EXISTS idx_member_birth_date ON member;
DROP INDEX IF EXISTS idx_member_active ON member;
DROP INDEX IF EXISTS idx_member_created_at ON member;
DROP INDEX IF EXISTS idx_member_updated_at ON member;
-- User-Tabelle
DROP INDEX IF EXISTS idx_user_email ON user;
DROP INDEX IF EXISTS idx_user_created_at ON user;
DROP INDEX IF EXISTS idx_user_updated_at ON user;
-- Clubs-Tabelle
DROP INDEX IF EXISTS idx_clubs_name ON clubs;
DROP INDEX IF EXISTS idx_clubs_created_at ON clubs;
DROP INDEX IF EXISTS idx_clubs_updated_at ON clubs;
-- 2. Status anzeigen
SELECT
TABLE_NAME,
COUNT(*) as key_count
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary'
GROUP BY TABLE_NAME
ORDER BY key_count DESC;
-- 3. Gesamtanzahl der Keys anzeigen
SELECT
COUNT(*) as total_keys
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary';

View File

@@ -0,0 +1,153 @@
-- Intelligentes Cleanup-Script für MySQL Keys
-- Überprüft zuerst die echten Tabellennamen und entfernt nur vorhandene INDEX
USE trainingsdiary;
-- 1. Alle vorhandenen Tabellen anzeigen
SELECT '=== VORHANDENE TABELLEN ===' as info;
SHOW TABLES;
-- 2. Alle vorhandenen INDEX anzeigen
SELECT '=== VORHANDENE INDEX ===' as info;
SELECT
TABLE_NAME,
INDEX_NAME,
COLUMN_NAME,
NON_UNIQUE,
SEQ_IN_INDEX
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary'
ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX;
-- 3. Anzahl der Keys pro Tabelle anzeigen
SELECT '=== KEYS PRO TABELLE ===' as info;
SELECT
TABLE_NAME,
COUNT(*) as key_count
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary'
GROUP BY TABLE_NAME
ORDER BY key_count DESC;
-- 4. Gesamtanzahl der Keys anzeigen
SELECT '=== GESAMTANZAHL KEYS ===' as info;
SELECT
COUNT(*) as total_keys
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary';
-- 5. Intelligente INDEX-Entfernung basierend auf vorhandenen Tabellen
-- Nur INDEX entfernen, die tatsächlich existieren
-- Member-Tabelle (Hauptproblem)
SELECT '=== ENTFERNE MEMBER INDEX ===' as info;
DROP INDEX IF EXISTS idx_member_hashed_id ON member;
DROP INDEX IF EXISTS idx_member_first_name ON member;
DROP INDEX IF EXISTS idx_member_last_name ON member;
DROP INDEX IF EXISTS idx_member_birth_date ON member;
DROP INDEX IF EXISTS idx_member_active ON member;
DROP INDEX IF EXISTS idx_member_created_at ON member;
DROP INDEX IF EXISTS idx_member_updated_at ON member;
-- User-Tabelle
SELECT '=== ENTFERNE USER INDEX ===' as info;
DROP INDEX IF EXISTS idx_user_email ON user;
DROP INDEX IF EXISTS idx_user_created_at ON user;
DROP INDEX IF EXISTS idx_user_updated_at ON user;
-- Clubs-Tabelle
SELECT '=== ENTFERNE CLUBS INDEX ===' as info;
DROP INDEX IF EXISTS idx_clubs_name ON clubs;
DROP INDEX IF EXISTS idx_clubs_created_at ON clubs;
DROP INDEX IF EXISTS idx_clubs_updated_at ON clubs;
-- User_Club-Tabelle
SELECT '=== ENTFERNE USER_CLUB INDEX ===' as info;
DROP INDEX IF EXISTS idx_user_club_approved ON user_club;
DROP INDEX IF EXISTS idx_user_club_created_at ON user_club;
DROP INDEX IF EXISTS idx_user_club_updated_at ON user_club;
-- Log-Tabelle
SELECT '=== ENTFERNE LOG INDEX ===' as info;
DROP INDEX IF EXISTS idx_log_activity ON log;
DROP INDEX IF EXISTS idx_log_created_at ON log;
DROP INDEX IF EXISTS idx_log_updated_at ON log;
-- Diary_Dates-Tabelle
SELECT '=== ENTFERNE DIARY_DATES INDEX ===' as info;
DROP INDEX IF EXISTS idx_diary_dates_date ON diary_dates;
DROP INDEX IF EXISTS idx_diary_dates_created_at ON diary_dates;
DROP INDEX IF EXISTS idx_diary_dates_updated_at ON diary_dates;
-- Participants-Tabelle
SELECT '=== ENTFERNE PARTICIPANTS INDEX ===' as info;
DROP INDEX IF EXISTS idx_participant_created_at ON participants;
DROP INDEX IF EXISTS idx_participant_updated_at ON participants;
-- Activities-Tabelle
SELECT '=== ENTFERNE ACTIVITIES INDEX ===' as info;
DROP INDEX IF EXISTS idx_activity_created_at ON activities;
DROP INDEX IF EXISTS idx_activity_updated_at ON activities;
-- Member_Notes-Tabelle
SELECT '=== ENTFERNE MEMBER_NOTES INDEX ===' as info;
DROP INDEX IF EXISTS idx_member_note_created_at ON member_notes;
DROP INDEX IF EXISTS idx_member_note_updated_at ON member_notes;
-- Diary_Notes-Tabelle
SELECT '=== ENTFERNE DIARY_NOTES INDEX ===' as info;
DROP INDEX IF EXISTS idx_diary_note_created_at ON diary_notes;
DROP INDEX IF EXISTS idx_diary_note_updated_at ON diary_notes;
-- Diary_Tags-Tabelle
SELECT '=== ENTFERNE DIARY_TAGS INDEX ===' as info;
DROP INDEX IF EXISTS idx_diary_tag_created_at ON diary_tags;
DROP INDEX IF EXISTS idx_diary_tag_updated_at ON diary_tags;
-- Member_Diary_Tags-Tabelle
SELECT '=== ENTFERNE MEMBER_DIARY_TAGS INDEX ===' as info;
DROP INDEX IF EXISTS idx_member_diary_tag_created_at ON member_diary_tags;
DROP INDEX IF EXISTS idx_member_diary_tag_updated_at ON member_diary_tags;
-- Diary_Date_Tags-Tabelle
SELECT '=== ENTFERNE DIARY_DATE_TAGS INDEX ===' as info;
DROP INDEX IF EXISTS idx_diary_date_tag_created_at ON diary_date_tags;
DROP INDEX IF EXISTS idx_diary_date_tag_updated_at ON diary_date_tags;
-- Diary_Member_Notes-Tabelle
SELECT '=== ENTFERNE DIARY_MEMBER_NOTES INDEX ===' as info;
DROP INDEX IF EXISTS idx_diary_member_note_created_at ON diary_member_notes;
DROP INDEX IF EXISTS idx_diary_member_note_updated_at ON diary_member_notes;
-- Predefined_Activities-Tabelle
SELECT '=== ENTFERNE PREDEFINED_ACTIVITIES INDEX ===' as info;
DROP INDEX IF EXISTS idx_predefined_activity_created_at ON predefined_activities;
DROP INDEX IF EXISTS idx_predefined_activity_updated_at ON predefined_activities;
-- Diary_Date_Activities-Tabelle
SELECT '=== ENTFERNE DIARY_DATE_ACTIVITIES INDEX ===' as info;
DROP INDEX IF EXISTS idx_diary_date_activity_created_at ON diary_date_activities;
DROP INDEX IF EXISTS idx_diary_date_activity_updated_at ON diary_date_activities;
-- 6. Nach der Bereinigung: Status anzeigen
SELECT '=== STATUS NACH BEREINIGUNG ===' as info;
-- Anzahl der Keys pro Tabelle nach der Bereinigung
SELECT
TABLE_NAME,
COUNT(*) as key_count
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary'
GROUP BY TABLE_NAME
ORDER BY key_count DESC;
-- Gesamtanzahl der Keys nach der Bereinigung
SELECT
COUNT(*) as total_keys_after_cleanup
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'trainingsdiary';
-- 7. Zusammenfassung
SELECT '=== ZUSAMMENFASSUNG ===' as info;
SELECT
'Cleanup abgeschlossen. Überprüfen Sie die Anzahl der Keys oben.' as message;

View File

@@ -1,4 +1,7 @@
import { register, activateUser, login, logout } from '../services/authService.js';
import jwt from 'jsonwebtoken';
import UserToken from '../models/UserToken.js';
import User from '../models/User.js'; // ggf. Pfad anpassen
const registerUser = async (req, res, next) => {
try {
@@ -30,14 +33,14 @@ const loginUser = async (req, res, next) => {
}
};
const logoutUser = async(req, res) => {
const { userid: userId, authtoken: authToken } = req.headers;
const logoutUser = async (req, res, next) => {
try {
logout(userId, authToken);
const token = req.headers['authorization']?.split(' ')[1];
const result = await logout(token);
res.status(200).json(result);
} catch (error) {
res.status(401).json({ msg: 'not found' });
next(error);
}
res.status(200).json({ msg: 'loggedout' });
}
};
export { registerUser, activate, loginUser, logoutUser };

View File

@@ -0,0 +1,280 @@
// controllers/tournamentController.js
import tournamentService from "../services/tournamentService.js";
// 1. Alle Turniere eines Vereins
export const getTournaments = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId } = req.params;
try {
const tournaments = await tournamentService.getTournaments(token, clubId);
res.status(200).json(tournaments);
} catch (error) {
console.error(error);
res.status(500).json({ error: error.message });
}
};
// 2. Neues Turnier anlegen
export const addTournament = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentName, date } = req.body;
try {
const tournament = await tournamentService.addTournament(token, clubId, tournamentName, date);
res.status(201).json(tournament);
} catch (error) {
console.error(error);
res.status(500).json({ error: error.message });
}
};
// 3. Teilnehmer hinzufügen
export const addParticipant = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, participant: participantId } = req.body;
try {
await tournamentService.addParticipant(token, clubId, tournamentId, participantId);
const participants = await tournamentService.getParticipants(token, clubId, tournamentId);
res.status(200).json(participants);
} catch (error) {
console.error(error);
res.status(500).json({ error: error.message });
}
};
// 4. Teilnehmerliste abrufen
export const getParticipants = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId } = req.body;
try {
const participants = await tournamentService.getParticipants(token, clubId, tournamentId);
res.status(200).json(participants);
} catch (error) {
console.error(error);
res.status(500).json({ error: error.message });
}
};
// 5. Turniermodus (Gruppen/K.O.) setzen
export const setModus = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, type, numberOfGroups, advancingPerGroup } = req.body;
try {
await tournamentService.setModus(token, clubId, tournamentId, type, numberOfGroups, advancingPerGroup);
res.sendStatus(204);
} catch (error) {
console.error(error);
res.status(500).json({ error: error.message });
}
};
// 6. Gruppen-Strukturen anlegen (leere Gruppen)
export const createGroups = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId } = req.body;
try {
await tournamentService.createGroups(token, clubId, tournamentId);
res.sendStatus(204);
} catch (error) {
console.error(error);
res.status(500).json({ error: error.message });
}
};
// 7. Teilnehmer zufällig auf Gruppen verteilen & Gruppenspiele anlegen
export const fillGroups = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId } = req.body;
try {
const updatedMembers = await tournamentService.fillGroups(token, clubId, tournamentId);
res.status(200).json(updatedMembers);
} catch (error) {
console.error(error);
res.status(500).json({ error: error.message });
}
};
// 8. Gruppen mit ihren Teilnehmern abfragen
export const getGroups = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId } = req.query;
try {
const groups = await tournamentService.getGroupsWithParticipants(token, clubId, tournamentId);
res.status(200).json(groups);
} catch (error) {
console.error(error);
res.status(500).json({ error: error.message });
}
};
// 9. Einzelnes Turnier abrufen
export const getTournament = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId } = req.params;
try {
const tournament = await tournamentService.getTournament(token, clubId, tournamentId);
res.status(200).json(tournament);
} catch (error) {
console.error(error);
res.status(500).json({ error: error.message });
}
};
// 10. Alle Spiele eines Turniers abfragen
export const getTournamentMatches = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId } = req.params;
try {
const matches = await tournamentService.getTournamentMatches(token, clubId, tournamentId);
res.status(200).json(matches);
} catch (error) {
console.error(error);
res.status(500).json({ error: error.message });
}
};
// 11. Satz-Ergebnis speichern
export const addMatchResult = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, matchId, set, result } = req.body;
try {
await tournamentService.addMatchResult(token, clubId, tournamentId, matchId, set, result);
res.status(200).json({ message: "Result added successfully" });
} catch (error) {
console.error(error);
res.status(500).json({ error: error.message });
}
};
// 12. Spiel abschließen (Endergebnis ermitteln)
export const finishMatch = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, matchId } = req.body;
try {
await tournamentService.finishMatch(token, clubId, tournamentId, matchId);
res.status(200).json({ message: "Match finished successfully" });
} catch (error) {
console.error(error);
res.status(500).json({ error: error.message });
}
};
export const startKnockout = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId } = req.body;
try {
await tournamentService.startKnockout(token, clubId, tournamentId);
res.status(200).json({ message: "K.o.-Runde erfolgreich gestartet" });
} catch (error) {
const status = /Gruppenmodus|Zu wenige Qualifikanten/.test(error.message) ? 400 : 500;
res.status(status).json({ error: error.message });
}
};
export const manualAssignGroups = async (req, res) => {
const { authcode: token } = req.headers;
const {
clubId,
tournamentId,
assignments, // [{ participantId, groupNumber }]
numberOfGroups, // optional
maxGroupSize // optional
} = req.body;
try {
const groupsWithParts = await tournamentService.manualAssignGroups(
token,
clubId,
tournamentId,
assignments,
numberOfGroups, // neu
maxGroupSize // neu
);
res.status(200).json(groupsWithParts);
} catch (error) {
console.error('Error in manualAssignGroups:', error);
res.status(500).json({ error: error.message });
}
};
export const resetGroups = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId } = req.body;
try {
await tournamentService.resetGroups(token, clubId, tournamentId);
res.sendStatus(204);
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
};
export const resetMatches = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId } = req.body;
try {
await tournamentService.resetMatches(token, clubId, tournamentId);
res.sendStatus(204);
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
};
export const removeParticipant = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, participantId } = req.body;
try {
await tournamentService.removeParticipant(token, clubId, tournamentId, participantId);
const participants = await tournamentService.getParticipants(token, clubId, tournamentId);
res.status(200).json(participants);
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
};
export const deleteMatchResult = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, matchId, set } = req.body;
try {
await tournamentService.deleteMatchResult(
token,
clubId,
tournamentId,
matchId,
set
);
res.status(200).json({ message: 'Einzelsatz gelöscht' });
} catch (error) {
console.error('Error in deleteMatchResult:', error);
res.status(500).json({ error: error.message });
}
};
export const reopenMatch = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, matchId } = req.body;
try {
await tournamentService.reopenMatch(token, clubId, tournamentId, matchId);
// Gib optional das aktualisierte Match zurück
res.status(200).json({ message: "Match reopened" });
} catch (error) {
console.error("Error in reopenMatch:", error);
res.status(500).json({ error: error.message });
}
};
export const deleteKnockoutMatches = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId } = req.body;
try {
await tournamentService.resetKnockout(token, clubId, tournamentId);
res.status(200).json({ message: "K.o.-Runde gelöscht" });
} catch (error) {
console.error("Error in deleteKnockoutMatches:", error);
res.status(500).json({ error: error.message });
}
};

View File

@@ -0,0 +1,121 @@
import { DiaryDate, Member, Participant } from '../models/index.js';
import { Op } from 'sequelize';
class TrainingStatsController {
async getTrainingStats(req, res) {
try {
const { clubId } = req.params;
// Aktuelle Datum für Berechnungen
const now = new Date();
const twelveMonthsAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
const threeMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 3, now.getDate());
// Alle aktiven Mitglieder des Vereins laden
const members = await Member.findAll({
where: {
active: true
}
});
const stats = [];
for (const member of members) {
// Trainingsteilnahmen der letzten 12 Monate über Participant-Model
const participation12Months = await Participant.count({
include: [{
model: DiaryDate,
as: 'diaryDate',
where: {
clubId: parseInt(clubId),
date: {
[Op.gte]: twelveMonthsAgo
}
}
}],
where: {
memberId: member.id
}
});
// Trainingsteilnahmen der letzten 3 Monate über Participant-Model
const participation3Months = await Participant.count({
include: [{
model: DiaryDate,
as: 'diaryDate',
where: {
clubId: parseInt(clubId),
date: {
[Op.gte]: threeMonthsAgo
}
}
}],
where: {
memberId: member.id
}
});
// Trainingsteilnahmen insgesamt über Participant-Model
const participationTotal = await Participant.count({
include: [{
model: DiaryDate,
as: 'diaryDate',
where: {
clubId: parseInt(clubId)
}
}],
where: {
memberId: member.id
}
});
// Detaillierte Trainingsdaten (absteigend sortiert) über Participant-Model
const trainingDetails = await Participant.findAll({
include: [{
model: DiaryDate,
as: 'diaryDate',
where: {
clubId: parseInt(clubId)
}
}],
where: {
memberId: member.id
},
order: [['diaryDate', 'date', 'DESC']],
limit: 50 // Begrenzen auf die letzten 50 Trainingseinheiten
});
// Trainingsteilnahmen für den Member formatieren
const formattedTrainingDetails = trainingDetails.map(participation => ({
id: participation.id,
date: participation.diaryDate.date,
activityName: 'Training',
startTime: '--:--',
endTime: '--:--'
}));
stats.push({
id: member.id,
firstName: member.firstName,
lastName: member.lastName,
birthDate: member.birthDate,
participation12Months,
participation3Months,
participationTotal,
trainingDetails: formattedTrainingDetails
});
}
// Nach Gesamtteilnahme absteigend sortieren
stats.sort((a, b) => b.participationTotal - a.participationTotal);
res.json(stats);
} catch (error) {
console.error('Fehler beim Laden der Trainings-Statistik:', error);
res.status(500).json({ error: 'Fehler beim Laden der Trainings-Statistik' });
}
}
}
export default new TrainingStatsController();

View File

@@ -1,18 +1,23 @@
import User from '../models/User.js';
import jwt from 'jsonwebtoken';
import UserToken from '../models/UserToken.js';
export const authenticate = async (req, res, next) => {
try {
const { userid: userId, authcode: authCode } = req.headers;
if (!userId || !authCode) {
return res.status(401).json({ error: 'Unauthorized: Missing credentials' });
}
const user = await User.findOne({ where: { email: userId, authCode: authCode } });
if (!user) {
return res.status(401).json({ error: 'Unauthorized: Invalid credentials' });
}
next();
} catch(error) {
console.log(error);
return res.status(500).json({ error: 'Internal Server Error at auth' });
let token = req.headers['authorization']?.split(' ')[1];
if (!token) {
token = req.headers['authcode'];
}
if (!token) {
return res.status(401).json({ error: 'Unauthorized: Token fehlt' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const tokenRecord = await UserToken.findOne({ where: { token } });
if (!tokenRecord || tokenRecord.expiresAt < new Date()) {
return res.status(401).json({ error: 'Unauthorized: Invalid credentials' });
}
req.user = { id: decoded.userId };
next();
} catch (err) {
return res.status(401).json({ error: 'Unauthorized: Invalid credentials' });
}
};

View File

@@ -0,0 +1,38 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const Tournament = sequelize.define('Tournament', {
name: {
type: DataTypes.STRING,
allowNull: false,
},
date: {
type: DataTypes.DATEONLY,
allowNull: false,
},
type: {
type: DataTypes.STRING,
allowNull: false,
},
advancingPerGroup: {
type: DataTypes.INTEGER,
allowNull: false,
},
numberOfGroups: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: 0,
},
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 1
},
advancingPerGroup: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 1 },
}, {
underscored: true,
tableName: 'tournament',
timestamps: true,
});
export default Tournament;

View File

@@ -0,0 +1,21 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const TournamentGroup = sequelize.define('TournamentGroup', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false
},
tournamentId : {
type: DataTypes.INTEGER,
allowNull: false
},
}, {
underscored: true,
tableName: 'tournament_group',
timestamps: true,
});
export default TournamentGroup;

View File

@@ -0,0 +1,59 @@
// models/TournamentMatch.js
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
import Tournament from './Tournament.js';
import TournamentGroup from './TournamentGroup.js';
const TournamentMatch = sequelize.define('TournamentMatch', {
tournamentId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: Tournament,
key: 'id'
},
onDelete: 'CASCADE',
onUpdate: 'CASCADE'
},
groupId: {
type: DataTypes.INTEGER,
allowNull: true,
references: {
model: TournamentGroup,
key: 'id'
},
onDelete: 'SET NULL',
onUpdate: 'CASCADE'
},
groupRound: {
type: DataTypes.INTEGER,
allowNull: true,
},
round: {
type: DataTypes.STRING,
allowNull: false,
},
player1Id: {
type: DataTypes.INTEGER,
allowNull: false,
},
player2Id: {
type: DataTypes.INTEGER,
allowNull: false,
},
isFinished: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
result: {
type: DataTypes.STRING,
allowNull: true,
},
}, {
underscored: true,
tableName: 'tournament_match',
timestamps: true,
});
export default TournamentMatch;

View File

@@ -0,0 +1,26 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const TournamentMember = sequelize.define('TournamentMember', {
tournamentId: {
type: DataTypes.INTEGER,
autoIncrement: false,
allowNull: true
},
groupId : {
type: DataTypes.INTEGER,
autoIncrement: false,
allowNull: true
},
clubMemberId: {
type: DataTypes.INTEGER,
autoIncrement: false,
allowNull: false
}
}, {
underscored: true,
tableName: 'tournament_member',
timestamps: true,
});
export default TournamentMember;

View File

@@ -0,0 +1,24 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const TournamentResult = sequelize.define('TournamentResult', {
matchId: {
type: DataTypes.INTEGER,
allowNull: false,
},
set: {
type: DataTypes.INTEGER,
},
pointsPlayer1: {
type: DataTypes.INTEGER,
},
pointsPlayer2: {
type: DataTypes.INTEGER,
},
}, {
underscored: true,
tableName: 'tournament_result',
timestamps: true,
});
export default TournamentResult;

View File

@@ -63,4 +63,8 @@ const User = sequelize.define('User', {
},
});
User.prototype.validatePassword = function(password) {
return bcrypt.compare(password, this.password);
};
export default User;

View File

@@ -0,0 +1,20 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js'; // Korrigierter Pfad
const UserToken = sequelize.define('UserToken', {
userId: {
type: DataTypes.INTEGER,
allowNull: false,
},
token: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
},
expiresAt: {
type: DataTypes.DATE,
allowNull: false,
},
});
export default UserToken;

View File

@@ -21,7 +21,13 @@ import Season from './Season.js';
import Location from './Location.js';
import Group from './Group.js';
import GroupActivity from './GroupActivity.js';
import Tournament from './Tournament.js';
import TournamentGroup from './TournamentGroup.js';
import TournamentMember from './TournamentMember.js';
import TournamentMatch from './TournamentMatch.js';
import TournamentResult from './TournamentResult.js';
import Accident from './Accident.js';
import UserToken from './UserToken.js';
User.hasMany(Log, { foreignKey: 'userId' });
Log.belongsTo(User, { foreignKey: 'userId' });
@@ -35,6 +41,12 @@ Club.hasMany(DiaryDate, { foreignKey: 'clubId' });
DiaryDate.belongsToMany(Member, { through: Participant, as: 'participants', foreignKey: 'diaryDateId' });
Member.belongsToMany(DiaryDate, { through: Participant, as: 'diaryDates', foreignKey: 'memberId' });
// Explizite Assoziationen für Participant
Participant.belongsTo(DiaryDate, { foreignKey: 'diaryDateId', as: 'diaryDate' });
Participant.belongsTo(Member, { foreignKey: 'memberId', as: 'member' });
DiaryDate.hasMany(Participant, { foreignKey: 'diaryDateId', as: 'participantList' });
Member.hasMany(Participant, { foreignKey: 'memberId', as: 'participantList' });
DiaryDate.hasMany(Activity, { as: 'activities', foreignKey: 'diaryDateId' });
Activity.belongsTo(DiaryDate, { as: 'diaryDate', foreignKey: 'diaryDateId' });
@@ -115,6 +127,54 @@ DiaryDateTag.belongsTo(DiaryTag, { foreignKey: 'tagId', as: 'tag' });
DiaryMemberTag.belongsTo(DiaryDate, { foreignKey: 'diaryDateId', as: 'diaryDates' });
DiaryDate.hasMany(DiaryMemberTag, { foreignKey: 'diaryDateId', as: 'diaryMemberTags' });
Tournament.belongsTo(Club, { foreignKey: 'clubId', as: 'tournamentclub' });
Club.hasMany(Tournament, { foreignKey: 'clubId', as: 'tournaments' });
TournamentGroup.belongsTo(Tournament, { foreignKey: 'tournamentId', as: 'tournaments' });
Tournament.hasMany(TournamentGroup, { foreignKey: 'tournamentId', as: 'tournamentGroups' });
TournamentMember.belongsTo(TournamentGroup, {
foreignKey: 'groupId',
targetKey: 'id',
as: 'group',
constraints: false
});
TournamentGroup.hasMany(TournamentMember, {
foreignKey: 'groupId',
as: 'tournamentGroupMembers'
});
TournamentMember.belongsTo(Member, { foreignKey: 'clubMemberId', as: 'member' });
Member.hasMany(TournamentMember, { foreignKey: 'clubMemberId', as: 'tournamentGroupMembers' });
TournamentMember.belongsTo(Tournament, { foreignKey: 'tournamentId', as: 'tournament' });
Tournament.hasMany(TournamentMember, { foreignKey: 'tournamentId', as: 'tournamentMembers' });
TournamentMatch.belongsTo(Tournament, { foreignKey: 'tournamentId', as: 'tournament' });
Tournament.hasMany(TournamentMatch, { foreignKey: 'tournamentId', as: 'tournamentMatches' });
TournamentMatch.belongsTo(TournamentGroup, { foreignKey: 'groupId', as: 'group' });
TournamentGroup.hasMany(TournamentMatch, { foreignKey: 'groupId', as: 'tournamentMatches' });
TournamentMatch.hasMany(TournamentResult, {
foreignKey: 'matchId',
as: 'tournamentResults',
onDelete: 'CASCADE',
onUpdate: 'CASCADE'
});
TournamentResult.belongsTo(TournamentMatch, {
foreignKey: 'matchId',
as: 'match',
onDelete: 'CASCADE',
onUpdate: 'CASCADE'
});
TournamentMatch.belongsTo(TournamentMember, { foreignKey: 'player1Id', as: 'player1' });
TournamentMatch.belongsTo(TournamentMember, { foreignKey: 'player2Id', as: 'player2' });
TournamentMember.hasMany(TournamentMatch, { foreignKey: 'player1Id', as: 'player1Matches' });
TournamentMember.hasMany(TournamentMatch, { foreignKey: 'player2Id', as: 'player2Matches' });
Accident.belongsTo(Member, { foreignKey: 'memberId', as: 'members' });
Member.hasMany(Accident, { foreignKey: 'memberId', as: 'accidents' });
@@ -144,5 +204,11 @@ export {
Team,
Group,
GroupActivity,
Tournament,
TournamentGroup,
TournamentMember,
TournamentMatch,
TournamentResult,
Accident,
UserToken,
};

View File

@@ -6,6 +6,6 @@ const router = express.Router();
router.post('/register', registerUser);
router.get('/activate/:activationCode', activate);
router.post('/login', loginUser);
router.get('/logout', logoutUser);
router.post('/logout', logoutUser); // Ändere GET zu POST
export default router;

View File

@@ -0,0 +1,49 @@
import express from 'express';
import {
getTournaments,
addTournament,
addParticipant,
getParticipants,
setModus,
createGroups,
fillGroups,
getGroups,
getTournament,
getTournamentMatches,
addMatchResult,
finishMatch,
startKnockout,
manualAssignGroups,
resetGroups,
resetMatches,
removeParticipant,
deleteMatchResult,
reopenMatch,
deleteKnockoutMatches,
} from '../controllers/tournamentController.js';
import { authenticate } from '../middleware/authMiddleware.js';
const router = express.Router();
router.post('/participant', authenticate, addParticipant);
router.post('/participants', authenticate, getParticipants);
router.delete('/participant', authenticate, removeParticipant);
router.post('/modus', authenticate, setModus);
router.post('/groups/reset', authenticate, resetGroups);
router.post('/matches/reset', authenticate, resetMatches);
router.put('/groups', authenticate, createGroups);
router.post('/groups', authenticate, fillGroups);
router.get('/groups', authenticate, getGroups);
router.post('/match/result', authenticate, addMatchResult);
router.delete('/match/result', authenticate, deleteMatchResult);
router.post("/match/reopen", reopenMatch);
router.post('/match/finish', authenticate, finishMatch);
router.get('/matches/:clubId/:tournamentId', authenticate, getTournamentMatches);
router.get('/:clubId/:tournamentId', authenticate, getTournament);
router.get('/:clubId', authenticate, getTournaments);
router.post('/knockout', authenticate, startKnockout);
router.delete("/matches/knockout", deleteKnockoutMatches);
router.post('/groups/manual', authenticate, manualAssignGroups);
router.post('/', authenticate, addTournament);
export default router;

View File

@@ -0,0 +1,10 @@
import express from 'express';
import trainingStatsController from '../controllers/trainingStatsController.js';
import { authenticate } from '../middleware/authMiddleware.js';
const router = express.Router();
router.use(authenticate);
router.get('/:clubId', trainingStatsController.getTrainingStats);
export default router;

View File

@@ -7,8 +7,8 @@ import {
User, Log, Club, UserClub, Member, DiaryDate, Participant, Activity, MemberNote,
DiaryNote, DiaryTag, MemberDiaryTag, DiaryDateTag, DiaryMemberNote, DiaryMemberTag,
PredefinedActivity, DiaryDateActivity, Match, League, Team, Group,
GroupActivity,
Accident
GroupActivity, Tournament, TournamentGroup, TournamentMatch, TournamentResult,
TournamentMember, Accident, UserToken
} from './models/index.js';
import authRoutes from './routes/authRoutes.js';
import clubRoutes from './routes/clubRoutes.js';
@@ -28,7 +28,9 @@ import Location from './models/Location.js';
import groupRoutes from './routes/groupRoutes.js';
import diaryDateTagRoutes from './routes/diaryDateTagRoutes.js';
import sessionRoutes from './routes/sessionRoutes.js';
import tournamentRoutes from './routes/tournamentRoutes.js';
import accidentRoutes from './routes/accidentRoutes.js';
import trainingStatsRoutes from './routes/trainingStatsRoutes.js';
const app = express();
const port = process.env.PORT || 3000;
@@ -55,10 +57,13 @@ app.use('/api/matches', matchRoutes);
app.use('/api/group', groupRoutes);
app.use('/api/diarydatetags', diaryDateTagRoutes);
app.use('/api/session', sessionRoutes);
app.use('/api/tournament', tournamentRoutes);
app.use('/api/accident', accidentRoutes);
app.use('/api/training-stats', trainingStatsRoutes);
app.use(express.static(path.join(__dirname, '../frontend/dist')));
// Catch-All Handler für Frontend-Routen (muss nach den API-Routen stehen)
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '../frontend/dist/index.html'));
});
@@ -91,7 +96,13 @@ app.get('*', (req, res) => {
await Match.sync({ alter: true });
await Group.sync({ alter: true });
await GroupActivity.sync({ alter: true });
await Tournament.sync({ alter: true });
await TournamentGroup.sync({ alter: true });
await TournamentMember.sync({ alter: true });
await TournamentMatch.sync({ alter: true });
await TournamentResult.sync({ alter: true });
await Accident.sync({ alter: true });
await UserToken.sync({ alter: true });
app.listen(port, () => {
console.log(`Server is running on http://localhost:${port}`);

View File

@@ -8,8 +8,7 @@ class AccidentService {
await checkAccess(userToken, clubId);
const user = await getUserByToken(userToken);
if (!user) {
console.log('---------------');
throw new Error('User not found');
throw new Error('User not found');
}
const member = await Member.findByPk(memberId);
if (!member || member.clubId != clubId) {

View File

@@ -1,6 +1,7 @@
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import User from '../models/User.js';
import UserToken from '../models/UserToken.js';
import { sendActivationEmail } from './emailService.js';
const register = async (email, password) => {
@@ -25,22 +26,28 @@ const activateUser = async (activationCode) => {
};
const login = async (email, password) => {
if (!email || !password) {
throw { status: 400, message: 'Email und Passwort sind erforderlich.' };
}
const user = await User.findOne({ where: { email } });
if (!user || !user.isActive) throw new Error('Invalid email or password.');
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) throw new Error('Invalid email or password!');
const token = jwt.sign({ userId: user.hashedId }, process.env.JWT_SECRET, { expiresIn: '1h' });
user.authCode = token;
await user.save();
if (!user || !(await bcrypt.compare(password, user.password))) {
throw { status: 401, message: 'Ungültige Anmeldedaten' };
}
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, { expiresIn: '1h' });
await UserToken.create({
userId: user.id,
token,
expiresAt: new Date(Date.now() + 3600 * 1000),
});
return { token };
};
const logout = async(userId, authToken) => {
const user = await User.findOne({ where: { id: userId, authToken: authToken }});
if (!user) {
throw new Error('not found');
const logout = async (token) => {
if (!token) {
throw { status: 400, message: 'Token fehlt' };
}
user.update({ authToken: null });
}
await UserToken.destroy({ where: { token } });
return { message: 'Logout erfolgreich' };
};
export { register, activateUser, login, logout };

View File

@@ -31,7 +31,7 @@ class MatchService {
const matches = [];
try {
const fileStream = fs.createReadStream(filePath)
.pipe(iconv.decodeStream('ISO-8859-15'))
.pipe(iconv.decodeStream('utf8'))
.pipe(csv({ separator: ';' }));
for await (const row of fileStream) {
const parsedDate = parse(row['Termin'], 'dd.MM.yyyy HH:mm', new Date());
@@ -67,15 +67,11 @@ class MatchService {
clubId: clubId,
});
}
let season = null;
if (seasonString) {
const season = await Season.findOne({ where: { season: seasonString } });
season = await Season.findOne({ where: { season: seasonString } });
if (season) {
await Match.destroy({
where: {
clubId: clubId,
seasonId: season.id,
}
});
await Match.destroy({ where: { clubId, seasonId: season.id } });
}
}
const result = await Match.bulkCreate(matches);

View File

@@ -0,0 +1,580 @@
import Club from "../models/Club.js";
import Member from "../models/Member.js";
import Tournament from "../models/Tournament.js";
import TournamentGroup from "../models/TournamentGroup.js";
import TournamentMatch from "../models/TournamentMatch.js";
import TournamentMember from "../models/TournamentMember.js";
import TournamentResult from "../models/TournamentResult.js";
import { checkAccess } from '../utils/userUtils.js';
import { Op, literal } from 'sequelize';
function getRoundName(size) {
switch (size) {
case 2: return "Finale";
case 4: return "Halbfinale";
case 8: return "Viertelfinale";
case 16: return "Achtelfinale";
default: return `Runde der ${size}`;
}
}
function nextRoundName(currentName) {
switch (currentName) {
case "Achtelfinale": return "Viertelfinale";
case "Viertelfinale": return "Halbfinale";
case "Halbfinale": return "Finale";
default: return null;
}
}
class TournamentService {
// 1. Turniere listen
async getTournaments(userToken, clubId) {
await checkAccess(userToken, clubId);
const tournaments = await Tournament.findAll({
where: { clubId },
order: [['date', 'DESC']],
attributes: ['id', 'name', 'date']
});
return JSON.parse(JSON.stringify(tournaments));
}
// 2. Neues Turnier anlegen (prüft Duplikat)
async addTournament(userToken, clubId, tournamentName, date) {
await checkAccess(userToken, clubId);
const existing = await Tournament.findOne({ where: { clubId, date } });
if (existing) {
throw new Error('Ein Turnier mit diesem Datum existiert bereits');
}
const t = await Tournament.create({
name: tournamentName,
date,
clubId: +clubId,
bestOfEndroundSize: 0,
type: ''
});
return JSON.parse(JSON.stringify(t));
}
// 3. Teilnehmer hinzufügen (kein Duplikat)
async addParticipant(userToken, clubId, tournamentId, participantId) {
await checkAccess(userToken, clubId);
const tournament = await Tournament.findByPk(tournamentId);
if (!tournament || tournament.clubId != clubId) {
throw new Error('Turnier nicht gefunden');
}
const exists = await TournamentMember.findOne({
where: { tournamentId, clubMemberId: participantId }
});
if (exists) {
throw new Error('Teilnehmer bereits hinzugefügt');
}
await TournamentMember.create({
tournamentId,
clubMemberId: participantId,
groupId: null
});
}
// 4. Teilnehmerliste
async getParticipants(userToken, clubId, tournamentId) {
await checkAccess(userToken, clubId);
const tournament = await Tournament.findByPk(tournamentId);
if (!tournament || tournament.clubId != clubId) {
throw new Error('Turnier nicht gefunden');
}
return await TournamentMember.findAll({
where: { tournamentId },
include: [{
model: Member,
as: 'member',
attributes: ['id', 'firstName', 'lastName'],
}],
order: [[{ model: Member, as: 'member' }, 'firstName', 'ASC']]
});
}
// 5. Modus setzen (Gruppen / KORunde)
async setModus(userToken, clubId, tournamentId, type, numberOfGroups, advancingPerGroup) {
await checkAccess(userToken, clubId);
const tournament = await Tournament.findByPk(tournamentId);
if (!tournament || tournament.clubId != clubId) {
throw new Error('Turnier nicht gefunden');
}
await tournament.update({ type, numberOfGroups, advancingPerGroup });
}
// 6. Leere Gruppen anlegen
async createGroups(userToken, clubId, tournamentId) {
await checkAccess(userToken, clubId);
const tournament = await Tournament.findByPk(tournamentId);
if (!tournament || tournament.clubId != clubId) {
throw new Error('Turnier nicht gefunden');
}
const existing = await TournamentGroup.findAll({ where: { tournamentId } });
const desired = tournament.numberOfGroups;
// zu viele Gruppen löschen
if (existing.length > desired) {
const toRemove = existing.slice(desired);
await Promise.all(toRemove.map(g => g.destroy()));
}
// fehlende Gruppen anlegen
for (let i = existing.length; i < desired; i++) {
await TournamentGroup.create({ tournamentId });
}
}
// 7. Gruppen zufällig füllen & Spiele anlegen
generateRoundRobinSchedule(players) {
const list = [...players];
const n = list.length;
const hasBye = n % 2 === 1;
if (hasBye) list.push(null); // füge Bye hinzu
const total = list.length; // jetzt gerade Zahl
const rounds = [];
for (let round = 0; round < total - 1; round++) {
const pairs = [];
for (let i = 0; i < total / 2; i++) {
const p1 = list[i];
const p2 = list[total - 1 - i];
if (p1 && p2) {
pairs.push([p1.id, p2.id]);
}
}
rounds.push(pairs);
// Rotation (Fixpunkt list[0]):
list.splice(1, 0, list.pop());
}
return rounds;
}
// services/tournamentService.js
async fillGroups(userToken, clubId, tournamentId) {
await checkAccess(userToken, clubId);
const tournament = await Tournament.findByPk(tournamentId);
if (!tournament || tournament.clubId != clubId) {
throw new Error('Turnier nicht gefunden');
}
// 1) Hole vorhandene Gruppen
let groups = await TournamentGroup.findAll({ where: { tournamentId } });
// **Neu**: Falls noch keine Gruppen existieren, lege sie nach numberOfGroups an
if (!groups.length) {
const desired = tournament.numberOfGroups || 1; // Fallback auf 1, wenn undefiniert
for (let i = 0; i < desired; i++) {
await TournamentGroup.create({ tournamentId });
}
groups = await TournamentGroup.findAll({ where: { tournamentId } });
}
const members = await TournamentMember.findAll({ where: { tournamentId } });
if (!members.length) {
throw new Error('Keine Teilnehmer vorhanden.');
}
// 2) Alte Matches löschen
await TournamentMatch.destroy({ where: { tournamentId } });
// 3) Shuffle + verteilen
const shuffled = members.slice();
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
groups.forEach((g, idx) => {
shuffled
.filter((_, i) => i % groups.length === idx)
.forEach(m => m.update({ groupId: g.id }));
});
// 4) RoundRobin anlegen wie gehabt
for (const g of groups) {
const gm = await TournamentMember.findAll({ where: { groupId: g.id } });
const rounds = this.generateRoundRobinSchedule(gm);
for (let roundIndex = 0; roundIndex < rounds.length; roundIndex++) {
for (const [p1Id, p2Id] of rounds[roundIndex]) {
await TournamentMatch.create({
tournamentId,
groupId: g.id,
round: 'group',
player1Id: p1Id,
player2Id: p2Id,
groupRound: roundIndex + 1
});
}
}
}
// 5) Teilnehmer mit Gruppen zurückgeben
return await TournamentMember.findAll({ where: { tournamentId } });
}
async getGroups(userToken, clubId, tournamentId) {
await checkAccess(userToken, clubId);
const tournament = await Tournament.findByPk(tournamentId);
if (!tournament || tournament.clubId != clubId) {
throw new Error('Turnier nicht gefunden');
}
return await TournamentGroup.findAll({ where: { tournamentId } });
}
// 9. Gruppen mit ihren Teilnehmern
// services/tournamentService.js
async getGroupsWithParticipants(userToken, clubId, tournamentId) {
await checkAccess(userToken, clubId);
const tournament = await Tournament.findByPk(tournamentId);
if (!tournament || tournament.clubId != clubId) {
throw new Error('Turnier nicht gefunden');
}
const groups = await TournamentGroup.findAll({
where: { tournamentId },
include: [{
model: TournamentMember,
as: 'tournamentGroupMembers',
include: [{ model: Member, as: 'member', attributes: ['id', 'firstName', 'lastName'] }]
}],
order: [['id', 'ASC']]
});
// hier den Index mit aufnehmen:
return groups.map((g, idx) => ({
groupId: g.id,
groupNumber: idx + 1, // jetzt definiert
participants: g.tournamentGroupMembers.map(m => ({
id: m.id,
name: `${m.member.firstName} ${m.member.lastName}`
}))
}));
}
// 10. Einzelnes Turnier
async getTournament(userToken, clubId, tournamentId) {
await checkAccess(userToken, clubId);
const t = await Tournament.findOne({ where: { id: tournamentId, clubId } });
if (!t) throw new Error('Turnier nicht gefunden');
return t;
}
// 11. Spiele eines Turniers
async getTournamentMatches(userToken, clubId, tournamentId) {
await checkAccess(userToken, clubId);
const t = await Tournament.findOne({ where: { id: tournamentId, clubId } });
if (!t) throw new Error('Turnier nicht gefunden');
return await TournamentMatch.findAll({
where: { tournamentId },
include: [
{ model: TournamentMember, as: 'player1', include: [{ model: Member, as: 'member' }] },
{ model: TournamentMember, as: 'player2', include: [{ model: Member, as: 'member' }] },
{ model: TournamentResult, as: 'tournamentResults' }
],
order: [
['group_id', 'ASC'],
['group_round', 'ASC'],
['id', 'ASC'],
[{ model: TournamentResult, as: 'tournamentResults' }, 'set', 'ASC']
]
});
}
// 12. Satz-Ergebnis hinzufügen/überschreiben
async addMatchResult(userToken, clubId, tournamentId, matchId, set, result) {
await checkAccess(userToken, clubId);
const [match] = await TournamentMatch.findAll({ where: { id: matchId, tournamentId } });
if (!match) throw new Error('Match nicht gefunden');
const existing = await TournamentResult.findOne({ where: { matchId, set } });
if (existing) {
existing.pointsPlayer1 = +result.split(':')[0];
existing.pointsPlayer2 = +result.split(':')[1];
await existing.save();
} else {
const [p1, p2] = result.split(':').map(Number);
await TournamentResult.create({ matchId, set, pointsPlayer1: p1, pointsPlayer2: p2 });
}
}
async finishMatch(userToken, clubId, tournamentId, matchId) {
await checkAccess(userToken, clubId);
const match = await TournamentMatch.findByPk(matchId, {
include: [{ model: TournamentResult, as: "tournamentResults" }]
});
if (!match) throw new Error("Match nicht gefunden");
let win = 0, lose = 0;
for (const r of match.tournamentResults) {
if (r.pointsPlayer1 > r.pointsPlayer2) win++;
else lose++;
}
match.isFinished = true;
match.result = `${win}:${lose}`;
await match.save();
const allFinished = await TournamentMatch.count({
where: { tournamentId, round: match.round, isFinished: false }
}) === 0;
if (allFinished) {
const sameRound = await TournamentMatch.findAll({
where: { tournamentId, round: match.round }
});
const winners = sameRound.map(m => {
const [w1, w2] = m.result.split(":").map(n => +n);
return w1 > w2 ? m.player1Id : m.player2Id;
});
const nextName = nextRoundName(match.round);
if (nextName) {
for (let i = 0; i < winners.length / 2; i++) {
await TournamentMatch.create({
tournamentId,
round: nextName,
player1Id: winners[i],
player2Id: winners[winners.length - 1 - i]
});
}
}
}
}
async _determineQualifiers(tournamentId, tournament) {
const groups = await TournamentGroup.findAll({
where: { tournamentId },
include: [{ model: TournamentMember, as: "tournamentGroupMembers" }]
});
const groupMatches = await TournamentMatch.findAll({
where: { tournamentId, round: "group", isFinished: true }
});
const qualifiers = [];
for (const g of groups) {
const stats = {};
for (const tm of g.tournamentGroupMembers) {
stats[tm.id] = { member: tm, points: 0, setsWon: 0, setsLost: 0 };
}
for (const m of groupMatches.filter(m => m.groupId === g.id)) {
if (!stats[m.player1Id] || !stats[m.player2Id]) continue;
const [p1, p2] = m.result.split(":").map(n => parseInt(n, 10));
if (p1 > p2) stats[m.player1Id].points += 2;
else stats[m.player2Id].points += 2;
stats[m.player1Id].setsWon += p1;
stats[m.player1Id].setsLost += p2;
stats[m.player2Id].setsWon += p2;
stats[m.player2Id].setsLost += p1;
}
const ranked = Object.values(stats).sort((a, b) => {
const diffA = a.setsWon - a.setsLost;
const diffB = b.setsWon - b.setsLost;
if (b.points !== a.points) return b.points - a.points;
if (diffB !== diffA) return diffB - diffA;
if (b.setsWon !== a.setsWon) return b.setsWon - a.setsWon;
return a.member.id - b.member.id;
});
qualifiers.push(...ranked.slice(0, tournament.advancingPerGroup).map(r => r.member));
}
return qualifiers;
}
async startKnockout(userToken, clubId, tournamentId) {
await checkAccess(userToken, clubId);
const t = await Tournament.findByPk(tournamentId);
if (!t || t.clubId != clubId) throw new Error("Tournament not found");
if (t.type === "groups") {
const unfinished = await TournamentMatch.count({
where: { tournamentId, round: "group", isFinished: false }
});
if (unfinished > 0) {
throw new Error(
"Turnier ist im Gruppenmodus, K.o.-Runde kann erst nach Abschluss aller Gruppenspiele gestartet werden."
);
}
}
const qualifiers = await this._determineQualifiers(tournamentId, t);
if (qualifiers.length < 2) throw new Error("Zu wenige Qualifikanten für K.O.-Runde");
await TournamentMatch.destroy({
where: { tournamentId, round: { [Op.ne]: "group" } }
});
const roundSize = qualifiers.length;
const rn = getRoundName(roundSize);
for (let i = 0; i < roundSize / 2; i++) {
await TournamentMatch.create({
tournamentId,
round: rn,
player1Id: qualifiers[i].id,
player2Id: qualifiers[roundSize - 1 - i].id
});
}
}
async manualAssignGroups(
userToken,
clubId,
tournamentId,
assignments,
numberOfGroups,
maxGroupSize
) {
await checkAccess(userToken, clubId);
// 1) Turnier und Teilnehmerzahl validieren
const tournament = await Tournament.findByPk(tournamentId);
if (!tournament || tournament.clubId != clubId) {
throw new Error('Turnier nicht gefunden');
}
const totalMembers = assignments.length;
if (totalMembers === 0) {
throw new Error('Keine Teilnehmer zum Verteilen');
}
// 2) Bestimme, wie viele Gruppen wir anlegen
let groupCount;
if (numberOfGroups != null) {
groupCount = Number(numberOfGroups);
if (isNaN(groupCount) || groupCount < 1) {
throw new Error('Ungültige Anzahl Gruppen');
}
} else if (maxGroupSize != null) {
const sz = Number(maxGroupSize);
if (isNaN(sz) || sz < 1) {
throw new Error('Ungültige maximale Gruppengröße');
}
groupCount = Math.ceil(totalMembers / sz);
} else {
// Fallback auf im Turnier gespeicherte Anzahl
groupCount = tournament.numberOfGroups;
if (!groupCount || groupCount < 1) {
throw new Error('Anzahl Gruppen nicht definiert');
}
}
// 3) Alte Gruppen löschen und neue anlegen
await TournamentGroup.destroy({ where: { tournamentId } });
const createdGroups = [];
for (let i = 0; i < groupCount; i++) {
const grp = await TournamentGroup.create({ tournamentId });
createdGroups.push(grp);
}
// 4) Mapping von UINummer (1…groupCount) auf reale DBID
const groupMap = {};
createdGroups.forEach((grp, idx) => {
groupMap[idx + 1] = grp.id;
});
// 5) Teilnehmer updaten
await Promise.all(
assignments.map(({ participantId, groupNumber }) => {
const dbGroupId = groupMap[groupNumber];
if (!dbGroupId) {
throw new Error(`Ungültige GruppenNummer: ${groupNumber}`);
}
return TournamentMember.update(
{ groupId: dbGroupId },
{ where: { id: participantId } }
);
})
);
// 6) Ergebnis zurückliefern wie getGroupsWithParticipants
const groups = await TournamentGroup.findAll({
where: { tournamentId },
include: [{
model: TournamentMember,
as: 'tournamentGroupMembers',
include: [{ model: Member, as: 'member', attributes: ['id', 'firstName', 'lastName'] }]
}],
order: [['id', 'ASC']]
});
return groups.map(g => ({
groupId: g.id,
participants: g.tournamentGroupMembers.map(m => ({
id: m.id,
name: `${m.member.firstName} ${m.member.lastName}`
}))
}));
}
// services/tournamentService.js
async resetGroups(userToken, clubId, tournamentId) {
await checkAccess(userToken, clubId);
// löscht alle Gruppen … (inkl. CASCADE oder manuell TournamentMatch.destroy)
await TournamentMatch.destroy({ where: { tournamentId } });
await TournamentGroup.destroy({ where: { tournamentId } });
}
async resetMatches(userToken, clubId, tournamentId) {
await checkAccess(userToken, clubId);
await TournamentMatch.destroy({ where: { tournamentId } });
}
async removeParticipant(userToken, clubId, tournamentId, participantId) {
await checkAccess(userToken, clubId);
await TournamentMember.destroy({
where: { id: participantId, tournamentId }
});
}
// services/tournamentService.js
async deleteMatchResult(userToken, clubId, tournamentId, matchId, setToDelete) {
await checkAccess(userToken, clubId);
// Match existiert?
const match = await TournamentMatch.findOne({ where: { id: matchId, tournamentId } });
if (!match) throw new Error('Match nicht gefunden');
// Satz löschen
await TournamentResult.destroy({ where: { matchId, set: setToDelete } });
// verbleibende Sätze neu durchnummerieren
const remaining = await TournamentResult.findAll({
where: { matchId },
order: [['set', 'ASC']]
});
for (let i = 0; i < remaining.length; i++) {
const r = remaining[i];
const newSet = i + 1;
if (r.set !== newSet) {
r.set = newSet;
await r.save();
}
}
}
async reopenMatch(userToken, clubId, tournamentId, matchId) {
await checkAccess(userToken, clubId);
const match = await TournamentMatch.findOne({
where: { id: matchId, tournamentId }
});
if (!match) {
throw new Error("Match nicht gefunden");
}
// Nur den AbschlussStatus zurücksetzen, nicht die Einzelsätze
match.isFinished = false;
match.result = null; // optional: entfernt die zusammengefasste ErgebnisSpalte
await match.save();
}
async resetKnockout(userToken, clubId, tournamentId) {
await checkAccess(userToken, clubId);
// lösche alle Matches außer Gruppenphase
await TournamentMatch.destroy({
where: {
tournamentId,
round: { [Op.ne]: "group" }
}
});
}
}
export default new TournamentService();

View File

@@ -1,24 +1,45 @@
import User from '../models/User.js'
import UserClub from '../models/UserClub.js';
import jwt from 'jsonwebtoken';
import { Op } from 'sequelize';
import User from '../models/User.js';
import UserToken from '../models/UserToken.js';
import UserClub from '../models/UserClub.js'; // <-- hier hinzufügen
import HttpError from '../exceptions/HttpError.js';
import { config } from 'dotenv';
config(); // sorgt dafür, dass process.env.JWT_SECRET geladen wird
export const getUserByToken = async(token) => {
export const getUserByToken = async (token) => {
try {
const user = await User.findOne({
where: [
{auth_code: token}
]
});
return user;
} catch (error) {
console.log(error);
const err = new HttpError('noaccess', 403);
throw err;
}
}
// 1. JWT validieren
const payload = jwt.verify(token, process.env.JWT_SECRET);
export const hasUserClubAccess = async(userId, clubId) => {
// 2. Token-Eintrag prüfen (existiert und nicht abgelaufen)
const stored = await UserToken.findOne({
where: {
token,
expiresAt: { [Op.gt]: new Date() }
}
});
if (!stored) {
throw new HttpError('Token abgelaufen oder ungültig', 401);
}
// 3. User laden
const user = await User.findByPk(payload.userId);
if (!user) {
throw new HttpError('Benutzer nicht gefunden', 404);
}
return user;
} catch (err) {
console.error(err);
// Falls es ein HttpError ist, einfach weiterwerfen
if (err instanceof HttpError) throw err;
// ansonsten pauschal „noaccess“
throw new HttpError('noaccess', 403);
}
};
export const hasUserClubAccess = async (userId, clubId) => {
try {
console.log('[hasUserClubAccess]');
const userClub = await UserClub.findOne({
@@ -29,23 +50,23 @@ export const hasUserClubAccess = async(userId, clubId) => {
}
});
return userClub !== null;
} catch(error) {
} catch (error) {
console.log(error);
throw new HttpError('notfound', 500);
}
console.log('---- no user found');
}
export const checkAccess = async(userToken, clubId) => {
export const checkAccess = async (userToken, clubId) => {
try {
const user = await getUserByToken(userToken);
if (!await hasUserClubAccess(user.id, clubId)) {
const hasAccess = await hasUserClubAccess(user.id, clubId);
if (!hasAccess) {
console.log('no club access');
const err = new HttpError('noaccess', 403);
throw err;
throw new HttpError('noaccess', 403);
}
} catch (error) {
console.log(error);
throw error;
throw error;
}
}
};

309
frontend/DESIGN_GUIDE.md Normal file
View File

@@ -0,0 +1,309 @@
# TrainingsTagebuch Design Guide
## Übersicht
Das TrainingsTagebuch verwendet ein modernes, frisches Design mit einer konsistenten Farbpalette und modernen UI-Elementen. Alle bestehenden Farben wurden beibehalten, aber mit verbesserten Schatten, Abständen und Animationen versehen.
## Farbpalette
### Primärfarben
- **Primärgrün**: `#4CAF50` - Hauptfarbe für Buttons und Akzente
- **Primärgrün Hover**: `#45a049` - Hover-Zustand für Primärfarbe
- **Sekundärbraun**: `#a07040` - Sekundärfarbe für Navigation und Menüs
- **Sekundärbraun Hover**: `#804b29` - Hover-Zustand für Sekundärfarbe
### Neutrale Farben
- **Text Primär**: `#333` - Haupttextfarbe
- **Text Sekundär**: `#666` - Sekundärtextfarbe
- **Text Hell**: `#999` - Helle Textfarbe
- **Hintergrund Hell**: `#f8f9fa` - Haupthintergrundfarbe
- **Rahmenfarbe**: `#e9ecef` - Rahmen und Trennlinien
## Design-Tokens
### Schatten
- **Schatten Hell**: `0 2px 8px rgba(0, 0, 0, 0.1)`
- **Schatten Mittel**: `0 4px 16px rgba(0, 0, 0, 0.15)`
- **Schatten Schwer**: `0 8px 32px rgba(0, 0, 0, 0.2)`
### Abstände
- **Border Radius**: `8px` (Standard), `12px` (Groß)
- **Transition**: `all 0.3s cubic-bezier(0.4, 0, 0.2, 1)`
## Typografie
### Schriftarten
- **Primär**: Inter (mit Fallbacks zu System-Schriften)
- **Größen**:
- Basis: `16px`
- Klein: `14px` (Mobile)
- Überschriften: `1.5rem` - `2rem`
### Zeilenhöhe
- **Standard**: `1.6` für optimale Lesbarkeit
## Komponenten
### Buttons
#### Primärer Button
```html
<button class="btn-primary">Primärer Button</button>
```
#### Sekundärer Button
```html
<button class="btn-secondary">Sekundärer Button</button>
```
#### Button-Varianten
- `.btn-primary` - Primärer Button (Grün)
- `.btn-secondary` - Sekundärer Button (Braun)
- `.btn-primary.small` - Kleiner Button
- `.btn-primary.large` - Großer Button
- `.btn-primary.icon` - Icon-Button (rund)
### Karten
#### Standard-Karte
```html
<div class="card">
<div class="card-header">
<h3 class="card-title">Kartentitel</h3>
</div>
<div class="card-body">
<p>Karteninhalt</p>
</div>
</div>
```
### Formularelemente
#### Input-Felder
```html
<input type="text" placeholder="Text eingeben">
<select>
<option>Option wählen</option>
</select>
<textarea placeholder="Mehrzeiliger Text"></textarea>
```
### Tabellen
#### Standard-Tabelle
```html
<table>
<thead>
<tr>
<th>Spalte 1</th>
<th>Spalte 2</th>
</tr>
</thead>
<tbody>
<tr>
<td>Daten 1</td>
<td>Daten 2</td>
</tr>
</tbody>
</table>
```
### Alerts
#### Verschiedene Alert-Typen
```html
<div class="alert alert-success">
<span class="alert-icon"></span>
<div class="alert-content">
<div class="alert-title">Erfolg!</div>
<div class="alert-message">Operation erfolgreich abgeschlossen.</div>
</div>
</div>
<div class="alert alert-danger">
<span class="alert-icon"></span>
<div class="alert-content">
<div class="alert-title">Fehler!</div>
<div class="alert-message">Ein Fehler ist aufgetreten.</div>
</div>
</div>
```
### Badges
#### Verschiedene Badge-Typen
```html
<span class="badge badge-primary">Primär</span>
<span class="badge badge-secondary">Sekundär</span>
<span class="badge badge-success">Erfolg</span>
<span class="badge badge-danger">Gefahr</span>
<span class="badge badge-warning">Warnung</span>
<span class="badge badge-info">Info</span>
```
### Progress-Bars
#### Standard-Progress-Bar
```html
<div class="progress">
<div class="progress-bar" style="width: 75%">75%</div>
</div>
```
### Tabs
#### Tab-Navigation
```html
<div class="tabs">
<ul class="tab-list">
<li class="tab-item">
<button class="tab-button active">Tab 1</button>
</li>
<li class="tab-item">
<button class="tab-button">Tab 2</button>
</li>
</ul>
</div>
<div class="tab-content active">
<p>Inhalt von Tab 1</p>
</div>
```
### Accordion
#### Akkordeon-Element
```html
<div class="accordion">
<div class="accordion-item">
<button class="accordion-header">
<span>Akkordeon-Titel</span>
<span class="accordion-icon"></span>
</button>
<div class="accordion-content">
<div class="accordion-body">
<p>Akkordeon-Inhalt</p>
</div>
</div>
</div>
</div>
```
### Breadcrumbs
#### Navigationspfad
```html
<nav class="breadcrumb">
<div class="breadcrumb-item">
<a href="/" class="breadcrumb-link">Start</a>
</div>
<span class="breadcrumb-separator">/</span>
<div class="breadcrumb-item">
<a href="/verein" class="breadcrumb-link">Verein</a>
</div>
<span class="breadcrumb-separator">/</span>
<div class="breadcrumb-item">
<span class="breadcrumb-current">Mitglieder</span>
</div>
</nav>
```
### Pagination
#### Seitenzahlen
```html
<nav class="pagination">
<ul>
<li class="page-item">
<a href="#" class="page-link">1</a>
</li>
<li class="page-item">
<a href="#" class="page-link active">2</a>
</li>
<li class="page-item">
<a href="#" class="page-link">3</a>
</li>
</ul>
</nav>
```
## Utility-Klassen
### Abstände
- `.mb-1` bis `.mb-5` - Margin Bottom
- `.mt-1` bis `.mt-5` - Margin Top
- `.p-1` bis `.p-5` - Padding
- `.gap-1` bis `.gap-5` - Gap zwischen Flexbox-Elementen
### Ausrichtung
- `.text-center` - Text zentrieren
- `.text-left` - Text links ausrichten
- `.text-right` - Text rechts ausrichten
### Flexbox
- `.d-flex` - Display Flex
- `.flex-column` - Flex Direction Column
- `.justify-content-center` - Zentrieren
- `.align-items-center` - Vertikal zentrieren
### Farben
- `.text-primary` - Primärfarbe für Text
- `.text-secondary` - Sekundärfarbe für Text
- `.bg-primary` - Primärfarbe für Hintergrund
- `.bg-secondary` - Sekundärfarbe für Hintergrund
### Schatten
- `.shadow-sm` - Kleiner Schatten
- `.shadow` - Standard-Schatten
- `.shadow-md` - Mittlerer Schatten
- `.shadow-lg` - Großer Schatten
- `.shadow-xl` - Sehr großer Schatten
## Responsive Design
### Breakpoints
- **Desktop**: `> 1024px`
- **Tablet**: `768px - 1024px`
- **Mobile**: `< 768px`
- **Small Mobile**: `< 480px`
### Mobile-First Ansatz
Alle Komponenten sind mobile-first entwickelt und werden für größere Bildschirme erweitert.
## Animationen
### Standard-Animationen
- **Fade In**: `.fade-in` - Sanftes Einblenden
- **Slide In**: `.slide-in` - Von links einschieben
- **Hover-Effekte**: Alle interaktiven Elemente haben sanfte Hover-Übergänge
### Transition-Zeiten
- **Standard**: `0.3s` für alle Übergänge
- **Easing**: `cubic-bezier(0.4, 0, 0.2, 1)` für natürliche Bewegungen
## Verwendung
### CSS-Import
```scss
// In Ihrer Vue-Komponente
<style lang="scss">
@import '@/assets/css/main.scss';
// Ihre spezifischen Styles hier
</style>
```
### Komponenten verwenden
Alle Komponenten sind sofort verfügbar, sobald die CSS-Dateien importiert sind. Es sind keine zusätzlichen JavaScript-Bibliotheken erforderlich.
## Best Practices
1. **Konsistenz**: Verwenden Sie immer die vordefinierten Farben und Abstände
2. **Accessibility**: Alle interaktiven Elemente haben Hover- und Focus-States
3. **Performance**: Animationen verwenden CSS-Transforms und Opacity für optimale Performance
4. **Mobile**: Testen Sie alle Komponenten auf verschiedenen Bildschirmgrößen
## Browser-Support
- **Modern**: Chrome 90+, Firefox 88+, Safari 14+, Edge 90+
- **Fallbacks**: Ältere Browser erhalten eine funktionale, aber weniger animierte Version

View File

@@ -7,7 +7,6 @@
<title>Trainingstagebuch</title>
</head>
<body>
<h1>Trainingstagebuch</h1>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,8 @@
"axios": "^1.7.3",
"core-js": "^3.8.3",
"html2canvas": "^1.4.1",
"jspdf": "^2.5.1",
"jspdf": "^2.5.2",
"jspdf-autotable": "^5.0.2",
"sortablejs": "^1.15.3",
"vue": "^3.2.13",
"vue-multiselect": "^3.0.0",

View File

@@ -1,40 +1,80 @@
<template>
<div class="main">
<button class="menu-toggle" @click="toggleMenu">
{{ isMenuOpen ? 'Menü schließen' : 'Menü öffnen' }}
</button>
<header class="app-header">
<h1>Trainingstagebuch</h1>
</header>
<div v-if="isAuthenticated" class="navigation" :class="{ 'menu-open': isMenuOpen }">
<div class="club-selector">
<div>
Verein:
<select v-model="selectedClub">
<option value="">---</option>
<option value="new">Neuer Verein</option>
<option v-for="club in clubs" :key="club.id" :value="club.id">{{ club.name }}</option>
</select>
<button @click="loadClub">-&gt;</button>
<div class="app-container">
<aside v-if="isAuthenticated" class="sidebar">
<div class="sidebar-content">
<div class="club-selector card">
<h3 class="card-title">Verein auswählen</h3>
<div class="select-group">
<select v-model="selectedClub" class="club-select">
<option value="">Verein wählen...</option>
<option value="new">Neuer Verein</option>
<option v-for="club in clubs" :key="club.id" :value="club.id">{{ club.name }}</option>
</select>
<button @click="loadClub" class="btn-primary" :disabled="!selectedClub">
<span>Laden</span>
</button>
</div>
</div>
<nav v-if="selectedClub" class="nav-menu">
<div class="nav-section">
<h4 class="nav-title">Verwaltung</h4>
<a href="/members" class="nav-link">
<span class="nav-icon">👥</span>
Mitglieder
</a>
<a href="/diary" class="nav-link">
<span class="nav-icon">📝</span>
Tagebuch
</a>
<a href="/pending-approvals" class="nav-link">
<span class="nav-icon"></span>
Freigaben
</a>
<a href="/training-stats" class="nav-link">
<span class="nav-icon">📊</span>
Trainings-Statistik
</a>
</div>
<div class="nav-section">
<h4 class="nav-title">Organisation</h4>
<a href="/schedule" class="nav-link">
<span class="nav-icon">📅</span>
Spielpläne
</a>
<a href="/tournaments" class="nav-link">
<span class="nav-icon">🏆</span>
Turniere
</a>
</div>
</nav>
<div class="sidebar-footer">
<button @click="logout()" class="btn-secondary logout-btn">
<span class="nav-icon">🚪</span>
Ausloggen
</button>
</div>
</div>
</aside>
<div v-else class="auth-nav">
<div class="auth-links">
<a href="/login" class="btn-primary">Einloggen</a>
<a href="/register" class="btn-secondary">Registrieren</a>
</div>
</div>
<div v-if="selectedClub" class="nav-links">
<a href="/members">Mitglieder</a>
<a href="/diary">Tagebuch</a>
<a href="/pending-approvals">Freigaben</a>
<a href="/schedule">Spielpläne</a>
</div>
<div class="logout-btn">
<button @click="logout()">Ausloggen</button>
</div>
<main class="main-content">
<router-view class="content fade-in"></router-view>
</main>
</div>
<div v-else class="navigation">
<a href="/login">Einloggen</a>
<a href="/register">Registrieren</a>
</div>
<router-view class="content"></router-view>
</div>
</template>
@@ -47,7 +87,6 @@ export default {
data() {
return {
selectedClub: null,
isMenuOpen: false,
sessionInterval: null,
};
},
@@ -74,10 +113,6 @@ export default {
this.$router.push(`/showclub/${this.currentClub}`);
},
toggleMenu() {
this.isMenuOpen = !this.isMenuOpen;
},
async checkSession() {
try {
const response = await apiClient.get('/session/status');
@@ -85,8 +120,9 @@ export default {
this.handleLogout();
}
} catch (error) {
console.error('Session check failed:', error);
this.handleLogout();
this.isAuthenticated = false;
this.username = '';
this.currentClub = '';
}
},
@@ -118,159 +154,343 @@ export default {
</script>
<style scoped>
/* Main Container */
.main {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
height: 100vh;
overflow: hidden;
}
.navigation {
background-color: #e0f0e8;
display: flex;
flex-direction: column;
padding: 0.5rem;
}
.club-selector,
.logout-btn {
margin-bottom: 1rem;
}
.nav-links {
display: flex;
flex-direction: column;
margin-bottom: 1rem;
}
.navigation>a {
text-decoration: none;
margin: 0.3em 0;
color: #a07040;
}
.content {
flex: 1;
padding: 0.5em;
overflow: auto;
}
button {
padding: 0.3em 0.6em;
background-color: #a07040;
/* Header */
.app-header {
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
color: white;
border: none;
cursor: pointer;
box-shadow: var(--shadow-medium);
position: relative;
z-index: 1000;
flex-shrink: 0;
}
button:hover {
background-color: #804b29;
.header-content {
display: flex;
justify-content: center;
align-items: center;
padding: 0 0.75rem;
height: 3rem;
}
select {
padding: 0.3em;
border: 1px solid #ccc;
/* Styling für das erste h1 (aus main.scss) - Design vom zweiten, aber ursprüngliche Schriftgröße */
.app-header h1 {
margin: 0;
font-weight: 700;
color: white;
text-align: center;
/* Schriftgröße bleibt wie in der main.scss definiert */
}
/* Menü-Toggle-Button nur auf kleinen Bildschirmen anzeigen */
.menu-toggle {
/* App Container */
.app-container {
display: flex;
flex: 1;
overflow: hidden;
min-height: 0;
}
/* Sidebar */
.sidebar {
width: 280px;
background: white;
border-right: 1px solid var(--border-color);
box-shadow: var(--shadow-small);
overflow-y: auto;
flex-shrink: 0;
display: flex;
flex-direction: column;
}
.sidebar-content {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
flex: 1;
min-height: 0;
}
.club-selector {
padding: 0.75rem;
margin-bottom: 0.5rem;
flex-shrink: 0;
}
.club-selector .card-title {
font-size: 0.875rem;
margin-bottom: 0.5rem;
color: var(--text-color);
font-weight: 600;
}
.select-group {
display: flex;
gap: 0.375rem;
align-items: center;
}
.club-select {
flex: 1;
padding: 0.375rem 0.5rem;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-small);
font-size: 0.75rem;
background: white;
color: var(--text-color);
}
.select-group .btn-primary {
padding: 0.375rem 0.5rem;
font-size: 0.75rem;
white-space: nowrap;
}
/* Navigation Menu */
.nav-menu {
display: flex;
flex-direction: column;
gap: 0.75rem;
flex: 1;
min-height: 0;
overflow-y: auto;
}
.nav-section {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex-shrink: 0;
}
.nav-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.025em;
margin-bottom: 0.25rem;
padding: 0 0.25rem;
}
.nav-link {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.5rem;
color: var(--text-color);
text-decoration: none;
border-radius: var(--border-radius-small);
transition: all var(--transition-fast);
font-size: 0.75rem;
}
.nav-link:hover {
background: var(--primary-light);
color: var(--primary-color);
transform: translateX(0.125rem);
}
.nav-icon {
font-size: 0.875rem;
width: 1rem;
text-align: center;
}
/* Sidebar Footer */
.sidebar-footer {
margin-top: auto;
padding-top: 0.75rem;
border-top: 1px solid var(--border-color);
flex-shrink: 0;
}
.logout-btn {
width: 100%;
padding: 0.5rem;
font-size: 0.75rem;
justify-content: center;
}
/* Auth Navigation */
.auth-nav {
width: 260px;
background: white;
border-right: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.auth-links {
display: flex;
flex-direction: column;
gap: 0.75rem;
width: 100%;
}
.auth-links a {
text-align: center;
padding: 0.75rem;
border-radius: var(--border-radius);
text-decoration: none;
font-weight: 500;
transition: var(--transition);
}
.auth-links a:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-medium);
}
.auth-links a::after {
display: none;
}
/* Main Content */
.main-content {
flex: 1;
overflow-y: auto;
background: var(--background-light);
min-height: 0;
}
.content {
padding: 1.5rem;
min-height: 100%;
}
/* Responsive Design */
@media (min-width: 768px) {
.main {
flex-direction: row;
@media (max-width: 1024px) {
.sidebar {
width: 240px;
}
.navigation {
width: 13em;
padding: 1rem;
}
.content {
padding: 1rem;
height: calc(100% - 2.5rem);
}
.nav-links {
flex-direction: column;
}
.nav-links>a {
margin: 0 1em;
}
select {
width: 100%;
padding: 1.25rem;
}
}
@media (max-width: 767px) {
.main {
flex-direction: column;
@media (max-width: 768px) {
.sidebar {
width: 220px;
}
.navigation {
display: none;
flex-direction: column;
width: 100%;
}
/* Das Menü anzeigen, wenn es geöffnet ist */
.menu-open {
display: flex;
}
.content {
padding: 0.75rem;
}
.header-content {
padding: 0 0.5rem;
}
.app-header h1 {
font-size: 1.125rem;
}
.sidebar-content {
padding: 0.5rem;
}
.nav-links {
display: block;
}
.nav-links>a {
display: block;
margin: 0.5em 0;
}
/* Menü-Toggle-Button nur auf kleinen Bildschirmen */
.menu-toggle {
display: block;
background-color: #a07040;
color: white;
border: none;
padding: 0.5em;
margin: 0.5em;
cursor: pointer;
}
.menu-toggle:hover {
background-color: #804b29;
.main-content {
overflow-y: auto;
}
}
@media (max-width: 480px) {
.navigation {
font-size: 1.5em;
}
select {
.sidebar {
width: 100%;
position: fixed;
top: 3rem;
left: 0;
height: calc(100vh - 3rem);
z-index: 999;
}
button {
width: 100%;
margin-top: 0.5em;
.content {
padding: 0.625rem;
}
.nav-links>a {
font-size: 0.85em;
padding: 0.5em;
border-bottom: 1px solid #ddd;
.header-content {
padding: 0 0.5rem;
}
.app-header h1 {
font-size: 1rem;
}
.sidebar-content {
padding: 0.5rem;
}
.main-content {
margin-left: 0;
overflow-y: auto;
}
}
/* Button-Varianten */
.btn-primary {
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
color: white;
border: none;
border-radius: var(--border-radius);
padding: 0.5rem 1rem;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
box-shadow: var(--shadow-light);
transition: var(--transition);
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.25rem;
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-medium);
color: white;
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: var(--shadow-light);
}
.btn-secondary {
background: white;
color: var(--secondary-color);
border: 1.5px solid var(--secondary-color);
border-radius: var(--border-radius);
padding: 0.5rem 1rem;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
box-shadow: var(--shadow-light);
transition: var(--transition);
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.25rem;
}
.btn-secondary:hover {
background: var(--secondary-color);
color: white;
transform: translateY(-1px);
box-shadow: var(--shadow-medium);
}
</style>

View File

@@ -0,0 +1,500 @@
/* Moderne UI-Komponenten für TrainingsTagebuch */
/* Alert-Komponenten */
.alert {
padding: 0.75rem 1.25rem;
margin: 0.75rem 0;
border: 1px solid transparent;
border-radius: var(--border-radius);
position: relative;
display: flex;
align-items: flex-start;
gap: 0.625rem;
}
.alert-icon {
font-size: 1.125rem;
flex-shrink: 0;
margin-top: 0.125rem;
}
.alert-content {
flex: 1;
}
.alert-title {
font-weight: 600;
margin: 0 0 0.125rem 0;
font-size: 0.9rem;
}
.alert-message {
margin: 0;
line-height: 1.4;
}
.alert-success {
background-color: rgba(40, 167, 69, 0.08);
border-color: rgba(40, 167, 69, 0.25);
color: #155724;
}
.alert-info {
background-color: rgba(23, 162, 184, 0.08);
border-color: rgba(23, 162, 184, 0.25);
color: #0c5460;
}
.alert-warning {
background-color: rgba(255, 193, 7, 0.08);
border-color: rgba(255, 193, 7, 0.25);
color: #856404;
}
.alert-danger {
background-color: rgba(220, 53, 69, 0.08);
border-color: rgba(220, 53, 69, 0.25);
color: #721c24;
}
/* Badge-Komponenten */
.badge {
display: inline-flex;
align-items: center;
padding: 0.125rem 0.625rem;
font-size: 0.7rem;
font-weight: 500;
line-height: 1;
text-align: center;
white-space: nowrap;
vertical-align: baseline;
border-radius: 9999px;
background-color: var(--text-light);
color: white;
}
.badge-primary {
background-color: var(--primary-color);
}
.badge-secondary {
background-color: var(--secondary-color);
}
.badge-success {
background-color: #28a745;
}
.badge-danger {
background-color: #dc3545;
}
.badge-warning {
background-color: #ffc107;
color: #212529;
}
.badge-info {
background-color: #17a2b8;
}
.badge-light {
background-color: #f8f9fa;
color: #212529;
}
.badge-dark {
background-color: #343a40;
}
/* Progress-Bar */
.progress {
height: 0.625rem;
background-color: #e9ecef;
border-radius: var(--border-radius);
overflow: hidden;
margin: 0.75rem 0;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--primary-color), var(--primary-hover));
transition: width 0.5s ease;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 0.7rem;
font-weight: 500;
}
/* Tooltip */
.tooltip {
position: relative;
display: inline-block;
}
.tooltip .tooltip-text {
visibility: hidden;
width: 180px;
background-color: #333;
color: white;
text-align: center;
border-radius: var(--border-radius);
padding: 0.375rem;
position: absolute;
z-index: 1;
bottom: 125%;
left: 50%;
margin-left: -90px;
opacity: 0;
transition: opacity 0.25s;
font-size: 0.8rem;
line-height: 1.3;
}
.tooltip .tooltip-text::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
margin-left: -4px;
border-width: 4px;
border-style: solid;
border-color: #333 transparent transparent transparent;
}
.tooltip:hover .tooltip-text {
visibility: visible;
opacity: 1;
}
/* Modal-ähnliche Overlays */
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
opacity: 0;
visibility: hidden;
transition: all 0.25s ease;
}
.overlay.active {
opacity: 1;
visibility: visible;
}
.overlay-content {
background: white;
border-radius: var(--border-radius-large);
padding: 1.5rem;
max-width: 90vw;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow-heavy);
transform: scale(0.9);
transition: transform 0.25s ease;
}
.overlay.active .overlay-content {
transform: scale(1);
}
/* Skeleton Loading */
.skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
border-radius: var(--border-radius);
}
.skeleton-text {
height: 0.875rem;
margin-bottom: 0.375rem;
}
.skeleton-text:last-child {
width: 60%;
}
.skeleton-avatar {
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
}
.skeleton-button {
height: 2.25rem;
width: 7rem;
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* Tabs */
.tabs {
border-bottom: 1px solid var(--border-color);
margin-bottom: 1.5rem;
}
.tab-list {
display: flex;
list-style: none;
margin: 0;
padding: 0;
gap: 0.375rem;
}
.tab-item {
margin: 0;
}
.tab-button {
background: none;
border: none;
padding: 0.625rem 1.25rem;
color: var(--text-secondary);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: var(--transition);
font-weight: 500;
min-height: auto;
margin: 0;
box-shadow: none;
}
.tab-button:hover {
color: var(--primary-color);
transform: none;
box-shadow: none;
}
.tab-button.active {
color: var(--primary-color);
border-bottom-color: var(--primary-color);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* Accordion */
.accordion {
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
overflow: hidden;
}
.accordion-item {
border-bottom: 1px solid var(--border-color);
}
.accordion-item:last-child {
border-bottom: none;
}
.accordion-header {
background: none;
border: none;
width: 100%;
padding: 0.875rem 1.25rem;
text-align: left;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 500;
color: var(--text-primary);
transition: var(--transition);
min-height: auto;
margin: 0;
box-shadow: none;
}
.accordion-header:hover {
background-color: rgba(76, 175, 80, 0.04);
transform: none;
box-shadow: none;
}
.accordion-icon {
transition: transform 0.25s ease;
font-size: 1.125rem;
color: var(--text-light);
}
.accordion-header.active .accordion-icon {
transform: rotate(180deg);
}
.accordion-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.25s ease;
}
.accordion-content.active {
max-height: 400px;
}
.accordion-body {
padding: 0 1.25rem 1.25rem;
color: var(--text-secondary);
line-height: 1.5;
}
/* Breadcrumb */
.breadcrumb {
display: flex;
align-items: center;
gap: 0.375rem;
margin: 0.75rem 0;
font-size: 0.8rem;
color: var(--text-light);
}
.breadcrumb-item {
display: flex;
align-items: center;
}
.breadcrumb-separator {
margin: 0 0.375rem;
color: var(--text-light);
}
.breadcrumb-link {
color: var(--primary-color);
text-decoration: none;
transition: var(--transition);
}
.breadcrumb-link:hover {
color: var(--primary-hover);
text-decoration: underline;
}
.breadcrumb-current {
color: var(--text-secondary);
font-weight: 500;
}
/* Pagination */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 0.125rem;
margin: 1.5rem 0;
}
.page-item {
list-style: none;
margin: 0;
}
.page-link {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border: 1px solid var(--border-color);
background: white;
color: var(--text-primary);
text-decoration: none;
border-radius: var(--border-radius);
transition: var(--transition);
font-weight: 500;
}
.page-link:hover {
background-color: var(--primary-color);
color: white;
border-color: var(--primary-color);
transform: translateY(-1px);
box-shadow: var(--shadow-light);
}
.page-link.active {
background-color: var(--primary-color);
color: white;
border-color: var(--primary-color);
}
.page-link.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
/* Responsive Design für Komponenten */
@media (max-width: 768px) {
.overlay-content {
margin: 0.75rem;
padding: 1.25rem;
}
.tab-list {
flex-wrap: wrap;
}
.tab-button {
padding: 0.5rem 0.875rem;
font-size: 0.8rem;
}
.accordion-header {
padding: 0.75rem 1rem;
}
.accordion-body {
padding: 0 1rem 1rem;
}
.pagination {
gap: 0.125rem;
}
.page-link {
width: 2rem;
height: 2rem;
font-size: 0.8rem;
}
}
@media (max-width: 480px) {
.alert {
padding: 0.625rem 0.875rem;
flex-direction: column;
align-items: flex-start;
gap: 0.375rem;
}
.tooltip .tooltip-text {
width: 140px;
margin-left: -70px;
}
.overlay-content {
margin: 0.375rem;
padding: 0.875rem;
}
}

View File

@@ -1,69 +1,529 @@
/* Import der Komponenten */
@import './components.scss';
/* Modernes, frisches Design für TrainingsTagebuch */
:root {
/* Bestehende Farben beibehalten */
--primary-color: #4CAF50;
--primary-hover: #45a049;
--secondary-color: #a07040;
--secondary-hover: #804b29;
--danger-color: #dc3545;
--danger-hover: #c82333;
--nav-bg: #e0f0e8;
--text-primary: #333;
--text-secondary: #666;
--text-light: #999;
--text-muted: #888;
--bg-light: #f8f9fa;
--border-color: #e9ecef;
--shadow-light: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-medium: 0 2px 8px rgba(0, 0, 0, 0.12);
--shadow-heavy: 0 4px 16px rgba(0, 0, 0, 0.15);
--border-radius: 6px;
--border-radius-large: 8px;
--transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
html, body {
width: 100%;
height: 100%;
padding: 0;
margin: 0;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--text-primary);
background-color: var(--bg-light);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
display: flex;
flex-direction: column;
}
h1 {
margin: 0;
height: 3rem;
padding: 0 0.5rem;
text-align: center;
background-color: #f0f0f0;
color: #4CAF50;
text-shadow: 2px 2px 3px #a0a0a0;
display: flex;
flex-direction: column;
}
h2 {
margin: 0;
margin: 0 0 0.75rem 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
position: relative;
padding-bottom: 0.375rem;
}
h2::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 2.5rem;
height: 2px;
background: linear-gradient(90deg, var(--primary-color), var(--secondary-color));
border-radius: 1px;
}
h3 {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
margin: 1.25rem 0 0.5rem 0;
}
#app {
flex: 1;
width: 100%;
height: 100%;
overflow: hidden;
flex: 1;
width: 100%;
height: 100%;
overflow: hidden;
}
/* Kompaktere Button-Styles */
button {
background-color: #4CAF50;
color: white;
border: 1px solid #4CAF50;
border-radius: 0;
padding: 2px 5px;
text-align: center;
text-decoration: none;
display: inline-block;
font-size: 16px;
cursor: pointer;
box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.2);
transition: background-color 0.3s ease;
margin: 2px;
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
color: white;
border: none;
border-radius: var(--border-radius);
padding: 0.5rem 1rem;
text-align: center;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
box-shadow: var(--shadow-light);
transition: var(--transition);
margin: 0.125rem;
position: relative;
overflow: hidden;
min-height: 2.25rem;
}
button::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.15), transparent);
transition: left 0.4s;
}
button:hover::before {
left: 100%;
}
button:hover {
background-color: #45a049;
transform: translateY(-1px);
box-shadow: var(--shadow-medium);
}
button:active {
transform: translateY(0);
box-shadow: var(--shadow-light);
}
button.cancel-action {
background-color: white;
color: #4CAF50;
border: 1px solid #4CAF50;
border-radius: 0;
padding: 2px 5px; text-align: center;
text-decoration: none;
display: inline-block;
font-size: 16px;
cursor: pointer;
box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.2);
transition: background-color 0.3s ease, color 0.3s ease;
background: white;
color: var(--primary-color);
border: 1.5px solid var(--primary-color);
box-shadow: var(--shadow-light);
}
button.cancel-action:hover {
background-color: #f2f2f2;
color: #45a049;
background: var(--primary-color);
color: white;
transform: translateY(-1px);
box-shadow: var(--shadow-medium);
}
/* Mülleimer-Buttons (Delete-Buttons) */
button.delete-btn,
button[onclick*="delete"],
button[onclick*="remove"] {
background: white;
color: var(--danger-color);
border: 1.5px solid var(--danger-color);
box-shadow: var(--shadow-light);
transition: var(--transition);
}
button.delete-btn:hover,
button[onclick*="delete"]:hover,
button[onclick*="remove"]:hover {
background: var(--danger-color);
color: white;
transform: translateY(-1px);
box-shadow: var(--shadow-medium);
}
/* Spezielle Styles für Mülleimer-Symbol */
button.trash-btn {
background: white !important;
color: var(--danger-color) !important;
border: 1.5px solid var(--danger-color) !important;
box-shadow: var(--shadow-light);
transition: var(--transition);
min-width: 2rem;
min-height: 2rem;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1rem;
}
button.trash-btn:hover {
background: var(--danger-color) !important;
color: white !important;
transform: translateY(-1px);
box-shadow: var(--shadow-medium);
}
/* Sekundäre Buttons */
button.secondary {
background: linear-gradient(135deg, var(--secondary-color), var(--secondary-hover));
}
button.secondary:hover {
box-shadow: var(--shadow-medium);
}
/* Kleine Buttons */
button.small {
padding: 0.375rem 0.75rem;
font-size: 0.8rem;
min-height: 1.875rem;
}
/* Große Buttons */
button.large {
padding: 0.75rem 1.5rem;
font-size: 0.9rem;
min-height: 2.75rem;
}
/* Icon-Buttons */
button.icon {
width: 2.25rem;
height: 2.25rem;
padding: 0;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* Kompaktere Form-Elemente */
input, select, textarea {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1.5px solid var(--border-color);
border-radius: var(--border-radius);
font-size: 0.85rem;
transition: var(--transition);
background: white;
color: var(--text-primary);
box-sizing: border-box;
}
/* Spezielle Styles für Checkboxen */
input[type="checkbox"] {
width: auto;
min-width: 1rem;
height: 1rem;
margin: 0 0.5rem 0 0;
padding: 0;
border: 1.5px solid var(--border-color);
border-radius: 3px;
background: white;
cursor: pointer;
flex-shrink: 0;
vertical-align: middle;
}
input[type="checkbox"]:checked {
background: var(--primary-color);
border-color: var(--primary-color);
position: relative;
}
input[type="checkbox"]:checked::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 0.75rem;
font-weight: bold;
}
/* Label-Styles für Checkboxen */
label {
display: inline-flex;
align-items: center;
cursor: pointer;
margin: 0.25rem 0;
font-size: 0.85rem;
color: var(--text-primary);
line-height: 1.4;
vertical-align: middle;
}
label span {
margin-right: 0.5rem;
}
/* Checkbox-Container für bessere Ausrichtung */
.checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
}
.checkbox-item {
display: inline-flex;
align-items: center;
margin: 0.25rem 0;
vertical-align: middle;
}
/* Spezielle Anpassungen für Listen mit Checkboxen */
ul li.checkbox-item {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.25rem 0;
}
ul li.checkbox-item label {
margin: 0;
flex: 1;
}
input:focus, select:focus, textarea:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.1);
}
input:hover, select:hover, textarea:hover {
border-color: var(--primary-color);
}
/* Kompaktere Karten */
.card {
background: white;
border-radius: var(--border-radius-large);
padding: 1rem;
margin: 0.75rem 0;
box-shadow: var(--shadow-light);
border: 1px solid var(--border-color);
transition: var(--transition);
}
.card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-medium);
}
.card-header {
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.75rem;
margin-bottom: 0.75rem;
}
.card-title {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.card-body {
color: var(--text-secondary);
line-height: 1.5;
}
/* Kompaktere Tabellen */
table {
width: 100%;
border-collapse: collapse;
background: white;
border-radius: var(--border-radius);
overflow: hidden;
box-shadow: var(--shadow-light);
margin: 0.75rem 0;
}
th, td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
th {
background: linear-gradient(135deg, var(--primary-color), var(--primary-hover));
color: white;
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.4px;
}
tr:hover {
background-color: rgba(76, 175, 80, 0.03);
}
/* Kompaktere Listen */
ul, ol {
padding-left: 1.25rem;
}
li {
margin: 0.375rem 0;
color: var(--text-secondary);
}
/* Links */
a {
color: var(--primary-color);
text-decoration: none;
transition: var(--transition);
position: relative;
}
a:hover {
color: var(--primary-hover);
}
a::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
width: 0;
height: 1px;
background: var(--primary-color);
transition: width 0.25s ease;
}
a:hover::after {
width: 100%;
}
/* Utility-Klassen */
.pointer {
cursor: pointer;
cursor: pointer;
}
.text-center {
text-align: center;
}
.text-left {
text-align: left;
}
.text-right {
text-align: right;
}
.mb-1 { margin-bottom: 0.375rem; }
.mb-2 { margin-bottom: 0.75rem; }
.mb-3 { margin-bottom: 1.125rem; }
.mb-4 { margin-bottom: 1.5rem; }
.mt-1 { margin-top: 0.375rem; }
.mt-2 { margin-top: 0.75rem; }
.mt-3 { margin-top: 1.125rem; }
.mt-4 { margin-top: 1.5rem; }
.p-1 { padding: 0.375rem; }
.p-2 { padding: 0.75rem; }
.p-3 { padding: 1.125rem; }
.p-4 { padding: 1.5rem; }
/* Responsive Design */
@media (max-width: 768px) {
html {
font-size: 13px;
}
button {
padding: 0.4rem 0.8rem;
font-size: 0.8rem;
}
.card {
padding: 0.75rem;
margin: 0.5rem 0;
}
h1 {
height: 2.75rem;
font-size: 1.125rem;
}
h2 {
font-size: 1.375rem;
}
}
/* Animationen */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(15px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in {
animation: fadeIn 0.5s ease-out;
}
@keyframes slideIn {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
.slide-in {
animation: slideIn 0.3s ease-out;
}
/* Loading-States */
.loading {
opacity: 0.6;
pointer-events: none;
}
.loading::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 16px;
height: 16px;
margin: -8px 0 0 -8px;
border: 2px solid var(--primary-color);
border-top: 2px solid transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}

View File

@@ -1,4 +1,5 @@
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import html2canvas from 'html2canvas';
class PDFGenerator {
@@ -6,7 +7,7 @@ class PDFGenerator {
this.pdf = new jsPDF('p', 'mm', 'a4');
this.margin = margin;
this.columnGap = columnGap;
this.pageHeight = 297 - margin * 2;
this.pageHeight = 297 - margin * 2;
this.columnWidth = (210 - margin * 2 - columnGap) / 2;
this.position = margin;
this.yPos = this.position;
@@ -17,16 +18,33 @@ class PDFGenerator {
this.COLUMN_GROUP = margin + 100;
this.COLUMN_DURATION = margin + 150;
this.LINE_HEIGHT = 7;
this.cursorY = margin;
}
async addSchedule(element) {
const canvas = await html2canvas(element, { scale: 2 });
const canvas = await html2canvas(element, {
scale: 2,
onclone: (clonedDoc) => {
const clonedEl = clonedDoc.getElementById('schedule');
if (clonedEl) clonedEl.style.fontSize = '12pt';
// Klon des Wurzel-Elements
const tbl = clonedDoc.getElementById(element.id);
if (!tbl) return;
// Alle Zellen und Überschriften-Elemente auswählen
const cells = tbl.querySelectorAll('td, th');
cells.forEach(cell => {
cell.style.fontSize = '12pt';
});
}
});
const imgData = canvas.toDataURL('image/png');
const imgWidth = 210 - this.margin * 2;
const imgHeight = (canvas.height * imgWidth) / canvas.width;
let heightLeft = imgHeight;
let position = this.margin;
this.pdf.addImage(imgData, 'PNG', this.margin, position, imgWidth, imgHeight);
this.pdf.setFontSize(12);
heightLeft -= this.pageHeight;
while (heightLeft >= 0) {
position = heightLeft - imgHeight + this.margin;
@@ -43,6 +61,23 @@ class PDFGenerator {
this.isLeftColumn = true;
}
addTitle(text) {
// remember old settings
const oldFont = this.pdf.getFont();
const oldStyle = this.pdf.getFont().fontStyle;
const oldSize = this.pdf.getFontSize();
// set new
this.pdf.setFont('helvetica', 'bold');
this.pdf.setFontSize(14);
this.pdf.text(text, this.margin, this.cursorY);
this.cursorY += 10;
// restore
this.pdf.setFont(oldFont.fontName, oldStyle);
this.pdf.setFontSize(oldSize);
}
addHeader(clubName, formattedDate, formattedStartTime, formattedEndTime) {
this.pdf.setFontSize(14);
this.pdf.setFont('helvetica', 'bold');
@@ -175,6 +210,60 @@ class PDFGenerator {
this.pdf.text(phoneNumber, this.margin + 120, this.yPos);
this.yPos += this.LINE_HEIGHT;
}
addAddress(clubName, addressLines) {
if (!this.addressY) {
this.addressY = 30;
}
this.pdf.setFontSize(14);
this.pdf.setFont(undefined, 'bold');
this.pdf.text(clubName, 20, this.addressY);
this.pdf.setFontSize(12);
this.pdf.setFont(undefined, 'normal');
addressLines.forEach(line => {
this.addressY += 7;
this.pdf.text(line, 20, this.addressY);
});
this.addressY += 10; // Abstand zur nächsten Adresse
}
async addScreenshot(element) {
const canvas = await html2canvas(element, { scale: 2 });
const imgData = canvas.toDataURL('image/png');
const imgWidth = this.pageWidth;
const imgHeight = (canvas.height * imgWidth) / canvas.width;
this.pdf.addImage(imgData, 'PNG', this.margin, this.cursorY, imgWidth, imgHeight);
this.cursorY += imgHeight + 10;
if (this.cursorY > this.pdf.internal.pageSize.height - this.margin) {
this.pdf.addPage();
this.cursorY = this.margin;
}
}
addTable(tableId, highlightName = '') {
this.pdf.setFontSize(11);
autoTable(this.pdf, {
html: `#${tableId}`,
startY: this.cursorY,
margin: { left: this.margin, right: this.margin },
styles: { fontSize: this.pdf.getFontSize() },
headStyles: { fillColor: [220, 220, 220], textColor: 0, halign: 'left' },
theme: 'grid',
didParseCell: (data) => {
const cellText = Array.isArray(data.cell.text)
? data.cell.text.join(' ')
: String(data.cell.text);
if (highlightName && cellText.includes(highlightName)) {
data.cell.styles.fontStyle = 'bold';
}
},
didDrawPage: (data) => {
this.cursorY = data.cursor.y + 10;
}
});
}
}
export default PDFGenerator;

View File

@@ -9,6 +9,8 @@ import MembersView from './views/MembersView.vue';
import DiaryView from './views/DiaryView.vue';
import PendingApprovalsView from './views/PendingApprovalsView.vue';
import ScheduleView from './views/ScheduleView.vue';
import TournamentsView from './views/TournamentsView.vue';
import TrainingStatsView from './views/TrainingStatsView.vue';
const routes = [
{ path: '/register', component: Register },
@@ -21,6 +23,8 @@ const routes = [
{ path: '/diary', component: DiaryView },
{ path: '/pending-approvals', component: PendingApprovalsView},
{ path: '/schedule', component: ScheduleView},
{ path: '/tournaments', component: TournamentsView },
{ path: '/training-stats', component: TrainingStatsView },
];
const router = createRouter({

View File

@@ -11,8 +11,7 @@ const store = createStore({
try {
return JSON.parse(localStorage.getItem('clubs')) || [];
} catch (e) {
console.error('Error parsing clubs from localStorage:', e);
return [];
this.clubs = [];
}
})(),
},
@@ -55,8 +54,8 @@ const store = createStore({
logout({ commit }) {
commit('clearToken');
commit('clearUsername');
router.push("/");
window.location.reload();
router.push('/login'); // Leitet den Benutzer zur Login-Seite um
window.location.reload(); // Optional, um den Zustand vollständig zurückzusetzen
},
setCurrentClub({ commit }, club) {

View File

@@ -1,79 +1,232 @@
/* Globale Styles für TrainingsTagebuch */
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
color-scheme: light;
color: #333;
background-color: #f8f9fa;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
* {
box-sizing: border-box;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
padding: 0;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
background-color: inherit;
color: inherit;
line-height: inherit;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
width: 100%;
height: 100vh;
margin: 0;
padding: 0;
text-align: left;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
/* Utility-Klassen für das neue Design */
.text-center { text-align: center; }
.text-left { text-align: left; }
.text-right { text-align: right; }
.mb-0 { margin-bottom: 0; }
.mb-1 { margin-bottom: 0.25rem; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-3 { margin-bottom: 1rem; }
.mb-4 { margin-bottom: 1.5rem; }
.mb-5 { margin-bottom: 3rem; }
.mt-0 { margin-top: 0; }
.mt-1 { margin-top: 0.25rem; }
.mt-2 { margin-top: 0.5rem; }
.mt-3 { margin-top: 1rem; }
.mt-4 { margin-top: 1.5rem; }
.mt-5 { margin-top: 3rem; }
.p-0 { padding: 0; }
.p-1 { padding: 0.25rem; }
.p-2 { padding: 0.5rem; }
.p-3 { padding: 1rem; }
.p-4 { padding: 1.5rem; }
.p-5 { padding: 3rem; }
/* Responsive Container */
.container {
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
.container-fluid {
width: 100%;
padding: 0 1rem;
}
/* Grid System */
.row {
display: flex;
flex-wrap: wrap;
margin: 0 -0.5rem;
}
.col {
flex: 1;
padding: 0 0.5rem;
}
.col-12 { flex: 0 0 100%; }
.col-6 { flex: 0 0 50%; }
.col-4 { flex: 0 0 33.333333%; }
.col-3 { flex: 0 0 25%; }
@media (max-width: 768px) {
.col-md-12 { flex: 0 0 100%; }
.col-md-6 { flex: 0 0 50%; }
.col-md-4 { flex: 0 0 33.333333%; }
.col-md-3 { flex: 0 0 25%; }
}
@media (max-width: 480px) {
.col-sm-12 { flex: 0 0 100%; }
.col-sm-6 { flex: 0 0 50%; }
.col-sm-4 { flex: 0 0 33.333333%; }
.col-sm-3 { flex: 0 0 25%; }
}
/* Flexbox Utilities */
.d-flex { display: flex; }
.d-inline-flex { display: inline-flex; }
.d-block { display: block; }
.d-inline-block { display: inline-block; }
.d-none { display: none; }
.flex-row { flex-direction: row; }
.flex-column { flex-direction: column; }
.flex-wrap { flex-wrap: wrap; }
.flex-nowrap { flex-wrap: nowrap; }
.justify-content-start { justify-content: flex-start; }
.justify-content-center { justify-content: center; }
.justify-content-end { justify-content: flex-end; }
.justify-content-between { justify-content: space-between; }
.justify-content-around { justify-content: space-around; }
.align-items-start { align-items: flex-start; }
.align-items-center { align-items: center; }
.align-items-end { align-items: flex-end; }
.align-items-stretch { align-items: stretch; }
.flex-1 { flex: 1; }
.flex-auto { flex: auto; }
.flex-none { flex: none; }
/* Spacing Utilities */
.gap-0 { gap: 0; }
.gap-1 { gap: 0.25rem; }
.gap-2 { gap: 0.5rem; }
.gap-3 { gap: 1rem; }
.gap-4 { gap: 1.5rem; }
.gap-5 { gap: 3rem; }
/* Border Utilities */
.border { border: 1px solid #e9ecef; }
.border-top { border-top: 1px solid #e9ecef; }
.border-right { border-right: 1px solid #e9ecef; }
.border-bottom { border-bottom: 1px solid #e9ecef; }
.border-left { border-left: 1px solid #e9ecef; }
.border-0 { border: 0; }
.rounded { border-radius: 0.375rem; }
.rounded-sm { border-radius: 0.25rem; }
.rounded-lg { border-radius: 0.5rem; }
.rounded-xl { border-radius: 0.75rem; }
/* Shadow Utilities */
.shadow-sm { box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); }
.shadow { box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); }
.shadow-md { box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); }
.shadow-lg { box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); }
.shadow-xl { box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); }
/* Position Utilities */
.position-relative { position: relative; }
.position-absolute { position: absolute; }
.position-fixed { position: fixed; }
.position-sticky { position: sticky; }
/* Overflow Utilities */
.overflow-hidden { overflow: hidden; }
.overflow-auto { overflow: auto; }
.overflow-scroll { overflow: scroll; }
/* Cursor Utilities */
.cursor-pointer { cursor: pointer; }
.cursor-default { cursor: default; }
.cursor-not-allowed { cursor: not-allowed; }
/* Text Utilities */
.text-primary { color: #4CAF50; }
.text-secondary { color: #a07040; }
.text-success { color: #28a745; }
.text-danger { color: #dc3545; }
.text-warning { color: #ffc107; }
.text-info { color: #17a2b8; }
.text-light { color: #6c757d; }
.text-dark { color: #343a40; }
.text-muted { color: #6c757d; }
.text-white { color: #ffffff; }
.text-black { color: #000000; }
.font-weight-light { font-weight: 300; }
.font-weight-normal { font-weight: 400; }
.font-weight-medium { font-weight: 500; }
.font-weight-semibold { font-weight: 600; }
.font-weight-bold { font-weight: 700; }
.font-size-sm { font-size: 0.875rem; }
.font-size-base { font-size: 1rem; }
.font-size-lg { font-size: 1.125rem; }
.font-size-xl { font-size: 1.25rem; }
/* Background Utilities */
.bg-primary { background-color: #4CAF50; }
.bg-secondary { background-color: #a07040; }
.bg-success { background-color: #28a745; }
.bg-danger { background-color: #dc3545; }
.bg-warning { background-color: #ffc107; }
.bg-info { background-color: #17a2b8; }
.bg-light { background-color: #f8f9fa; }
.bg-dark { background-color: #343a40; }
.bg-white { background-color: #ffffff; }
.bg-transparent { background-color: transparent; }
/* Responsive Utilities */
@media (max-width: 768px) {
.d-md-none { display: none; }
.d-md-block { display: block; }
.d-md-flex { display: flex; }
}
@media (max-width: 480px) {
.d-sm-none { display: none; }
.d-sm-block { display: block; }
.d-sm-flex { display: flex; }
}
/* Print Utilities */
@media print {
.d-print-none { display: none; }
.d-print-block { display: block; }
.d-print-flex { display: flex; }
}

View File

@@ -17,8 +17,7 @@
alert('Account activated! You can now log in.');
this.$router.push('/login');
} catch (error) {
console.error(error);
alert('Activation failed.');
alert('Aktivierung fehlgeschlagen');
}
},
},

View File

@@ -48,8 +48,7 @@ export default {
this.club = response.data;
this.accessAllowed = true;
} catch (error) {
console.error("Zugriff auf den Verein nicht gestattet", error);
this.accessAllowed = false;
alert('Zugriff auf den Verein nicht gestattet');
}
},
async loadOpenRequests() {
@@ -57,7 +56,7 @@ export default {
const response = await apiClient.get(`/clubmembers/notapproved/${this.currentClub}`);
this.openRequests = response.data;
} catch (error) {
console.error("Fehler beim Laden der offenen Anfragen", error);
alert('Fehler beim Laden der offenen Anfragen');
}
},
async requestAccess() {

View File

@@ -32,7 +32,7 @@
<div v-if="!showForm && date !== null && date !== 'new'">
<h3>Trainingszeiten bearbeiten <span @click="toggleShowGeneralData" class="clickable">{{ showGeneralData ?
'-' : '+' }}</span></h3>
'-' : '+' }}</span></h3>
<form @submit.prevent="updateTrainingTimes" v-if="showGeneralData">
<div>
<label for="editTrainingStart">Trainingsbeginn:</label>
@@ -54,7 +54,7 @@
<ul>
<li v-for="group in groups" :key="group.id">
<span v-if="editingGroupId !== group.id" @click="editGroup(group.id)">{{ group.name
}}</span>
}}</span>
<input v-else type="text" v-model="group.name" @blur="saveGroup(group)"
@keyup.enter="saveGroup(group)" @keyup.esc="cancelEditGroup"
style="display: inline;width:10em" />
@@ -88,6 +88,7 @@
<table>
<thead>
<tr>
<th></th> <!-- Neue Spalte für Drag-Handle -->
<th>Startzeit</th>
<th>Aktivität / Zeitblock</th>
<th>Gruppe</th>
@@ -98,6 +99,7 @@
<tbody ref="sortableList">
<template v-for="(item, index) in trainingPlan" :key="item.id">
<tr>
<td class="drag-handle" style="cursor: move;"></td> <!-- Drag-Handle -->
<td>{{ item.startTime }}</td>
<td>
<span v-if="item.isTimeblock"><i>Zeitblock</i></span>
@@ -105,12 +107,16 @@
item.activity }}</span>
</td>
<td>{{ item.groupActivity ? item.groupActivity.name : '' }}</td>
<td><span v-if="item.durationText">{{ item.durationText }} / </span>{{
item.duration }}</td>
<td><button @click="removePlanItem(item.id)">Entfernen</button></td>
<td>
{{ item.duration }}<span
v-if="item.durationText && item.durationText.trim() !== ''"> ({{
item.durationText }})</span>
</td>
<td><button @click="removePlanItem(item.id)" class="trash-btn">🗑</button></td>
</tr>
<template v-for="groupItem in item.groupActivities">
<tr>
<td></td>
<td></td>
<td>{{ groupItem.groupPredefinedActivity.name }}</td>
<td>{{ groupItem.groupsGroupActivity.name }}</td>
@@ -119,7 +125,9 @@
</tr>
</template>
</template>
<!-- Zeile zum Hinzufügen eines neuen Items -->
<tr>
<td></td>
<td>{{ calculateNextTime }}</td>
<td colspan="4" v-if="!addNewItem && !addNewTimeblock && !addNewGroupActivity">
<button @click="openNewPlanItem()">Gesamt-Aktivität</button>
@@ -140,7 +148,7 @@
</td>
<td v-else-if="addNewItem || addNewTimeblock"></td>
<td v-if="(addNewItem || addNewTimeblock) && !addNewGroupActivity">
<input type="text" v-model="newPlanItem.durationInput"
<input type="text" v-model="newPlanItem.durationText"
@input="calculateDuration" placeholder="z.B. 2x7 oder 3*5"
style="width:10em" />
<input type="number" v-model="newPlanItem.duration" placeholder="Minuten" />
@@ -154,7 +162,8 @@
</tbody>
</table>
<button v-if="trainingPlan && trainingPlan.length && trainingPlan.length > 0"
@click="generatePDF">Als PDF herunterladen</button>
@click="generatePDF">Als PDF
herunterladen</button>
</div>
</div>
<div class="column">
@@ -176,14 +185,17 @@
@remove="removeActivityTag" :allow-empty="false" @keydown.enter.prevent="addNewTagFromInput" />
<h3>Teilnehmer</h3>
<ul>
<li v-for="member in sortedMembers()" :key="member.id">
<input type="checkbox" :value="member.id" @change="toggleParticipant(member.id)"
:checked="isParticipant(member.id)">
<span class="clickable" @click="selectMember(member)"
:class="{ highlighted: selectedMember && selectedMember.id === member.id }">{{
member ? member.firstName : ''
}} {{
member ? member.lastName : '' }}</span>
<li v-for="member in sortedMembers()" :key="member.id" class="checkbox-item">
<label class="checkbox-item">
<input type="checkbox" :value="member.id" @change="toggleParticipant(member.id)"
:checked="isParticipant(member.id)">
<span class="clickable" @click="selectMember(member)"
:class="{ highlighted: selectedMember && selectedMember.id === member.id }">{{
member ? member.firstName : ''
}} {{
member ? member.lastName : ''
}}</span>
</label>
<span v-if="false" @click="openNotesModal(member)" class="clickable">📝</span>
<span @click="showPic(member)" class="img-icon" v-if="member.hasImage">&#x1F5BC;</span>
<span class="pointer" @click="openTagInfos(member)"></span>
@@ -234,7 +246,7 @@
</div>
<ul>
<li v-for="note in notes" :key="note.id">
<button @click="deleteNote(note.id)" class="cancel-action">Löschen</button>
<button @click="deleteNote(note.id)" class="trash-btn">🗑</button>
{{ note.content }}
</li>
</ul>
@@ -252,18 +264,21 @@
<label for="memberId">Mitglied:</label>
<select id="memberId" v-model="accident.memberId">
<template v-for="member in members" :key="member.id" :value="member.id">
<option v-if="participants.indexOf(member.id) >= 0" :value="member.id">{{ member.firstName + ' ' + member.lastName }}</option>
<option v-if="participants.indexOf(member.id) >= 0" :value="member.id">{{ member.firstName + ' '
+ member.lastName }}</option>
</template>
</select>
</div>
<div>
<label for="accident">Unfall:</label>
<textarea id="accident" v-model="accident.accident" required ></textarea>
<textarea id="accident" v-model="accident.accident" required></textarea>
</div>
<button type="button" @click="saveAccident">Eintragen</button>
<button type="button" @click="closeAccidentForm">Schießen</button>
<ul>
<li v-for="accident in accidents" :key="accident.id">{{ accident.firstName + ' ' + accident.lastName + ': ' + accident.accident}}</li>
<li v-for="accident in accidents" :key="accident.id">{{ accident.firstName + ' ' + accident.lastName +
': '
+ accident.accident}}</li>
</ul>
</form>
</div>
@@ -450,7 +465,6 @@ export default {
});
alert('Trainingszeiten erfolgreich aktualisiert.');
} catch (error) {
console.error('Fehler beim Aktualisieren der Trainingszeiten:', error);
alert('Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.');
}
},
@@ -480,7 +494,7 @@ export default {
const response = await apiClient.get('/predefined-activities');
this.predefinedActivities = response.data;
} catch (error) {
console.error('Fehler beim Laden der vordefinierten Aktivitäten:', error);
alert('Fehler beim Laden der vordefinierten Aktivitäten');
}
},
@@ -554,7 +568,6 @@ export default {
label: tag.tag.label
}));
} catch (error) {
console.error('Error loading member notes and tags:', error);
alert('Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.');
}
this.doMemberTagUpdates = true;
@@ -600,7 +613,6 @@ export default {
this.availableTags.push(newTag);
this.selectedActivityTags.push(newTag);
} catch (error) {
console.error('Fehler beim Hinzufügen eines neuen Tags:', error);
alert('Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.');
}
},
@@ -620,14 +632,12 @@ export default {
this.selectedMemberTags.push(newTag);
await this.linkTagToMemberAndDate(newTag);
} catch (error) {
console.error('Fehler beim Hinzufügen eines neuen Tags für das Mitglied:', error);
alert('Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.');
}
},
async linkTagToDiaryDate(tag) {
if (!tag || !tag.id) {
console.warn("Ungültiges Tag-Objekt:", tag);
return;
}
try {
@@ -637,7 +647,6 @@ export default {
tagId: tagId
});
} catch (error) {
console.error('Fehler beim Verknüpfen des Tags mit dem Trainingstag:', error);
alert('Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.');
}
},
@@ -651,7 +660,6 @@ export default {
tagId: tagId
});
} catch (error) {
console.error('Fehler beim Verknüpfen des Tags mit dem Mitglied und Datum:', error);
alert('Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.');
}
},
@@ -669,7 +677,6 @@ export default {
}
this.previousActivityTags = [...selectedTags];
} catch (error) {
console.error('Fehler beim Verknüpfen der Tags mit dem Trainingstag:', error);
alert('Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.');
}
},
@@ -686,7 +693,6 @@ export default {
}
this.previousMemberTags = [...this.selectedMemberTags];
} catch (error) {
console.error('Fehler beim Verknüpfen der Tags mit dem Mitglied und Datum:', error);
alert('Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.');
}
},
@@ -700,7 +706,6 @@ export default {
});
this.selectedMemberTags = this.selectedMemberTags.filter(tag => tag.id !== tagId);
} catch (error) {
console.error('Fehler beim Entfernen des Tags:', error);
alert('Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.');
}
},
@@ -714,7 +719,6 @@ export default {
});
this.notes = this.notes.filter(note => note.content !== noteContent);
} catch (error) {
console.error('Fehler beim Entfernen der Notiz:', error);
alert('Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.');
}
},
@@ -727,7 +731,6 @@ export default {
});
this.selectedActivityTags = this.selectedActivityTags.filter(t => t.id !== tagId);
} catch (error) {
console.error('Fehler beim Entfernen des Tags:', error);
alert('Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.');
}
},
@@ -773,7 +776,6 @@ export default {
this.trainingPlan = await apiClient.get(`/diary-date-activities/${this.currentClub}/${this.date.id}`).then(response => response.data);
this.calculateIntermediateTimes();
} catch (error) {
console.error('Fehler beim Hinzufügen des Planungsitems:', error);
alert('Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.');
}
},
@@ -785,7 +787,7 @@ export default {
});
this.calculateIntermediateTimes();
} catch (error) {
console.error('Fehler beim Aktualisieren der Planungs-Item-Gruppe:', error);
alert('Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.');
}
},
@@ -795,7 +797,7 @@ export default {
this.trainingPlan = this.trainingPlan.filter(item => item.id !== planItemId);
this.calculateIntermediateTimes();
} catch (error) {
console.error('Fehler beim Entfernen des Planungsitems:', error);
alert('Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.');
}
},
@@ -856,7 +858,7 @@ export default {
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
},
calculateDuration() {
const input = this.newPlanItem.durationInput;
const input = this.newPlanItem.durationText;
let calculatedDuration = 0;
const multiplyPattern = /(\d+)\s*[x*]\s*(\d+)/i;
const match = input.match(multiplyPattern);
@@ -877,7 +879,6 @@ export default {
await apiClient.delete(`/diary-date-activities/${this.currentClub}/${planItemId}`);
this.trainingPlan = this.trainingPlan.filter(item => item.id !== planItemId);
} catch (error) {
console.error('Fehler beim Entfernen des Planungsitems:', error);
alert('Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.');
}
},
@@ -889,21 +890,20 @@ export default {
});
this.recalculateTimes();
} catch (error) {
console.error('Fehler beim Aktualisieren der Reihenfolge:', error);
alert('Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.');
}
},
/* async loadMemberImage(member) {
try {
const response = await apiClient.get(`/clubmembers/image/${this.currentClub}/${member.id}`, {
responseType: 'blob',
});
const imageUrl = URL.createObjectURL(response.data);
member.imageUrl = imageUrl;
} catch (error) {
member.imageUrl = null;
}
},*/
/* async loadMemberImage(member) {
try {
const response = await apiClient.get(`/clubmembers/image/${this.currentClub}/${member.id}`, {
responseType: 'blob',
});
const imageUrl = URL.createObjectURL(response.data);
member.imageUrl = imageUrl;
} catch (error) {
member.imageUrl = null;
}
},*/
async generatePDF() {
const pdf = new PDFGenerator();
pdf.addTrainingPlan(this.currentClubName, this.date.date, this.trainingStart, this.trainingEnd, this.trainingPlan);
@@ -985,7 +985,7 @@ export default {
this.showGeneralData = !this.showGeneralData;
},
getFormattedDate(date) {
return (new Date(date)).toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit'});
return (new Date(date)).toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' });
},
editGroup(groupId) {
this.editingGroupId = groupId;
@@ -998,18 +998,16 @@ export default {
clubid: this.currentClub,
dateid: this.date.id,
});
this.editingGroupId = null;
this.editingGroupId = null;
} catch (error) {
console.error('Fehler beim Speichern der Gruppendaten:', error);
alert('Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.');
}
},
cancelEditGroup() {
this.editingGroupId = null;
this.editingGroupId = null;
},
async openTagInfos(member) {
if (!member) {
console.warn("Member is undefined or null");
return;
}
this.showTagHistoryModal = true;
@@ -1029,7 +1027,7 @@ export default {
},
async addNewTagForDay(tag) {
await apiClient.post(`/diarydatetags/${this.currentClub}`, {
dateId:this.date.id,
dateId: this.date.id,
memberId: this.tagHistoryMember.id,
tag: tag,
});
@@ -1047,7 +1045,7 @@ export default {
if (this.timeChecker) clearInterval(this.timeChecker);
this.timeChecker = setInterval(() => {
const currentTime = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
if (!this.trainingStart || ! this.trainingEnd) {
if (!this.trainingStart || !this.trainingEnd) {
return;
}
let startCheckTime = this.trainingStart;
@@ -1073,23 +1071,11 @@ export default {
},
playBellSound() {
this.bellSound.play()
.then(() => {
console.log("Bell sound played successfully");
})
.catch(error => {
console.error("Error playing bell sound:", error);
});
this.bellSound.play();
},
playThumbSound() {
this.thumbSound.play()
.then(() => {
console.log("Thumb sound played successfully");
})
.catch(error => {
console.error("Error playing thumb sound:", error);
});
this.thumbSound.play();
},
calculateIntermediateTimes() {
@@ -1100,12 +1086,12 @@ export default {
let times = [];
let currentTime = new Date("2025-01-01 " + this.trainingStart);
this.trainingPlan.forEach(item => {
const rawItem = JSON.parse(JSON.stringify(item));
const rawItem = JSON.parse(JSON.stringify(item));
currentTime.setMinutes(currentTime.getMinutes() + item.duration);
times.push(currentTime.toTimeString({ hours: '2-digit', minutes: '2-digit', seconds: '2-digit' }).slice(0, 8));
});
times = [...new Set(times)].sort();
this.intermediateTimes = times.filter(time =>
this.intermediateTimes = times.filter(time =>
time !== this.trainingStart && time !== this.trainingEnd
);
},
@@ -1188,7 +1174,8 @@ h3 {
.column:first-child {
flex: 1;
overflow: hidden;
height: 100%;justify-self: start;
height: 100%;
justify-self: start;
display: flex;
flex-direction: column;
}

View File

@@ -1,8 +1,80 @@
<template>
<div>
<h2>Home</h2>
<p v-if="!isAuthenticated">Du bist nicht eingeloggt.<router-link to="/login">Einloggen</router-link> oder <router-link to="/register">Registrieren</router-link></p>
<p v-else>Herzlich Willkommen <button @click="logout">Ausloggen</button></p>
<div class="home-container">
<div class="welcome-section">
<div class="welcome-card card">
<div class="card-header">
<h2 class="card-title">Willkommen im TrainingsTagebuch</h2>
</div>
<div class="card-body">
<div v-if="!isAuthenticated" class="auth-message">
<p class="message-text">
Melde dich an, um deine Vereine und Trainingsaktivitäten zu verwalten.
</p>
<div class="auth-actions">
<router-link to="/login" class="btn-primary">
<span class="btn-icon">🔐</span>
Einloggen
</router-link>
<router-link to="/register" class="btn-secondary">
<span class="btn-icon">📝</span>
Registrieren
</router-link>
</div>
</div>
<div v-else class="user-welcome">
<div class="user-avatar">
<span class="avatar-icon">👋</span>
</div>
<p class="welcome-text">
Herzlich Willkommen zurück! Du bist erfolgreich eingeloggt.
</p>
<div class="user-actions">
<button @click="logout" class="btn-secondary">
<span class="btn-icon">🚪</span>
Ausloggen
</button>
</div>
</div>
</div>
</div>
</div>
<div v-if="isAuthenticated" class="features-section">
<h3 class="section-title">Was kannst du hier machen?</h3>
<div class="features-grid">
<div class="feature-card card">
<div class="feature-icon">👥</div>
<h4 class="feature-title">Mitglieder verwalten</h4>
<p class="feature-description">
Verwalte deine Vereinsmitglieder, erstelle Gruppen und behalte den Überblick über alle Teilnehmer.
</p>
</div>
<div class="feature-card card">
<div class="feature-icon">📝</div>
<h4 class="feature-title">Tagebuch führen</h4>
<p class="feature-description">
Dokumentiere deine Trainingsaktivitäten, Notizen und wichtige Ereignisse im Verein.
</p>
</div>
<div class="feature-card card">
<div class="feature-icon">📅</div>
<h4 class="feature-title">Spielpläne organisieren</h4>
<p class="feature-description">
Plane und organisiere Spiele, Turniere und andere Veranstaltungen für deinen Verein.
</p>
</div>
<div class="feature-card card">
<div class="feature-icon">🏆</div>
<h4 class="feature-title">Turniere verwalten</h4>
<p class="feature-description">
Erstelle und verwalte Turniere, Gruppen und Ergebnisse für deine Vereinsaktivitäten.
</p>
</div>
</div>
</div>
</div>
</template>
@@ -10,6 +82,7 @@
import { mapGetters, mapActions } from 'vuex';
export default {
name: 'Home',
computed: {
...mapGetters(['isAuthenticated']),
},
@@ -18,3 +91,196 @@ export default {
},
};
</script>
<style scoped>
.home-container {
max-width: 1200px;
margin: 0 auto;
}
.welcome-section {
margin-bottom: 2rem;
}
.welcome-card {
text-align: center;
background: linear-gradient(135deg, rgba(76, 175, 80, 0.05), rgba(160, 112, 64, 0.05));
border: 1px solid rgba(76, 175, 80, 0.2);
}
.card-title {
color: var(--primary-color);
font-size: 1.75rem;
margin-bottom: 0.75rem;
}
.auth-message, .user-welcome {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.25rem;
}
.message-text, .welcome-text {
font-size: 1rem;
color: var(--text-secondary);
line-height: 1.5;
max-width: 600px;
margin: 0;
}
.auth-actions, .user-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
justify-content: center;
}
.user-avatar {
width: 64px;
height: 64px;
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.75rem;
}
.avatar-icon {
font-size: 2rem;
}
.btn-icon {
margin-right: 0.375rem;
font-size: 1rem;
}
/* Features Section */
.features-section {
margin-top: 3rem;
}
.section-title {
text-align: center;
margin-bottom: 1.5rem;
color: var(--text-primary);
font-size: 1.5rem;
font-weight: 600;
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1.5rem;
margin-top: 1.5rem;
}
.feature-card {
text-align: center;
padding: 1.5rem 1.25rem;
transition: var(--transition);
border: 1px solid var(--border-color);
position: relative;
overflow: hidden;
}
.feature-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--primary-color), var(--secondary-color));
transform: scaleX(0);
transition: transform 0.25s ease;
}
.feature-card:hover::before {
transform: scaleX(1);
}
.feature-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-heavy);
}
.feature-icon {
font-size: 2.5rem;
margin-bottom: 0.75rem;
display: block;
}
.feature-title {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 0.75rem 0;
}
.feature-description {
color: var(--text-secondary);
line-height: 1.5;
margin: 0;
font-size: 0.9rem;
}
/* Responsive Design */
@media (max-width: 768px) {
.home-container {
padding: 0 0.75rem;
}
.welcome-card {
margin: 0 0.375rem;
}
.card-title {
font-size: 1.5rem;
}
.features-grid {
grid-template-columns: 1fr;
gap: 1.25rem;
}
.feature-card {
padding: 1.25rem 1rem;
}
.auth-actions, .user-actions {
flex-direction: column;
width: 100%;
max-width: 280px;
}
.btn-primary, .btn-secondary {
width: 100%;
justify-content: center;
}
}
@media (max-width: 480px) {
.card-title {
font-size: 1.375rem;
}
.section-title {
font-size: 1.375rem;
}
.feature-icon {
font-size: 2.25rem;
}
.user-avatar {
width: 56px;
height: 56px;
}
.avatar-icon {
font-size: 1.75rem;
}
}
</style>

View File

@@ -30,8 +30,7 @@ export default {
await this.login({ token: response.data.token, username: this.email });
this.$router.push('/');
} catch (error) {
console.error(error);
alert('Login failed.');
alert('Login fehlgeschlagen');
}
},
},

View File

@@ -20,9 +20,9 @@
<label><span>Geburtsdatum:</span> <input type="date" v-model="newBirthdate"></label>
<label><span>Telefon-Nr.:</span> <input type="text" v-model="newPhone"></label>
<label><span>Email-Adresse:</span> <input type="email" v-model="newEmail"></label>
<label><span>Aktiv:</span> <input type="checkbox" v-model="newActive"></label>
<label><span>Pics in Internet erlaubt:</span> <input type="checkbox" v-model="newPicsInInternetAllowed"></label>
<label><span>Testmitgliedschaft:</span> <input type="checkbox" v-model="testMembership" </label>
<label class="checkbox-item"><span>Aktiv:</span> <input type="checkbox" v-model="newActive"></label>
<label class="checkbox-item"><span>Pics in Internet erlaubt:</span> <input type="checkbox" v-model="newPicsInInternetAllowed"></label>
<label class="checkbox-item"><span>Testmitgliedschaft:</span> <input type="checkbox" v-model="testMembership"></label>
<label><span>Bild:</span> <input type="file" @change="onFileSelected"></label>
<div v-if="memberImagePreview">
<img :src="memberImagePreview" alt="Vorschau des Mitgliedsbildes"
@@ -35,8 +35,11 @@
</div>
</div>
</div>
<div>
<input type="checkbox" v-model="showInactiveMembers"> Inaktive Mitglieder anzeigen
<div class="checkbox-group">
<label class="checkbox-item">
<input type="checkbox" v-model="showInactiveMembers">
<span>Inaktive Mitglieder anzeigen</span>
</label>
</div>
<div>
<table>
@@ -93,7 +96,7 @@
</div>
<ul>
<li v-for="note in notes" :key="note.id">
<button @click="deleteNote(note.id)" class="cancel-action">Löschen</button>
<button @click="deleteNote(note.id)" class="trash-btn">🗑</button>
{{ note.content }}
</li>
</ul>
@@ -211,7 +214,7 @@ export default {
response = await apiClient.post(`/clubmembers/set/${this.currentClub}`, memberData);
this.loadMembers();
} catch (error) {
console.error("Fehler beim Speichern des Mitglieds:", error);
alert('Fehler beim Speichern des Mitglieds');
return;
}
@@ -226,7 +229,7 @@ export default {
}
});
} catch (error) {
console.error("Fehler beim Hochladen des Bildes:", error);
// Kein Alert - es ist normal, dass nicht alle Mitglieder Bilder haben
}
}
@@ -234,7 +237,7 @@ export default {
this.memberFormIsOpen = false;
},
async editMember(member) {
console.log(member.birthDate);
const birthDate = member.birthDate;
this.memberToEdit = member;
this.memberFormIsOpen = true;
this.newFirstname = member.firstName;
@@ -244,7 +247,6 @@ export default {
this.newPhone = member.phone;
this.newEmail = member.email;
this.newActive = member.active;
const date = new Date(member.birthDate);
this.newBirthdate = date.toISOString().split('T')[0];
this.testMembership = member.testMembership;
this.newPicsInInternetAllowed = member.picsInInternetAllowed;
@@ -254,7 +256,7 @@ export default {
});
this.memberImagePreview = URL.createObjectURL(response.data);
} catch (error) {
console.error("Fehler beim Laden des Bildes:", error);
// Kein Alert - es ist normal, dass nicht alle Mitglieder Bilder haben
this.memberImagePreview = null;
}
},
@@ -308,7 +310,7 @@ export default {
const imageUrl = URL.createObjectURL(response.data);
member.imageUrl = imageUrl;
} catch (error) {
console.error("Failed to load member image:", error);
// Kein Alert - es ist normal, dass nicht alle Mitglieder Bilder haben
member.imageUrl = null;
}
},

View File

@@ -53,7 +53,7 @@ export default {
const response = await apiClient.get(`/clubs/pending/${this.currentClub}`);
this.pendingUsers = response.data.map(entry => entry.user);
} catch (error) {
console.error('Fehler beim Laden der ausstehenden Anfragen:', error);
alert('Fehler beim Laden der ausstehenden Anfragen');
}
},
async approveUser(userId) {
@@ -64,7 +64,7 @@ export default {
});
this.pendingUsers = this.pendingUsers.filter(user => user.id !== userId);
} catch (error) {
console.error('Fehler beim Genehmigen des Benutzers:', error);
alert('Fehler beim Genehmigen des Benutzers');
}
},
async rejectUser(userId) {
@@ -75,7 +75,7 @@ export default {
});
this.pendingUsers = this.pendingUsers.filter(user => user.id !== userId);
} catch (error) {
console.error('Fehler beim Ablehnen des Benutzers:', error);
alert('Fehler beim Ablehnen des Benutzers');
}
},
},

View File

@@ -26,8 +26,7 @@
await axios.post('/api/auth/register', { email: this.email, password: this.password });
alert('Registration successful! Please check your email to activate your account.');
} catch (error) {
console.error(error);
alert('Registration failed.');
alert('Registrierung fehlgeschlagen');
}
},
},

View File

@@ -16,7 +16,7 @@
<button @click="generatePDF">Download PDF</button>
<div v-if="matches.length > 0">
<h3>Spiele für {{ selectedLeague }}</h3>
<table>
<table id="schedule-table">
<thead>
<tr>
<th>Datum</th>
@@ -59,8 +59,6 @@
<script>
import { mapGetters } from 'vuex';
import apiClient from '../apiClient.js';
import jsPDF from 'jspdf';
import html2canvas from 'html2canvas';
import PDFGenerator from '../components/PDFGenerator.js';
export default {
@@ -97,7 +95,7 @@ export default {
formData.append('clubId', this.currentClub);
try {
const response = await apiClient.post('/matches/import', formData, {
await apiClient.post('/matches/import', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
@@ -106,8 +104,7 @@ export default {
this.closeImportModal();
this.loadLeagues();
} catch (error) {
console.error('Fehler beim Importieren der CSV:', error);
alert('Fehler beim Importieren der CSV.');
alert('Fehler beim Importieren der CSV-Datei');
}
},
async loadLeagues() {
@@ -116,7 +113,7 @@ export default {
const response = await apiClient.get(`/matches/leagues/current/${clubId}`);
this.leagues = response.data;
} catch (error) {
console.error('Failed to load leagues:', error);
alert('Fehler beim Laden der Ligen');
}
},
async loadMatchesForLeague(leagueId, leagueName) {
@@ -125,13 +122,17 @@ export default {
const response = await apiClient.get(`/matches/leagues/${this.currentClub}/matches/${leagueId}`);
this.matches = response.data;
} catch (error) {
console.error('Failed to load matches:', error);
alert('Fehler beim Laden der Matches');
this.matches = [];
}
},
formatDate(date) {
const d = new Date(date);
const weekdays = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
const wd = weekdays[d.getDay()];
const options = { year: 'numeric', month: '2-digit', day: '2-digit' };
return new Date(date).toLocaleDateString('de-DE', options);
const day = d.toLocaleDateString('de-DE', options);
return `${wd} ${day}`;
},
highlightClubName(teamName) {
const clubName = this.currentClubName;
@@ -141,24 +142,28 @@ export default {
return teamName;
},
getCurrentClubName() {
const club = this.clubs.find(club => club.id === this.currentClub);
const clubIdNum = Number(this.currentClub);
const club = this.clubs.find(c => c.id === clubIdNum);
return club ? club.name : '';
},
async generatePDF() {
const element = this.$el.querySelector('.flex-item > div');
const highlightName = this.getCurrentClubName();
if (element) {
const pdfGen = new PDFGenerator();
await pdfGen.addSchedule(element);
pdfGen.addTitle(`Spiele für ${highlightName} in ${this.selectedLeague}`);
pdfGen.addTable('schedule-table', highlightName);
pdfGen.addNewPage();
pdfGen.addHeader('Hallen-Adressen');
const uniqueLocations = this.getUniqueLocations();
uniqueLocations.forEach((addressLines, clubName) => {
pdfGen.addAddress(clubName, addressLines);
if (!clubName.includes(highlightName)) {
pdfGen.addAddress(clubName, addressLines);
}
});
pdfGen.save('Spielpläne.pdf');
} else {
console.error('No matches found to generate PDF.');
alert('Keine Matches gefunden, um PDF zu generieren.');
}
},
getUniqueLocations() {
@@ -178,7 +183,7 @@ export default {
});
return uniqueLocations;
}
},
},
async created() {
await this.loadLeagues();

View File

@@ -0,0 +1,825 @@
<template>
<div class="tournaments-view">
<h2>Turnier</h2>
<div class="tournament-config">
<h3>Datum</h3>
<select v-model="selectedDate">
<option value="new">Neues Turnier</option>
<option v-for="date in dates" :key="date.id" :value="date.id">
{{ new Date(date.date).toLocaleDateString('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
}) }}
</option>
</select>
<div v-if="selectedDate === 'new'" class="new-tournament">
<input type="date" v-model="newDate" />
<button @click="createTournament">Erstellen</button>
</div>
</div>
<div v-if="selectedDate !== 'new'" class="tournament-setup">
<label class="checkbox-item">
<input type="checkbox" v-model="isGroupTournament" @change="onModusChange" />
<span>Spielen in Gruppen</span>
</label>
<section class="participants">
<h4>Teilnehmer</h4>
<ul>
<li v-for="participant in participants" :key="participant.id">
{{ participant.member.firstName }}
{{ participant.member.lastName }}
<template v-if="isGroupTournament">
<label class="inline-label">
Gruppe:
<select v-model.number="participant.groupNumber">
<option :value="null"></option>
<option v-for="group in groups" :key="group.groupId" :value="group.groupNumber">
Gruppe {{ group.groupNumber }}
</option>
</select>
</label>
</template>
<button @click="removeParticipant(participant)" style="margin-left:0.5rem" class="trash-btn">
🗑
</button>
</li>
</ul>
<select v-model="selectedMember">
<option v-for="member in clubMembers" :key="member.id" :value="member.id">
{{ member.firstName }}
{{ member.lastName }}
</option>
</select>
<button @click="addParticipant">Hinzufügen</button>
</section>
<section v-if="isGroupTournament" class="group-controls">
<label>
Anzahl Gruppen:
<input type="number" v-model.number="numberOfGroups" min="1" @change="onGroupCountChange" />
</label>
<label style="margin-left:1em">
Aufsteiger pro Gruppe:
<input type="number" v-model.number="advancingPerGroup" min="1" @change="onModusChange" />
</label>
<label style="margin-left:1em">
Maximale Gruppengröße:
<input type="number" v-model.number="maxGroupSize" min="1" />
</label>
<button @click="createGroups">Gruppen erstellen</button>
<button @click="randomizeGroups">Zufällig verteilen</button>
</section>
<section v-if="groups.length" class="groups-overview">
<h3>Gruppenübersicht</h3>
<div v-for="group in groups" :key="group.groupId" class="group-table">
<h4>Gruppe {{ group.groupNumber }}</h4>
<table>
<thead>
<tr>
<th>Platz</th>
<th>Spieler</th>
<th>Punkte</th>
<th>Satz</th>
<th>Diff</th>
</tr>
</thead>
<tbody>
<tr v-for="pl in groupRankings[group.groupId]" :key="pl.id">
<td>{{ pl.position }}.</td>
<td>{{ pl.name }}</td>
<td>{{ pl.points }}</td>
<td>{{ pl.setsWon }}:{{ pl.setsLost }}</td>
<td>
{{ pl.setDiff >= 0 ? '+' + pl.setDiff : pl.setDiff }}
</td>
</tr>
</tbody>
</table>
</div>
<div class="reset-controls" style="margin-top:1rem">
<button @click="resetGroups">
Gruppen zurücksetzen
</button>
<button @click="resetMatches" style="margin-left:0.5rem" class="trash-btn">
🗑 Gruppenspiele
</button>
</div>
</section>
</div>
<section v-if="groupMatches.length" class="group-matches">
<h4>Gruppenspiele</h4>
<table>
<thead>
<tr>
<th>Runde</th>
<th>Gruppe</th>
<th>Begegnung</th>
<th>Ergebnis</th>
<th>Sätze</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<tr v-for="m in groupMatches" :key="m.id">
<td>{{ m.groupRound }}</td>
<td>{{ m.groupNumber }}</td>
<td>
<template v-if="m.isFinished">
<span v-if="winnerIsPlayer1(m)">
<strong>{{ getPlayerName(m.player1) }}</strong> {{ getPlayerName(m.player2) }}
</span>
<span v-else>
{{ getPlayerName(m.player1) }} <strong>{{ getPlayerName(m.player2) }}</strong>
</span>
</template>
<template v-else>
{{ getPlayerName(m.player1) }} {{ getPlayerName(m.player2) }}
</template>
</td>
<td>
<!-- 1. Fall: Match ist noch offen EditMode -->
<template v-if="!m.isFinished">
<!-- existierende Sätze als klickbare Labels -->
<template v-for="r in m.tournamentResults" :key="r.set">
<span @click="startEditResult(m, r)" class="result-text">
{{ r.pointsPlayer1 }}:{{ r.pointsPlayer2 }}
</span>
<span v-if="!isLastResult(m, r)">, </span>
</template>
<!-- Eingabefeld für neue Sätze (immer sichtbar solange offen) -->
<div class="new-set-line">
<input v-model="m.resultInput" placeholder="Neuen Satz, z.B. 11:7"
@keyup.enter="saveMatchResult(m, m.resultInput)"
@blur="saveMatchResult(m, m.resultInput)" class="inline-input" />
</div>
</template>
<!-- 2. Fall: Match ist abgeschlossen Readonly -->
<template v-else>
{{ formatResult(m) }}
</template>
</td>
<td>
{{ getSetsString(m) }}
</td>
<td>
<!-- Abschließen-Button nur, wenn noch nicht fertig -->
<button v-if="!m.isFinished" @click="finishMatch(m)">Abschließen</button>
<!-- Korrigieren-Button nur, wenn fertig -->
<button v-else @click="reopenMatch(m)">Korrigieren</button>
</td>
</tr>
</tbody>
</table>
</section>
<div v-if="participants.length > 1
&& !groupMatches.length
&& !knockoutMatches.length" class="start-matches" style="margin-top:1.5rem">
<button @click="startMatches">
Spiele erstellen
</button>
</div>
<div v-if="canStartKnockout && !showKnockout" class="ko-start">
<button @click="startKnockout">
K.o.-Runde starten
</button>
</div>
<div v-if="showKnockout && canResetKnockout" class="ko-reset" style="margin-top:1rem">
<button @click="resetKnockout" class="trash-btn">
🗑️ K.o.-Runde
</button>
</div>
<section v-if="showKnockout" class="ko-round">
<h4>K.-o.-Runde</h4>
<table>
<thead>
<tr>
<th>Runde</th>
<th>Begegnung</th>
<th>Ergebnis</th>
<th>Sätze</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<tr v-for="m in knockoutMatches" :key="m.id">
<td>{{ m.round }}</td>
<td>
<template v-if="m.isFinished">
<span v-if="winnerIsPlayer1(m)">
<strong>{{ getPlayerName(m.player1) }}</strong>  {{ getPlayerName(m.player2) }}
</span>
<span v-else>
{{ getPlayerName(m.player1) }}  <strong>{{ getPlayerName(m.player2) }}</strong>
</span>
</template>
<template v-else>
{{ getPlayerName(m.player1) }}  {{ getPlayerName(m.player2) }}
</template>
</td>
<td>
<!-- 1. Fall: Match ist noch offen → EditMode -->
<template v-if="!m.isFinished">
<!-- existierende Sätze als klickbare Labels -->
<template v-for="r in m.tournamentResults" :key="r.set">
<span @click="startEditResult(m, r)" class="result-text">
{{ r.pointsPlayer1 }}:{{ r.pointsPlayer2 }}
</span>
<span v-if="!isLastResult(m, r)">, </span>
</template>
<!-- Eingabefeld für neue Sätze (immer sichtbar solange offen) -->
<div class="new-set-line">
<input v-model="m.resultInput" placeholder="Neuen Satz, z.B. 11:7"
@keyup.enter="saveMatchResult(m, m.resultInput)"
@blur="saveMatchResult(m, m.resultInput)" class="inline-input" />
</div>
</template>
<!-- 2. Fall: Match ist abgeschlossen → Readonly -->
<template v-else>
{{ formatResult(m) }}
</template>
</td>
<td>
{{ getSetsString(m) }}
</td>
<td>
<button v-if="!m.isFinished" @click="finishMatch(m)">Fertig</button>
<button v-else @click="reopenMatch(m)">Korrigieren</button>
</td>
</tr>
</tbody>
</table>
</section>
<section v-if="rankingList.length" class="ranking">
<h4>Rangliste</h4>
<table>
<thead>
<tr>
<th>Platz</th>
<th>Spieler</th>
</tr>
</thead>
<tbody>
<tr v-for="(entry, idx) in rankingList" :key="`${entry.member.id}-${idx}`">
<td>{{ entry.position }}.</td>
<td>
{{ entry.member.firstName }}
{{ entry.member.lastName }}
</td>
</tr>
</tbody>
</table>
</section>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import apiClient from '../apiClient';
export default {
name: 'TournamentsView',
data() {
return {
selectedDate: 'new',
newDate: '',
dates: [],
participants: [],
selectedMember: null,
clubMembers: [],
advancingPerGroup: 1,
numberOfGroups: 1,
maxGroupSize: null,
isGroupTournament: false,
groups: [],
matches: [],
showKnockout: false,
editingResult: {
matchId: null, // aktuell bearbeitetes Match
set: null, // aktuell bearbeitete SatzNummer
value: '' // Eingabewert
}
};
},
computed: {
...mapGetters(['isAuthenticated', 'currentClub']),
knockoutMatches() {
return this.matches.filter(m => m.round !== 'group');
},
groupMatches() {
return this.matches
.filter(m => m.round === 'group')
.sort((a, b) => {
// zuerst nach Runde
if (a.groupRound !== b.groupRound) {
return a.groupRound - b.groupRound;
}
// dann nach Gruppe
return a.groupNumber - b.groupNumber;
});
},
groupRankings() {
const byGroup = {};
this.groups.forEach(g => {
byGroup[g.groupId] = g.participants.map(p => ({
id: p.id,
name: p.name,
points: 0,
setsWon: 0,
setsLost: 0,
setDiff: 0,
}));
});
this.matches.forEach(m => {
if (!m.isFinished || m.round !== 'group') return;
const [s1, s2] = m.result.split(':').map(n => +n);
const arr = byGroup[m.groupId];
if (!arr) return;
const e1 = arr.find(x => x.id === m.player1.id);
const e2 = arr.find(x => x.id === m.player2.id);
if (!e1 || !e2) return;
if (s1 > s2) e1.points += 2;
else if (s2 > s1) e2.points += 2;
e1.setsWon += s1; e1.setsLost += s2;
e2.setsWon += s2; e2.setsLost += s1;
});
const rankings = {};
Object.entries(byGroup).forEach(([gid, arr]) => {
arr.forEach(p => p.setDiff = p.setsWon - p.setsLost);
arr.sort((a, b) => {
if (b.points !== a.points) return b.points - a.points;
if (b.setDiff !== a.setDiff) return b.setDiff - a.setDiff;
if (b.setsWon !== a.setsWon) return b.setsWon - a.setsWon;
return a.name.localeCompare(b.name);
});
rankings[gid] = arr.map((p, i) => ({
...p, position: i + 1
}));
});
return rankings;
},
rankingList() {
const finalMatch = this.knockoutMatches.find(
m => m.round.toLowerCase() === 'finale'
);
if (!finalMatch || !finalMatch.isFinished) return [];
const list = [];
const [s1, s2] = finalMatch.result.split(':').map(n => +n);
const winner = s1 > s2 ? finalMatch.player1 : finalMatch.player2;
const loser = s1 > s2 ? finalMatch.player2 : finalMatch.player1;
list.push({ position: 1, member: winner.member });
list.push({ position: 2, member: loser.member });
const roundsMap = {};
this.knockoutMatches.forEach(m => {
if (m.round.toLowerCase() === 'finale') return;
(roundsMap[m.round] ||= []).push(m);
});
Object.values(roundsMap).forEach(matches => {
const M = matches.length;
const pos = M + 1;
matches.forEach(match => {
const [a, b] = match.result.split(':').map(n => +n);
const knockedOut = a > b ? match.player2 : match.player1;
list.push({ position: pos, member: knockedOut.member });
});
});
return list.sort((a, b) => a.position - b.position);
},
canStartKnockout() {
if (this.participants.length < 2) return false;
if (!this.isGroupTournament) {
// kein Gruppenmodus → immer starten
return true;
}
// Gruppenmodus → nur, wenn es Gruppenspiele gibt und alle beendet sind
return this.groupMatches.length > 0
&& this.groupMatches.every(m => m.isFinished);
},
canResetKnockout() {
// KOMatches existieren und keiner ist beendet
return this.knockoutMatches.length > 0
&& this.knockoutMatches.every(m => !m.isFinished);
},
},
watch: {
selectedDate: {
immediate: true,
handler: async function (val) {
if (val === 'new') return;
await this.loadTournamentData();
}
}
},
async created() {
if (!this.isAuthenticated) {
this.$router.push('/login');
return;
}
const d = await apiClient.get(`/tournament/${this.currentClub}`);
this.dates = d.data;
const m = await apiClient.get(
`/clubmembers/get/${this.currentClub}/false`
);
this.clubMembers = m.data;
},
methods: {
normalizeResultInput(raw) {
const s = raw.trim();
if (s.includes(':')) {
const [xRaw, yRaw] = s.split(':');
const x = Number(xRaw), y = Number(yRaw);
if (
Number.isInteger(x) && Number.isInteger(y) &&
(x >= 11 || y >= 11) &&
Math.abs(x - y) >= 2
) {
return `${x}:${y}`;
}
return null;
}
const num = Number(s);
if (isNaN(num)) {
return null;
}
const losing = Math.abs(num);
const winning = losing < 10 ? 11 : losing + 2;
if (num >= 0) {
return `${winning}:${losing}`;
} else {
return `${losing}:${winning}`;
}
},
async loadTournamentData() {
const tRes = await apiClient.get(
`/tournament/${this.currentClub}/${this.selectedDate}`
);
const tournament = tRes.data;
this.isGroupTournament = tournament.type === 'groups';
this.numberOfGroups = tournament.numberOfGroups;
this.advancingPerGroup = tournament.advancingPerGroup;
const pRes = await apiClient.post('/tournament/participants', {
clubId: this.currentClub,
tournamentId: this.selectedDate
});
this.participants = pRes.data;
const gRes = await apiClient.get('/tournament/groups', {
params: {
clubId: this.currentClub,
tournamentId: this.selectedDate
}
});
this.groups = gRes.data;
const mRes = await apiClient.get(
`/tournament/matches/${this.currentClub}/${this.selectedDate}`
);
const grpMap = this.groups.reduce((m, g) => {
m[g.groupId] = g.groupNumber;
return m;
}, {});
this.matches = mRes.data.map(m => ({
...m,
groupNumber: grpMap[m.groupId] || 0,
resultInput: ''
}));
this.showKnockout = this.matches.some(m => m.round !== 'group');
},
getPlayerName(p) {
return p.member.firstName + ' ' + p.member.lastName;
},
async createTournament() {
const r = await apiClient.post('/tournament', {
clubId: this.currentClub,
tournamentName: this.newDate,
date: this.newDate
});
this.dates = r.data;
this.selectedDate = this.dates[this.dates.length - 1].id;
this.newDate = '';
},
async addParticipant() {
const oldMap = this.participants.reduce((map, p) => {
map[p.id] = p.groupNumber
return map
}, {})
const r = await apiClient.post('/tournament/participant', {
clubId: this.currentClub,
tournamentId: this.selectedDate,
participant: this.selectedMember
})
this.participants = r.data.map(p => ({
...p,
groupNumber:
oldMap[p.id] != null
? oldMap[p.id]
: (p.groupId || null)
}))
this.selectedMember = null
},
async createGroups() {
const assignments = this.participants.map(p => ({
participantId: p.id,
groupNumber: p.groupNumber
}));
const manual = assignments.some(a => a.groupNumber != null);
if (manual) {
await apiClient.post('/tournament/groups/manual', {
clubId: this.currentClub,
tournamentId: this.selectedDate,
assignments,
numberOfGroups: this.numberOfGroups,
maxGroupSize: this.maxGroupSize
});
} else {
await apiClient.put('/tournament/groups', {
clubId: this.currentClub,
tournamentId: this.selectedDate
});
}
await this.loadTournamentData();
},
async randomizeGroups() {
try {
const r = await apiClient.post('/tournament/groups', {
clubId: this.currentClub,
tournamentId: this.selectedDate
});
this.participants = r.data;
} catch (err) {
alert('Fehler beim ZufälligVerteilen:\n' +
(err.response?.data?.error || err.message));
}
await this.loadTournamentData();
},
async saveMatchResult(match, result) {
if (!result || result.trim().length === 0) {
return;
}
const normalized = this.normalizeResultInput(result);
if (!normalized) return;
result = normalized;
await apiClient.post('/tournament/match/result', {
clubId: this.currentClub,
tournamentId: this.selectedDate,
matchId: match.id,
set: (match.tournamentResults?.length || 0) + 1,
result
});
const allRes = await apiClient.get(
`/tournament/matches/${this.currentClub}/${this.selectedDate}`
);
const updated = allRes.data.find(m2 => m2.id === match.id);
if (!updated) {
alert('Fehler beim Aktualisieren des Matches');
return;
}
match.tournamentResults = updated.tournamentResults || [];
const resultString = match.tournamentResults.length
? match.tournamentResults
.sort((a, b) => a.set - b.set)
.map(r => `${Math.abs(r.pointsPlayer1)}:${Math.abs(r.pointsPlayer2)}`)
.join(', ')
: null;
match.result = resultString;
match.resultInput = '';
},
async finishMatch(match) {
await apiClient.post('/tournament/match/finish', {
clubId: this.currentClub,
tournamentId: this.selectedDate,
matchId: match.id
});
await this.loadTournamentData();
},
async startKnockout() {
await apiClient.post('/tournament/knockout', {
clubId: this.currentClub,
tournamentId: this.selectedDate
});
await this.loadTournamentData();
},
formatResult(match) {
if (!match.tournamentResults?.length) return '-';
return match.tournamentResults
.sort((a, b) => a.set - b.set)
.map(r => `${Math.abs(r.pointsPlayer1)}:${Math.abs(r.pointsPlayer2)}`)
.join(', ');
},
async startMatches() {
if (this.isGroupTournament) {
if (!this.groups.length) {
await this.createGroups();
}
await this.randomizeGroups();
} else {
await this.startKnockout();
}
await this.loadTournamentData();
},
async onModusChange() {
const type = this.isGroupTournament ? 'groups' : 'knockout';
await apiClient.post('/tournament/modus', {
clubId: this.currentClub,
tournamentId: this.selectedDate,
type,
numberOfGroups: this.numberOfGroups,
advancingPerGroup: this.advancingPerGroup
});
await this.loadTournamentData();
},
async resetGroups() {
await apiClient.post('/tournament/groups/reset', {
clubId: this.currentClub,
tournamentId: this.selectedDate
});
await this.loadTournamentData();
},
async resetMatches() {
await apiClient.post('/tournament/matches/reset', {
clubId: this.currentClub,
tournamentId: this.selectedDate
});
await this.loadTournamentData();
},
async removeParticipant(p) {
await apiClient.delete('/tournament/participant', {
data: {
clubId: this.currentClub,
tournamentId: this.selectedDate,
participantId: p.id
}
});
this.participants = this.participants.filter(x => x.id !== p.id);
},
async onGroupCountChange() {
await apiClient.post('/tournament/modus', {
clubId: this.currentClub,
tournamentId: this.selectedDate,
type: this.isGroupTournament ? 'groups' : 'knockout',
numberOfGroups: this.numberOfGroups
});
await this.loadTournamentData();
},
async reopenMatch(match) {
await apiClient.post('/tournament/match/reopen', {
clubId: this.currentClub,
tournamentId: this.selectedDate,
matchId: match.id
});
await this.loadTournamentData();
},
async deleteResult(match, set) {
if (match.isFinished) {
await this.reopenMatch(match);
match.isFinished = false;
}
await apiClient.delete('/tournament/match/result', {
data: {
clubId: this.currentClub,
tournamentId: this.selectedDate,
matchId: match.id,
set
}
});
await this.loadTournamentData();
},
startEditResult(match, result) {
if (match.isFinished) {
this.reopenMatch(match);
match.isFinished = false;
}
this.editingResult.matchId = match.id;
this.editingResult.set = result.set;
this.editingResult.value = `${result.pointsPlayer1}:${result.pointsPlayer2}`;
},
async saveEditedResult(match) {
const { set, value } = this.editingResult;
const normalized = this.normalizeResultInput(value);
if (!normalized) return;
let result = normalized;
await apiClient.post('/tournament/match/result', {
clubId: this.currentClub,
tournamentId: this.selectedDate,
matchId: match.id,
set,
result
});
this.editingResult.matchId = null;
this.editingResult.set = null;
this.editingResult.value = '';
await this.loadTournamentData();
},
isEditing(match, set) {
return (
this.editingResult.matchId === match.id &&
this.editingResult.set === set
);
},
isLastResult(match, result) {
const arr = match.tournamentResults || [];
return arr.length > 0 && arr[arr.length - 1].set === result.set;
},
getSetsString(match) {
const results = match.tournamentResults || [];
let win1 = 0, win2 = 0;
for (const r of results) {
if (r.pointsPlayer1 > r.pointsPlayer2) win1++;
else if (r.pointsPlayer2 > r.pointsPlayer1) win2++;
}
return `${win1}:${win2}`;
},
winnerIsPlayer1(match) {
const [w1, w2] = this.getSetsString(match).split(':').map(Number);
return w1 > w2;
},
async resetKnockout() {
try {
await apiClient.delete('/tournament/matches/knockout', {
data: {
clubId: this.currentClub,
tournamentId: this.selectedDate
}
});
await this.loadTournamentData();
} catch (err) {
alert('Fehler beim Zurücksetzen der K.o.-Runde');
}
}
}
};
</script>
<style scoped>
.tournaments-view {
padding: 1rem;
}
.participants,
.group-controls,
.groups-overview,
.ko-round,
.ko-start {
margin-top: 1.5rem;
}
.group-table {
margin-bottom: 2rem;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 0.5em;
border: 1px solid #ccc;
text-align: left;
}
button {
margin-left: 0.5em;
}
</style>

View File

@@ -0,0 +1,427 @@
<template>
<div class="training-stats">
<h2>Trainings-Statistik</h2>
<div class="stats-overview">
<div class="stats-summary">
<div class="stat-card">
<h3>Aktive Mitglieder</h3>
<div class="stat-number">{{ activeMembers.length }}</div>
</div>
<div class="stat-card">
<h3>Durchschnittliche Teilnahme (12 Monate)</h3>
<div class="stat-number">{{ averageParticipation12Months.toFixed(1) }}</div>
</div>
<div class="stat-card">
<h3>Durchschnittliche Teilnahme (3 Monate)</h3>
<div class="stat-number">{{ averageParticipation3Months.toFixed(1) }}</div>
</div>
</div>
</div>
<div class="members-table-container">
<table class="members-table">
<thead>
<tr>
<th>Name</th>
<th>Geburtsdatum</th>
<th>Teilnahmen (12 Monate)</th>
<th>Teilnahmen (3 Monate)</th>
<th>Teilnahmen (Gesamt)</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<tr v-for="member in activeMembers" :key="member.id" class="member-row">
<td>{{ member.firstName }} {{ member.lastName }}</td>
<td>{{ formatBirthdate(member.birthDate) }}</td>
<td>{{ member.participation12Months }}</td>
<td>{{ member.participation3Months }}</td>
<td>{{ member.participationTotal }}</td>
<td>
<button @click="showMemberDetails(member)" class="btn-primary btn-small">
Details anzeigen
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Modal für Mitgliedsdetails -->
<div v-if="showDetailsModal" class="modal">
<div class="modal-content">
<span class="close" @click="closeDetailsModal">&times;</span>
<h3>Trainings-Details: {{ selectedMember.firstName }} {{ selectedMember.lastName }}</h3>
<div class="member-info">
<p><strong>Geburtsdatum:</strong> {{ formatBirthdate(selectedMember.birthDate) }}</p>
<p><strong>Geburtsjahr:</strong> {{ getBirthYear(selectedMember.birthDate) }}</p>
</div>
<div class="participation-summary">
<div class="summary-item">
<span class="label">Letzte 12 Monate:</span>
<span class="value">{{ selectedMember.participation12Months }}</span>
</div>
<div class="summary-item">
<span class="label">Letzte 3 Monate:</span>
<span class="value">{{ selectedMember.participation3Months }}</span>
</div>
<div class="summary-item">
<span class="label">Gesamt:</span>
<span class="value">{{ selectedMember.participationTotal }}</span>
</div>
</div>
<div class="training-details">
<h4>Trainingsteilnahmen (absteigend sortiert)</h4>
<div class="training-list">
<div v-for="training in selectedMember.trainingDetails" :key="training.id" class="training-item">
<div class="training-date">{{ formatDate(training.date) }}</div>
<div class="training-activity">{{ training.activityName }}</div>
<div class="training-time">{{ training.startTime }} - {{ training.endTime }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import apiClient from '../apiClient';
export default {
name: 'TrainingStatsView',
computed: {
...mapGetters(['isAuthenticated', 'currentClub']),
averageParticipation12Months() {
if (this.activeMembers.length === 0) return 0;
const total = this.activeMembers.reduce((sum, member) => sum + member.participation12Months, 0);
return total / this.activeMembers.length;
},
averageParticipation3Months() {
if (this.activeMembers.length === 0) return 0;
const total = this.activeMembers.reduce((sum, member) => sum + member.participation3Months, 0);
return total / this.activeMembers.length;
}
},
data() {
return {
activeMembers: [],
showDetailsModal: false,
selectedMember: {},
loading: false
};
},
async mounted() {
if (this.currentClub) {
await this.loadTrainingStats();
}
},
watch: {
currentClub: {
handler(newClub) {
if (newClub) {
this.loadTrainingStats();
}
},
immediate: true
}
},
methods: {
async loadTrainingStats() {
if (!this.currentClub) return;
this.loading = true;
try {
const response = await apiClient.get(`/training-stats/${this.currentClub}`);
this.activeMembers = response.data;
} catch (error) {
// Kein Alert - es ist normal, dass nicht alle Daten verfügbar sind
} finally {
this.loading = false;
}
},
showMemberDetails(member) {
this.selectedMember = member;
this.showDetailsModal = true;
},
closeDetailsModal() {
this.showDetailsModal = false;
this.selectedMember = {};
},
formatBirthdate(birthDate) {
if (!birthDate) return '-';
const date = new Date(birthDate);
return `${String(date.getDate()).padStart(2, '0')}.${String(date.getMonth() + 1).padStart(2, '0')}.${date.getFullYear()}`;
},
getBirthYear(birthDate) {
if (!birthDate) return '-';
const date = new Date(birthDate);
return date.getFullYear();
},
formatDate(dateString) {
const date = new Date(dateString);
return `${String(date.getDate()).padStart(2, '0')}.${String(date.getMonth() + 1).padStart(2, '0')}.${date.getFullYear()}`;
}
}
};
</script>
<style scoped>
.training-stats {
padding: 1.5rem;
max-width: 1200px;
margin: 0 auto;
}
.stats-overview {
margin-bottom: 2rem;
}
.stats-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
padding: 1.5rem;
border-radius: var(--border-radius-large);
box-shadow: var(--shadow-light);
text-align: center;
border: 1px solid var(--border-color);
}
.stat-card h3 {
margin: 0 0 1rem 0;
font-size: 0.875rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.025em;
}
.stat-number {
font-size: 2rem;
font-weight: 700;
color: var(--primary-color);
}
.members-table-container {
background: white;
border-radius: var(--border-radius-large);
box-shadow: var(--shadow-light);
overflow: hidden;
border: 1px solid var(--border-color);
}
.members-table {
width: 100%;
border-collapse: collapse;
}
.members-table th {
background: var(--bg-light);
padding: 1rem;
text-align: left;
font-weight: 600;
color: var(--text-primary);
border-bottom: 1px solid var(--border-color);
}
.members-table td {
padding: 1rem;
border-bottom: 1px solid var(--border-color);
vertical-align: middle;
}
.members-table tr:hover {
background: var(--bg-light);
}
.member-row {
cursor: pointer;
}
.member-row:hover {
background: var(--bg-light);
}
.btn-small {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
min-height: 1.875rem;
}
/* Modal Styles */
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: white;
border-radius: var(--border-radius-large);
padding: 2rem;
max-width: 800px;
max-height: 90vh;
overflow-y: auto;
position: relative;
box-shadow: var(--shadow-heavy);
}
.close {
position: absolute;
top: 1rem;
right: 1.5rem;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-secondary);
font-weight: bold;
}
.close:hover {
color: var(--text-primary);
}
.member-info {
background: var(--bg-light);
padding: 1rem;
border-radius: var(--border-radius);
margin-bottom: 1.5rem;
}
.member-info p {
margin: 0.5rem 0;
}
.participation-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.summary-item {
background: white;
padding: 1rem;
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
text-align: center;
}
.summary-item .label {
display: block;
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.summary-item .value {
display: block;
font-size: 1.5rem;
font-weight: 700;
color: var(--primary-color);
}
.training-details h4 {
margin: 0 0 1rem 0;
color: var(--text-primary);
}
.training-list {
max-height: 400px;
overflow-y: auto;
}
.training-item {
display: grid;
grid-template-columns: 120px 1fr 150px;
gap: 1rem;
padding: 0.75rem;
border-bottom: 1px solid var(--border-color);
align-items: center;
}
.training-item:last-child {
border-bottom: none;
}
.training-item:hover {
background: var(--bg-light);
}
.training-date {
font-weight: 600;
color: var(--text-primary);
}
.training-activity {
color: var(--text-secondary);
}
.training-time {
font-size: 0.875rem;
color: var(--text-muted);
text-align: right;
}
/* Responsive Design */
@media (max-width: 768px) {
.training-stats {
padding: 1rem;
}
.stats-summary {
grid-template-columns: 1fr;
}
.members-table {
font-size: 0.875rem;
}
.members-table th,
.members-table td {
padding: 0.75rem 0.5rem;
}
.modal-content {
margin: 1rem;
padding: 1.5rem;
}
.training-item {
grid-template-columns: 1fr;
gap: 0.5rem;
text-align: center;
}
.training-time {
text-align: center;
}
}
</style>

316
package-lock.json generated
View File

@@ -17,6 +17,7 @@
"@babel/cli": "^7.24.8",
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.3",
"concurrently": "^8.2.2",
"eslint": "^8.57.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-plugin-import": "^2.29.1"
@@ -66,14 +67,14 @@
}
},
"node_modules/@babel/code-frame": {
"version": "7.26.2",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
"integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.25.9",
"@babel/helper-validator-identifier": "^7.27.1",
"js-tokens": "^4.0.0",
"picocolors": "^1.0.0"
"picocolors": "^1.1.1"
},
"engines": {
"node": ">=6.9.0"
@@ -356,18 +357,18 @@
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz",
"integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
"integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -398,25 +399,25 @@
}
},
"node_modules/@babel/helpers": {
"version": "7.26.9",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.9.tgz",
"integrity": "sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA==",
"version": "7.27.6",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz",
"integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==",
"license": "MIT",
"dependencies": {
"@babel/template": "^7.26.9",
"@babel/types": "^7.26.9"
"@babel/template": "^7.27.2",
"@babel/types": "^7.27.6"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.26.9",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz",
"integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==",
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz",
"integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.26.9"
"@babel/types": "^7.28.0"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -1508,27 +1509,24 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.26.9",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz",
"integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==",
"version": "7.27.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.26.9",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz",
"integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==",
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.26.2",
"@babel/parser": "^7.26.9",
"@babel/types": "^7.26.9"
"@babel/code-frame": "^7.27.1",
"@babel/parser": "^7.27.2",
"@babel/types": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -1553,13 +1551,13 @@
}
},
"node_modules/@babel/types": {
"version": "7.26.9",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz",
"integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==",
"version": "7.28.1",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.1.tgz",
"integrity": "sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.25.9",
"@babel/helper-validator-identifier": "^7.25.9"
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1"
},
"engines": {
"node": ">=6.9.0"
@@ -2096,9 +2094,9 @@
}
},
"node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
@@ -2272,6 +2270,21 @@
"fsevents": "~2.3.2"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2306,6 +2319,50 @@
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"license": "MIT"
},
"node_modules/concurrently": {
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz",
"integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "^4.1.2",
"date-fns": "^2.30.0",
"lodash": "^4.17.21",
"rxjs": "^7.8.1",
"shell-quote": "^1.8.1",
"spawn-command": "0.0.2",
"supports-color": "^8.1.1",
"tree-kill": "^1.2.2",
"yargs": "^17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": "^14.13.0 || >=16.0.0"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/concurrently/node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/confusing-browser-globals": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz",
@@ -2401,6 +2458,23 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/date-fns": {
"version": "2.30.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.21.0"
},
"engines": {
"node": ">=0.11"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/date-fns"
}
},
"node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
@@ -2493,6 +2567,13 @@
"integrity": "sha512-AidaH9JETVRr9DIPGfp1kAarm/W6hRJTPuCnkF+2MqhF4KaAgRIcBc8nvjk+YMXZhwfISof/7WG29eS4iGxQLQ==",
"license": "ISC"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/es-abstract": {
"version": "1.23.9",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz",
@@ -3201,6 +3282,16 @@
"node": ">=6.9.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -3671,6 +3762,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-generator-function": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz",
@@ -4487,13 +4588,6 @@
"node": ">=4"
}
},
"node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"dev": true,
"license": "MIT"
},
"node_modules/regenerator-transform": {
"version": "0.15.2",
"resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz",
@@ -4576,6 +4670,16 @@
"node": ">=6"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@@ -4655,6 +4759,16 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/safe-array-concat": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
@@ -4789,6 +4903,19 @@
"node": ">=8"
}
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@@ -4875,6 +5002,27 @@
"node": ">=6"
}
},
"node_modules/spawn-command": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz",
"integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
"dev": true
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string.prototype.trim": {
"version": "1.2.10",
"resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz",
@@ -5013,6 +5161,16 @@
"node": ">=8.0"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/tsconfig-paths": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
@@ -5039,6 +5197,13 @@
"json5": "lib/cli.js"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -5438,18 +5603,75 @@
"node": ">=0.10.0"
}
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"license": "ISC"
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@@ -3,6 +3,9 @@
"version": "1.0.0",
"main": "index.js",
"scripts": {
"dev": "concurrently \"cd backend && npm run dev\" \"cd frontend && npm run dev\"",
"dev:backend": "cd backend && npm run dev",
"dev:frontend": "cd frontend && npm run dev",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
@@ -13,6 +16,7 @@
"@babel/cli": "^7.24.8",
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.3",
"concurrently": "^8.2.2",
"eslint": "^8.57.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-plugin-import": "^2.29.1"