Add training times management and enhance diary view with group selection dialog

This commit introduces the `TrainingTime` model and related functionality, allowing for the management of training times associated with training groups. The backend is updated to include new routes for training times, while the frontend is enhanced with a new dialog in the `DiaryView` for selecting training groups and suggesting available training times. This improves user experience by streamlining the process of scheduling training sessions and managing associated data.
This commit is contained in:
Torsten Schulz (local)
2025-11-15 20:51:08 +01:00
parent 7a9e856961
commit 54ce09e9a9
11 changed files with 1117 additions and 28 deletions

View File

@@ -0,0 +1,80 @@
import trainingTimeService from '../services/trainingTimeService.js';
import { getSafeErrorMessage } from '../utils/errorUtils.js';
export const getTrainingTimes = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const groups = await trainingTimeService.getTrainingTimes(userToken, clubId);
res.status(200).json(groups);
} catch (error) {
console.error('[getTrainingTimes] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Laden der Trainingszeiten');
res.status(error.statusCode || 500).json({ error: msg });
}
};
export const createTrainingTime = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const { trainingGroupId, weekday, startTime, endTime } = req.body;
if (!trainingGroupId || weekday === undefined || !startTime || !endTime) {
return res.status(400).json({ error: 'Alle Felder müssen ausgefüllt sein' });
}
const trainingTime = await trainingTimeService.createTrainingTime(
userToken,
clubId,
trainingGroupId,
weekday,
startTime,
endTime
);
res.status(201).json(trainingTime);
} catch (error) {
console.error('[createTrainingTime] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Erstellen der Trainingszeit');
res.status(error.statusCode || 500).json({ error: msg });
}
};
export const updateTrainingTime = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, timeId } = req.params;
const { weekday, startTime, endTime } = req.body;
const trainingTime = await trainingTimeService.updateTrainingTime(
userToken,
clubId,
timeId,
weekday,
startTime,
endTime
);
res.status(200).json(trainingTime);
} catch (error) {
console.error('[updateTrainingTime] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Aktualisieren der Trainingszeit');
res.status(error.statusCode || 500).json({ error: msg });
}
};
export const deleteTrainingTime = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, timeId } = req.params;
const result = await trainingTimeService.deleteTrainingTime(userToken, clubId, timeId);
res.status(200).json(result);
} catch (error) {
console.error('[deleteTrainingTime] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Löschen der Trainingszeit');
res.status(error.statusCode || 500).json({ error: msg });
}
};

View File

@@ -0,0 +1,19 @@
-- Migration: Create training_times table
-- Date: 2025-01-16
-- For MariaDB/MySQL
-- Stores training times for training groups
CREATE TABLE IF NOT EXISTS `training_times` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`training_group_id` INT(11) NOT NULL,
`weekday` TINYINT(1) NOT NULL COMMENT '0 = Sunday, 1 = Monday, ..., 6 = Saturday',
`start_time` TIME NOT NULL,
`end_time` TIME NOT NULL,
`sort_order` INT(11) NOT NULL DEFAULT 0 COMMENT 'Order for displaying multiple times on the same weekday',
`created_at` DATETIME NOT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `training_group_id` (`training_group_id`),
CONSTRAINT `training_times_ibfk_1` FOREIGN KEY (`training_group_id`) REFERENCES `training_group` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,47 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
import TrainingGroup from './TrainingGroup.js';
const TrainingTime = sequelize.define('TrainingTime', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
trainingGroupId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: TrainingGroup,
key: 'id',
},
onDelete: 'CASCADE',
},
weekday: {
type: DataTypes.INTEGER,
allowNull: false,
comment: '0 = Sunday, 1 = Monday, ..., 6 = Saturday'
},
startTime: {
type: DataTypes.TIME,
allowNull: false,
},
endTime: {
type: DataTypes.TIME,
allowNull: false,
},
sortOrder: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
comment: 'Order for displaying multiple times on the same weekday'
}
}, {
tableName: 'training_times',
underscored: true,
timestamps: true,
});
export default TrainingTime;

View File

@@ -47,6 +47,7 @@ import MemberImage from './MemberImage.js';
import TrainingGroup from './TrainingGroup.js';
import MemberTrainingGroup from './MemberTrainingGroup.js';
import ClubDisabledPresetGroup from './ClubDisabledPresetGroup.js';
import TrainingTime from './TrainingTime.js';
// Official tournaments relations
OfficialTournament.hasMany(OfficialCompetition, { foreignKey: 'tournamentId', as: 'competitions' });
OfficialCompetition.belongsTo(OfficialTournament, { foreignKey: 'tournamentId', as: 'tournament' });
@@ -316,6 +317,10 @@ TrainingGroup.belongsToMany(Member, {
Club.hasMany(ClubDisabledPresetGroup, { foreignKey: 'clubId', as: 'disabledPresetGroups' });
ClubDisabledPresetGroup.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
// Training Times
TrainingGroup.hasMany(TrainingTime, { foreignKey: 'trainingGroupId', as: 'trainingTimes' });
TrainingTime.belongsTo(TrainingGroup, { foreignKey: 'trainingGroupId', as: 'trainingGroup' });
export {
User,
Log,
@@ -365,4 +370,5 @@ export {
TrainingGroup,
MemberTrainingGroup,
ClubDisabledPresetGroup,
TrainingTime,
};

View File

@@ -0,0 +1,19 @@
import express from 'express';
import { authenticate } from '../middleware/authMiddleware.js';
import {
getTrainingTimes,
createTrainingTime,
updateTrainingTime,
deleteTrainingTime
} from '../controllers/trainingTimeController.js';
const router = express.Router();
router.use(authenticate);
router.get('/:clubId', getTrainingTimes);
router.post('/:clubId', createTrainingTime);
router.put('/:clubId/:timeId', updateTrainingTime);
router.delete('/:clubId/:timeId', deleteTrainingTime);
export default router;

View File

@@ -1,15 +1,22 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { development } from '../config.js';
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Load .env from backend directory
dotenv.config({ path: join(__dirname, '..', '.env') });
const report = [];
const dbConfig = {
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'trainingdiary',
host: process.env.DB_HOST || development.host || 'localhost',
user: process.env.DB_USER || development.username || 'root',
password: process.env.DB_PASSWORD || development.password || '',
database: process.env.DB_NAME || development.database || 'trainingdiary',
};
async function getTables(connection) {

View File

@@ -47,6 +47,7 @@ import permissionRoutes from './routes/permissionRoutes.js';
import apiLogRoutes from './routes/apiLogRoutes.js';
import memberTransferConfigRoutes from './routes/memberTransferConfigRoutes.js';
import trainingGroupRoutes from './routes/trainingGroupRoutes.js';
import trainingTimeRoutes from './routes/trainingTimeRoutes.js';
import schedulerService from './services/schedulerService.js';
import { requestLoggingMiddleware } from './middleware/requestLoggingMiddleware.js';
@@ -134,6 +135,7 @@ app.use('/api/permissions', permissionRoutes);
app.use('/api/logs', apiLogRoutes);
app.use('/api/member-transfer-config', memberTransferConfigRoutes);
app.use('/api/training-groups', trainingGroupRoutes);
app.use('/api/training-times', trainingTimeRoutes);
app.use(express.static(path.join(__dirname, '../frontend/dist')));

View File

@@ -0,0 +1,142 @@
import { checkAccess } from '../utils/userUtils.js';
import TrainingTime from '../models/TrainingTime.js';
import TrainingGroup from '../models/TrainingGroup.js';
import HttpError from '../exceptions/HttpError.js';
class TrainingTimeService {
async getTrainingTimes(userToken, clubId) {
await checkAccess(userToken, clubId);
const groups = await TrainingGroup.findAll({
where: { clubId },
include: [{
model: TrainingTime,
as: 'trainingTimes',
required: false,
separate: true,
order: [['weekday', 'ASC'], ['sortOrder', 'ASC'], ['startTime', 'ASC']]
}],
order: [['isPreset', 'DESC'], ['sortOrder', 'ASC'], ['name', 'ASC']]
});
return groups.map(group => {
const groupData = group.toJSON ? group.toJSON() : group;
return {
...groupData,
trainingTimes: Array.isArray(groupData.trainingTimes)
? groupData.trainingTimes.sort((a, b) => {
if (a.weekday !== b.weekday) return a.weekday - b.weekday;
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder;
return a.startTime.localeCompare(b.startTime);
})
: []
};
});
}
async createTrainingTime(userToken, clubId, trainingGroupId, weekday, startTime, endTime) {
await checkAccess(userToken, clubId);
// Validate that the training group belongs to the club
const group = await TrainingGroup.findOne({
where: { id: trainingGroupId, clubId }
});
if (!group) {
throw new HttpError('Trainingsgruppe nicht gefunden', 404);
}
// Validate weekday (0-6)
if (weekday < 0 || weekday > 6) {
throw new HttpError('Ungültiger Wochentag (0-6)', 400);
}
// Validate times
if (!startTime || !endTime) {
throw new HttpError('Start- und Endzeit müssen angegeben werden', 400);
}
if (startTime >= endTime) {
throw new HttpError('Startzeit muss vor Endzeit liegen', 400);
}
// Get max sortOrder for this weekday and group
const maxSortOrder = await TrainingTime.max('sortOrder', {
where: {
trainingGroupId,
weekday
}
}) || 0;
const trainingTime = await TrainingTime.create({
trainingGroupId,
weekday,
startTime,
endTime,
sortOrder: maxSortOrder + 1
});
return trainingTime;
}
async updateTrainingTime(userToken, clubId, timeId, weekday, startTime, endTime) {
await checkAccess(userToken, clubId);
const trainingTime = await TrainingTime.findByPk(timeId, {
include: [{
model: TrainingGroup,
as: 'trainingGroup'
}]
});
if (!trainingTime || !trainingTime.trainingGroup) {
throw new HttpError('Trainingszeit nicht gefunden', 404);
}
if (trainingTime.trainingGroup.clubId !== parseInt(clubId)) {
throw new HttpError('Keine Berechtigung', 403);
}
// Validate weekday (0-6)
if (weekday !== undefined && (weekday < 0 || weekday > 6)) {
throw new HttpError('Ungültiger Wochentag (0-6)', 400);
}
// Validate times
if (startTime && endTime && startTime >= endTime) {
throw new HttpError('Startzeit muss vor Endzeit liegen', 400);
}
if (weekday !== undefined) trainingTime.weekday = weekday;
if (startTime !== undefined) trainingTime.startTime = startTime;
if (endTime !== undefined) trainingTime.endTime = endTime;
await trainingTime.save();
return trainingTime;
}
async deleteTrainingTime(userToken, clubId, timeId) {
await checkAccess(userToken, clubId);
const trainingTime = await TrainingTime.findByPk(timeId, {
include: [{
model: TrainingGroup,
as: 'trainingGroup'
}]
});
if (!trainingTime || !trainingTime.trainingGroup) {
throw new HttpError('Trainingszeit nicht gefunden', 404);
}
if (trainingTime.trainingGroup.clubId !== parseInt(clubId)) {
throw new HttpError('Keine Berechtigung', 403);
}
await trainingTime.destroy();
return { success: true };
}
}
export default new TrainingTimeService();

View File

@@ -0,0 +1,483 @@
<template>
<div class="training-times-tab">
<div v-if="loading" class="loading">Lade Trainingszeiten...</div>
<div v-else class="groups-section">
<div
v-for="group in groups"
:key="group.id"
class="group-card"
>
<div class="group-header">
<h3>{{ group.name }}</h3>
<button
@click="showAddTimeForm(group.id)"
class="btn-primary btn-small"
>
+ Zeit hinzufügen
</button>
</div>
<!-- Add Time Form -->
<div v-if="addTimeFormGroupId === group.id" class="add-time-form">
<div class="form-row">
<label>
<span>Wochentag:</span>
<select v-model="newTime.weekday" class="input-field">
<option :value="0">Sonntag</option>
<option :value="1">Montag</option>
<option :value="2">Dienstag</option>
<option :value="3">Mittwoch</option>
<option :value="4">Donnerstag</option>
<option :value="5">Freitag</option>
<option :value="6">Samstag</option>
</select>
</label>
<label>
<span>Von:</span>
<input
v-model="newTime.startTime"
type="time"
class="input-field"
/>
</label>
<label>
<span>Bis:</span>
<input
v-model="newTime.endTime"
type="time"
class="input-field"
/>
</label>
</div>
<div class="form-actions">
<button @click="createTime(group.id)" class="btn-primary">Erstellen</button>
<button @click="cancelAddTime" class="btn-secondary">Abbrechen</button>
</div>
</div>
<!-- Times List -->
<div v-if="group.trainingTimes && group.trainingTimes.length > 0" class="times-list">
<div
v-for="time in group.trainingTimes"
:key="time.id"
class="time-item"
>
<div class="time-info">
<span class="weekday">{{ getWeekdayName(time.weekday) }}</span>
<span class="time-range">{{ formatTime(time.startTime) }} - {{ formatTime(time.endTime) }}</span>
</div>
<div class="time-actions">
<button
@click="editTime(time)"
class="btn-icon"
title="Bearbeiten"
>
</button>
<button
@click="deleteTime(time.id)"
class="btn-icon btn-danger"
title="Löschen"
>
🗑
</button>
</div>
</div>
</div>
<div v-else class="no-times">
Keine Trainingszeiten definiert
</div>
</div>
</div>
<!-- Edit Time Dialog -->
<div v-if="editingTime" class="edit-time-dialog">
<div class="dialog-content">
<h3>Trainingszeit bearbeiten</h3>
<div class="form-row">
<label>
<span>Wochentag:</span>
<select v-model="editingTime.weekday" class="input-field">
<option :value="0">Sonntag</option>
<option :value="1">Montag</option>
<option :value="2">Dienstag</option>
<option :value="3">Mittwoch</option>
<option :value="4">Donnerstag</option>
<option :value="5">Freitag</option>
<option :value="6">Samstag</option>
</select>
</label>
<label>
<span>Von:</span>
<input
v-model="editingTime.startTime"
type="time"
class="input-field"
/>
</label>
<label>
<span>Bis:</span>
<input
v-model="editingTime.endTime"
type="time"
class="input-field"
/>
</label>
</div>
<div class="form-actions">
<button @click="saveTime" class="btn-primary">Speichern</button>
<button @click="cancelEdit" class="btn-secondary">Abbrechen</button>
</div>
</div>
</div>
</div>
</template>
<script>
import apiClient from '../apiClient.js';
import { mapGetters } from 'vuex';
import { getSafeErrorMessage } from '../utils/errorMessages.js';
export default {
name: 'TrainingTimesTab',
computed: {
...mapGetters(['currentClub']),
},
data() {
return {
groups: [],
loading: false,
addTimeFormGroupId: null,
newTime: {
weekday: 1,
startTime: '',
endTime: ''
},
editingTime: null
};
},
mounted() {
this.loadTrainingTimes();
},
methods: {
async loadTrainingTimes() {
this.loading = true;
try {
const response = await apiClient.get(`/training-times/${this.currentClub}`);
this.groups = Array.isArray(response.data) ? response.data : [];
} catch (error) {
console.error('[loadTrainingTimes] Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Laden der Trainingszeiten');
alert(msg);
this.groups = [];
} finally {
this.loading = false;
}
},
showAddTimeForm(groupId) {
this.addTimeFormGroupId = groupId;
this.newTime = {
weekday: 1,
startTime: '',
endTime: ''
};
},
cancelAddTime() {
this.addTimeFormGroupId = null;
this.newTime = {
weekday: 1,
startTime: '',
endTime: ''
};
},
async createTime(groupId) {
if (!this.newTime.startTime || !this.newTime.endTime) {
alert('Bitte füllen Sie alle Felder aus');
return;
}
try {
await apiClient.post(`/training-times/${this.currentClub}`, {
trainingGroupId: groupId,
weekday: this.newTime.weekday,
startTime: this.newTime.startTime,
endTime: this.newTime.endTime
});
this.cancelAddTime();
await this.loadTrainingTimes();
} catch (error) {
console.error('[createTime] Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Erstellen der Trainingszeit');
alert(msg);
}
},
editTime(time) {
this.editingTime = {
id: time.id,
weekday: time.weekday,
startTime: this.formatTimeForInput(time.startTime),
endTime: this.formatTimeForInput(time.endTime)
};
},
cancelEdit() {
this.editingTime = null;
},
async saveTime() {
if (!this.editingTime.startTime || !this.editingTime.endTime) {
alert('Bitte füllen Sie alle Felder aus');
return;
}
try {
await apiClient.put(`/training-times/${this.currentClub}/${this.editingTime.id}`, {
weekday: this.editingTime.weekday,
startTime: this.editingTime.startTime,
endTime: this.editingTime.endTime
});
this.cancelEdit();
await this.loadTrainingTimes();
} catch (error) {
console.error('[saveTime] Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Speichern der Trainingszeit');
alert(msg);
}
},
async deleteTime(timeId) {
if (!confirm('Möchten Sie diese Trainingszeit wirklich löschen?')) {
return;
}
try {
await apiClient.delete(`/training-times/${this.currentClub}/${timeId}`);
await this.loadTrainingTimes();
} catch (error) {
console.error('[deleteTime] Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Löschen der Trainingszeit');
alert(msg);
}
},
getWeekdayName(weekday) {
const days = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
return days[weekday] || '';
},
formatTime(time) {
if (!time) return '';
// Time comes as HH:MM:SS or HH:MM, we want HH:MM
return time.substring(0, 5);
},
formatTimeForInput(time) {
if (!time) return '';
// Ensure format HH:MM for time input
return time.substring(0, 5);
}
}
};
</script>
<style scoped>
.training-times-tab {
padding: 20px;
}
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.groups-section {
display: flex;
flex-direction: column;
gap: 20px;
}
.group-card {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 16px;
}
.group-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.group-header h3 {
margin: 0;
font-size: 1.2rem;
}
.btn-small {
padding: 6px 12px;
font-size: 0.9rem;
}
.add-time-form {
background: #f9f9f9;
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 12px;
margin-bottom: 16px;
}
.form-row {
display: flex;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.form-row label {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 150px;
}
.form-row span {
font-size: 0.9rem;
color: #666;
}
.input-field {
padding: 6px 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
}
.form-actions {
display: flex;
gap: 8px;
}
.times-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.time-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
background: #f9f9f9;
border: 1px solid #e0e0e0;
border-radius: 4px;
}
.time-info {
display: flex;
gap: 16px;
align-items: center;
}
.weekday {
font-weight: 600;
min-width: 100px;
}
.time-range {
color: #666;
}
.time-actions {
display: flex;
gap: 8px;
}
.btn-icon {
background: none;
border: none;
cursor: pointer;
font-size: 1.2rem;
padding: 4px 8px;
border-radius: 4px;
transition: background 0.2s;
}
.btn-icon:hover {
background: #f0f0f0;
}
.btn-danger:hover {
background: #fee;
}
.no-times {
color: #999;
font-style: italic;
padding: 20px;
text-align: center;
}
.edit-time-dialog {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog-content {
background: #fff;
border-radius: 8px;
padding: 24px;
max-width: 500px;
width: 90%;
}
.dialog-content h3 {
margin: 0 0 20px 0;
}
.btn-primary {
background: #28a745;
color: #fff;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
}
.btn-primary:hover {
background: #218838;
}
.btn-secondary {
background: #6c757d;
color: #fff;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
}
.btn-secondary:hover {
background: #5a6268;
}
</style>

View File

@@ -16,6 +16,12 @@
>
👨👩👧👦 Trainingsgruppen
</button>
<button
:class="['tab-button', { active: activeTab === 'training-times' }]"
@click="activeTab = 'training-times'"
>
🕐 Trainingszeiten
</button>
</div>
<!-- Settings Tab -->
@@ -56,17 +62,25 @@
<TrainingGroupsTab />
</div>
<!-- End Training Groups Tab -->
<!-- Training Times Tab -->
<div v-if="activeTab === 'training-times'">
<TrainingTimesTab />
</div>
<!-- End Training Times Tab -->
</div>
</template>
<script>
import apiClient from '../apiClient';
import TrainingGroupsTab from '../components/TrainingGroupsTab.vue';
import TrainingTimesTab from '../components/TrainingTimesTab.vue';
export default {
name: 'ClubSettings',
components: {
TrainingGroupsTab,
TrainingTimesTab,
},
data() {
return {

View File

@@ -4,12 +4,13 @@
<div class="diary-header-row">
<label>Datum:
<select v-model="date" @change="handleDateChange">
<option value="new">Neu anlegen</option>
<option :value="null" v-if="dates.length === 0">Keine Einträge</option>
<option v-for="entry in dates" :key="entry.id" :value="entry">{{ getFormattedDate(entry.date) }}
</option>
</select>
<button v-if="date && date !== 'new' && canDeleteCurrentDate" class="btn-secondary"
<button v-if="date && canDeleteCurrentDate" class="btn-secondary"
@click="deleteCurrentDate">Datum löschen</button>
<button @click="openNewDateDialog" class="btn-primary">Neu anlegen</button>
</label>
<button
class="btn-secondary gallery-trigger"
@@ -19,7 +20,43 @@
{{ galleryLoading ? 'Galerie wird erstellt…' : 'Mitglieder-Galerie' }}
</button>
</div>
<div v-if="showForm && date === 'new'">
<!-- Training Group Selection Dialog -->
<div v-if="showTrainingGroupDialog" class="dialog-overlay" @click.self="closeTrainingGroupDialog">
<div class="dialog-content">
<h3>Trainingsgruppe auswählen</h3>
<div class="dialog-body">
<label>
<span>Trainingsgruppe:</span>
<select v-model="selectedTrainingGroupId" class="input-field" @change="onTrainingGroupSelected">
<option value="">Bitte wählen...</option>
<option v-for="group in trainingGroups" :key="group.id" :value="group.id">
{{ group.name }}
</option>
</select>
</label>
<div v-if="suggestedDate" class="suggestion-info">
<p><strong>Vorschlag:</strong></p>
<p>Nächster Termin: <strong>{{ getFormattedDate(suggestedDate) }}</strong></p>
<p v-if="suggestedStartTime && suggestedEndTime">
Zeit: <strong>{{ suggestedStartTime }} - {{ suggestedEndTime }}</strong>
</p>
</div>
</div>
<div class="dialog-actions">
<button @click="applySuggestion" class="btn-primary" :disabled="!suggestedDate">
Vorschlag übernehmen
</button>
<button @click="closeTrainingGroupDialog" class="btn-secondary">
Abbrechen
</button>
<button @click="skipSuggestion" class="btn-secondary">
Ohne Vorschlag fortfahren
</button>
</div>
</div>
</div>
<div v-if="showForm">
<h3>Neues Datum anlegen</h3>
<form @submit.prevent="createDate">
<div>
@@ -39,7 +76,7 @@
</form>
</div>
<div v-if="!showForm && date !== null && date !== 'new'">
<div v-if="!showForm && date !== null">
<h3>Trainingszeiten bearbeiten <span @click="toggleShowGeneralData" class="clickable">{{ showGeneralData ?
'-' : '+' }}</span></h3>
<form @submit.prevent="updateTrainingTimes" v-if="showGeneralData">
@@ -54,7 +91,7 @@
<button type="submit">Zeiten aktualisieren</button>
</form>
</div>
<div v-if="date !== 'new' && date !== null" class="diary-content">
<div v-if="date !== null && !showForm" class="diary-content">
<!-- Tab-Navigation für Mobile -->
<div class="mobile-tabs">
<button
@@ -752,6 +789,13 @@ export default {
trainingEnd: '',
members: [],
participants: [],
showTrainingGroupDialog: false,
trainingGroups: [],
selectedTrainingGroupId: null,
trainingTimes: [],
suggestedDate: null,
suggestedStartTime: null,
suggestedEndTime: null,
newActivity: '',
activities: [],
notes: [],
@@ -891,7 +935,7 @@ export default {
},
canDeleteCurrentDate() {
if (!this.date || this.date === 'new') return false;
if (!this.date) return false;
// Prüfe ob keine Inhalte vorhanden sind
const hasTrainingPlan = this.trainingPlan && this.trainingPlan.length > 0;
@@ -943,7 +987,7 @@ export default {
this.showGalleryDialog = true;
},
async handleGalleryMemberClick(member) {
if (!this.date || this.date === 'new') {
if (!this.date) {
return;
}
console.log('[handleGalleryMemberClick] Clicked member:', member);
@@ -1044,7 +1088,7 @@ export default {
async autoSelectDateWithEntries() {
if (this.dates.length === 0) {
this.date = 'new';
this.date = null;
return;
}
@@ -1062,9 +1106,13 @@ export default {
}
// 2. Falls heutiges Datum nicht gefunden oder keine Einträge vorhanden
// → Bleibe bei "Neu anlegen"
this.date = 'new';
this.showForm = true;
// → Wähle das erste Datum aus der Liste
if (this.dates.length > 0) {
this.date = this.dates[0];
await this.handleDateChange();
} else {
this.date = null;
}
},
async countEntriesForDate(dateId) {
@@ -1104,9 +1152,16 @@ export default {
this.newDate = today;
},
async openNewDateDialog() {
// Zeige Dialog zur Trainingsgruppen-Auswahl
await this.loadTrainingGroups();
this.showTrainingGroupDialog = true;
this.showForm = false;
},
async handleDateChange() {
this.showForm = this.date === 'new';
if (this.date && this.date !== 'new') {
this.showForm = false;
if (this.date) {
const dateId = this.date.id;
const response = await apiClient.get(`/diary/${this.currentClub}`);
const dateData = response.data.find(entry => entry.id === dateId);
@@ -1184,6 +1239,126 @@ export default {
// Erstelle ein neues Array, um Vue-Reaktivität sicherzustellen
this.members = Array.isArray(response.data) ? [...response.data] : [];
},
async loadTrainingGroups() {
try {
const response = await apiClient.get(`/training-groups/${this.currentClub}`);
this.trainingGroups = Array.isArray(response.data) ? response.data : [];
} catch (error) {
console.error('[loadTrainingGroups] Error:', error);
this.trainingGroups = [];
}
},
async onTrainingGroupSelected() {
if (!this.selectedTrainingGroupId) {
this.suggestedDate = null;
this.suggestedStartTime = null;
this.suggestedEndTime = null;
return;
}
try {
// Lade Trainingszeiten für diese Gruppe
const response = await apiClient.get(`/training-times/${this.currentClub}`);
const groups = Array.isArray(response.data) ? response.data : [];
const selectedGroup = groups.find(g => g.id === parseInt(this.selectedTrainingGroupId));
if (!selectedGroup || !selectedGroup.trainingTimes || selectedGroup.trainingTimes.length === 0) {
this.suggestedDate = null;
this.suggestedStartTime = null;
this.suggestedEndTime = null;
return;
}
// Finde den nächsten verfügbaren Wochentag
const nextDate = this.findNextAvailableDate(selectedGroup.trainingTimes);
if (nextDate) {
this.suggestedDate = nextDate.date;
this.suggestedStartTime = nextDate.startTime;
this.suggestedEndTime = nextDate.endTime;
} else {
this.suggestedDate = null;
this.suggestedStartTime = null;
this.suggestedEndTime = null;
}
} catch (error) {
console.error('[onTrainingGroupSelected] Error:', error);
this.suggestedDate = null;
this.suggestedStartTime = null;
this.suggestedEndTime = null;
}
},
findNextAvailableDate(trainingTimes) {
if (!trainingTimes || trainingTimes.length === 0) {
return null;
}
// Sortiere Trainingszeiten nach Wochentag und Startzeit
const sortedTimes = [...trainingTimes].sort((a, b) => {
if (a.weekday !== b.weekday) return a.weekday - b.weekday;
return a.startTime.localeCompare(b.startTime);
});
const today = new Date();
// Prüfe die nächsten 14 Tage (2 Wochen)
for (let dayOffset = 0; dayOffset < 14; dayOffset++) {
const checkDate = new Date(today);
checkDate.setDate(today.getDate() + dayOffset);
const checkWeekday = checkDate.getDay();
// Finde Trainingszeiten für diesen Wochentag
const timesForWeekday = sortedTimes.filter(tt => tt.weekday === checkWeekday);
if (timesForWeekday.length > 0) {
// Nimm die erste Trainingszeit für diesen Tag
const time = timesForWeekday[0];
// Prüfe, ob dieses Datum bereits existiert
const dateStr = checkDate.toISOString().split('T')[0];
const exists = this.dates.some(d => d.date === dateStr);
if (!exists) {
return {
date: dateStr,
startTime: this.formatTimeForInput(time.startTime),
endTime: this.formatTimeForInput(time.endTime)
};
}
}
}
return null;
},
formatTimeForInput(time) {
if (!time) return '';
return time.substring(0, 5); // HH:MM für time input
},
applySuggestion() {
if (this.suggestedDate) {
this.newDate = this.suggestedDate;
this.trainingStart = this.suggestedStartTime || '';
this.trainingEnd = this.suggestedEndTime || '';
}
this.closeTrainingGroupDialog();
this.showForm = true;
},
skipSuggestion() {
this.closeTrainingGroupDialog();
this.showForm = true;
},
closeTrainingGroupDialog() {
this.showTrainingGroupDialog = false;
this.selectedTrainingGroupId = null;
this.suggestedDate = null;
this.suggestedStartTime = null;
this.suggestedEndTime = null;
},
async loadParticipants(dateId) {
const response = await apiClient.get(`/participants/${dateId}`);
@@ -2699,7 +2874,7 @@ export default {
async handleParticipantAdded(data) {
// Nur aktualisieren, wenn das aktuelle Datum betroffen ist
if (this.date && this.date !== 'new' && this.date.id === data.dateId) {
if (this.date && this.date.id === data.dateId) {
// Lade Teilnehmer neu
await this.loadParticipants(data.dateId);
}
@@ -2707,7 +2882,7 @@ export default {
async handleParticipantRemoved(data) {
// Nur aktualisieren, wenn das aktuelle Datum betroffen ist
if (this.date && this.date !== 'new' && this.date.id === data.dateId) {
if (this.date && this.date.id === data.dateId) {
// Entferne aus participants-Array
this.participants = this.participants.filter(memberId => memberId !== data.participantId);
// Entferne aus Maps
@@ -2745,7 +2920,7 @@ export default {
async handleDiaryNoteAdded(data) {
// Nur aktualisieren, wenn das aktuelle Datum betroffen ist
if (this.date && this.date !== 'new' && this.date.id === data.dateId) {
if (this.date && this.date.id === data.dateId) {
// Lade Notizen neu, falls das betroffene Mitglied ausgewählt ist
if (this.selectedMember && data.note.memberId === this.selectedMember.id) {
try {
@@ -2760,7 +2935,7 @@ export default {
async handleDiaryNoteDeleted(data) {
// Nur aktualisieren, wenn das aktuelle Datum betroffen ist
if (this.date && this.date !== 'new' && this.date.id === data.dateId) {
if (this.date && this.date.id === data.dateId) {
// Entferne Notiz aus notes-Array
this.notes = this.notes.filter(note => note.id !== data.noteId);
}
@@ -2768,7 +2943,7 @@ export default {
async handleDiaryTagAdded(data) {
// Nur aktualisieren, wenn das aktuelle Datum betroffen ist
if (this.date && this.date !== 'new' && this.date.id === data.dateId) {
if (this.date && this.date.id === data.dateId) {
// Lade Tags neu
await this.loadTags();
// Aktualisiere selectedActivityTags
@@ -2783,7 +2958,7 @@ export default {
async handleDiaryTagRemoved(data) {
// Nur aktualisieren, wenn das aktuelle Datum betroffen ist
if (this.date && this.date !== 'new' && this.date.id === data.dateId) {
if (this.date && this.date.id === data.dateId) {
// Entferne Tag aus selectedActivityTags
this.selectedActivityTags = this.selectedActivityTags.filter(t => t.id !== data.tagId);
}
@@ -2791,7 +2966,7 @@ export default {
async handleDiaryDateUpdated(data) {
// Nur aktualisieren, wenn das aktuelle Datum betroffen ist
if (this.date && this.date !== 'new' && this.date.id === data.dateId) {
if (this.date && this.date.id === data.dateId) {
// Aktualisiere Trainingszeiten
if (data.updates.trainingStart !== undefined) {
this.trainingStart = data.updates.trainingStart;
@@ -2804,7 +2979,7 @@ export default {
async handleActivityMemberAdded(data) {
// Nur aktualisieren, wenn das aktuelle Datum betroffen ist
if (this.date && this.date !== 'new' && this.date.id === data.dateId) {
if (this.date && this.date.id === data.dateId) {
// Lade Training Plan neu
try {
this.trainingPlan = await apiClient.get(`/diary-date-activities/${this.currentClub}/${this.date.id}`).then(response => response.data);
@@ -2817,7 +2992,7 @@ export default {
async handleActivityMemberRemoved(data) {
// Nur aktualisieren, wenn das aktuelle Datum betroffen ist
if (this.date && this.date !== 'new' && this.date.id === data.dateId) {
if (this.date && this.date.id === data.dateId) {
// Lade Training Plan neu
try {
this.trainingPlan = await apiClient.get(`/diary-date-activities/${this.currentClub}/${this.date.id}`).then(response => response.data);
@@ -2830,7 +3005,7 @@ export default {
async handleActivityChanged(data) {
// Nur aktualisieren, wenn das aktuelle Datum betroffen ist
if (this.date && this.date !== 'new' && this.date.id === data.dateId) {
if (this.date && this.date.id === data.dateId) {
// Lade Training Plan neu
try {
this.trainingPlan = await apiClient.get(`/diary-date-activities/${this.currentClub}/${this.date.id}`).then(response => response.data);
@@ -3803,6 +3978,101 @@ img {
}
}
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog-content {
background: #fff;
border-radius: 8px;
padding: 24px;
max-width: 500px;
width: 90%;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.dialog-content h3 {
margin: 0 0 20px 0;
font-size: 1.2rem;
}
.dialog-body {
margin-bottom: 20px;
}
.dialog-body label {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.dialog-body .input-field {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.suggestion-info {
background: #e3f2fd;
border: 1px solid #90caf9;
border-radius: 4px;
padding: 12px;
margin-top: 16px;
}
.suggestion-info p {
margin: 4px 0;
}
.dialog-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
flex-wrap: wrap;
}
.dialog-actions button {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
}
.dialog-actions .btn-primary {
background: #28a745;
color: #fff;
}
.dialog-actions .btn-primary:hover:not(:disabled) {
background: #218838;
}
.dialog-actions .btn-primary:disabled {
background: #ccc;
cursor: not-allowed;
}
.dialog-actions .btn-secondary {
background: #6c757d;
color: #fff;
}
.dialog-actions .btn-secondary:hover {
background: #5a6268;
}
@media (min-width: 769px) {
.mobile-tabs {
display: none !important;