Update server port and enhance participant management features

Changed the server port from 3000 to 3005 for local development. Enhanced the participant management functionality by adding a new endpoint to update participant group assignments, including error handling for non-existent participants. Updated the participant model to include a groupId reference, and modified the participant retrieval logic to include group information. Additionally, improved the frontend API client to accommodate the new backend structure and added filtering options in the MembersView for better user experience.
This commit is contained in:
Torsten Schulz (local)
2025-10-29 11:48:24 +01:00
parent bb2164f666
commit 7a35a0a1d3
11 changed files with 288 additions and 23 deletions

View File

@@ -4,7 +4,10 @@ import { devLog } from '../utils/logger.js';
export const getParticipants = async (req, res) => {
try {
const { dateId } = req.params;
const participants = await Participant.findAll({ where: { diaryDateId: dateId } });
const participants = await Participant.findAll({
where: { diaryDateId: dateId },
attributes: ['id', 'diaryDateId', 'memberId', 'groupId', 'notes', 'createdAt', 'updatedAt']
});
res.status(200).json(participants);
} catch (error) {
devLog(error);
@@ -12,6 +15,32 @@ export const getParticipants = async (req, res) => {
}
};
export const updateParticipantGroup = async (req, res) => {
try {
const { dateId, memberId } = req.params;
const { groupId } = req.body;
const participant = await Participant.findOne({
where: {
diaryDateId: dateId,
memberId: memberId
}
});
if (!participant) {
return res.status(404).json({ error: 'Teilnehmer nicht gefunden' });
}
participant.groupId = groupId || null;
await participant.save();
res.status(200).json(participant);
} catch (error) {
devLog(error);
res.status(500).json({ error: 'Fehler beim Aktualisieren der Teilnehmer-Gruppenzuordnung' });
}
};
export const addParticipant = async (req, res) => {
try {
const { diaryDateId, memberId } = req.body;

View File

@@ -0,0 +1,9 @@
-- Migration: Add group_id to participants table
-- This allows assigning participants to groups for training organization
ALTER TABLE participants
ADD COLUMN group_id INTEGER NULL REFERENCES "group"(id) ON DELETE SET NULL ON UPDATE CASCADE;
-- Add index for better query performance
CREATE INDEX IF NOT EXISTS idx_participants_group_id ON participants(group_id);

View File

@@ -2,6 +2,7 @@ import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
import Member from './Member.js';
import DiaryDate from './DiaryDates.js';
import Group from './Group.js';
import { encryptData, decryptData } from '../utils/encrypt.js';
const Participant = sequelize.define('Participant', {
@@ -27,6 +28,16 @@ const Participant = sequelize.define('Participant', {
key: 'id'
}
},
groupId: {
type: DataTypes.INTEGER,
allowNull: true,
references: {
model: Group,
key: 'id'
},
onDelete: 'SET NULL',
onUpdate: 'CASCADE'
},
notes: {
type: DataTypes.STRING(4096),
allowNull: true,

View File

@@ -1,5 +1,5 @@
import express from 'express';
import { getParticipants, addParticipant, removeParticipant } from '../controllers/participantController.js';
import { getParticipants, addParticipant, removeParticipant, updateParticipantGroup } from '../controllers/participantController.js';
import { authenticate } from '../middleware/authMiddleware.js';
const router = express.Router();
@@ -7,5 +7,6 @@ const router = express.Router();
router.get('/:dateId', authenticate, getParticipants);
router.post('/add', authenticate, addParticipant);
router.post('/remove', authenticate, removeParticipant);
router.put('/:dateId/:memberId/group', authenticate, updateParticipantGroup);
export default router;

View File

@@ -43,7 +43,7 @@ import permissionRoutes from './routes/permissionRoutes.js';
import schedulerService from './services/schedulerService.js';
const app = express();
const port = process.env.PORT || 3000;
const port = process.env.PORT || 3005;
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

View File

@@ -2,7 +2,7 @@ import axios from 'axios';
import store from './store';
const apiClient = axios.create({
baseURL: `${import.meta.env.VITE_BACKEND}/api`,
baseURL: `${import.meta.env.VITE_BACKEND || 'http://localhost:3005'}/api`,
});
apiClient.interceptors.request.use(config => {

View File

@@ -848,6 +848,17 @@ export default {
this.participants = response.data.map(participant => participant.memberId);
// Map für memberId -> participantId speichern
this.participantMapByMemberId = response.data.reduce((map, p) => { map[p.memberId] = p.id; return map; }, {});
// Map für memberId -> groupId speichern und mit Reaktivität initialisieren
this.memberGroupsMap = {};
response.data.forEach(p => {
if (p.groupId) {
if (this.$set) {
this.$set(this.memberGroupsMap, p.memberId, p.groupId);
} else {
this.memberGroupsMap[p.memberId] = p.groupId;
}
}
});
},
async loadActivities(dateId) {
@@ -1391,12 +1402,19 @@ export default {
},
async createGroups() {
try {
// Erstelle die gewünschte Anzahl Gruppen mit Namen 1 bis X
for (let i = 1; i <= this.newGroupCount; i++) {
// Bestimme Startnummer basierend auf vorhandenen Gruppen
const existingNumbers = (this.groups || [])
.map(g => parseInt((g.name || '').trim(), 10))
.filter(n => Number.isFinite(n));
const startNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1;
// Erstelle die gewünschte Anzahl Gruppen mit fortlaufender Nummerierung
for (let i = 0; i < this.newGroupCount; i++) {
const groupNumber = startNumber + i;
const form = {
clubid: this.currentClub,
dateid: this.date.id,
name: i.toString(),
name: groupNumber.toString(),
lead: '', // Leiter wird leer gelassen
}
await apiClient.post('/group', form);
@@ -1933,14 +1951,25 @@ export default {
async updateMemberGroup(memberId, groupId) {
try {
// Hier würde normalerweise ein API-Call gemacht werden
// Für jetzt speichern wir es nur lokal
this.memberGroupsMap[memberId] = groupId || '';
const selectedGroupId = groupId || '';
// TODO: API-Call zum Speichern der Teilnehmer-Gruppenzuordnung
// await apiClient.put(`/participants/${this.date.id}/${memberId}/group`, { groupId });
// Verwende Vue.set für Reaktivität (Vue 2)
if (this.$set) {
this.$set(this.memberGroupsMap, memberId, selectedGroupId);
} else {
// Vue 3 oder Fallback
this.memberGroupsMap = {
...this.memberGroupsMap,
[memberId]: selectedGroupId
};
}
console.log(`Teilnehmer ${memberId} wurde Gruppe ${groupId} zugewiesen`);
// API-Call zum Speichern der Teilnehmer-Gruppenzuordnung
await apiClient.put(`/participants/${this.date.id}/${memberId}/group`, {
groupId: selectedGroupId || null
});
console.log(`Teilnehmer ${memberId} wurde Gruppe ${selectedGroupId} zugewiesen`);
} catch (error) {
console.error('Fehler beim Aktualisieren der Teilnehmer-Gruppenzuordnung:', error);
this.showInfo('Fehler', 'Fehler beim Aktualisieren der Teilnehmer-Gruppenzuordnung', '', 'error');

View File

@@ -98,7 +98,7 @@ export default {
...mapActions(['login']),
async executeLogin() {
try {
const response = await axios.post(`${import.meta.env.VITE_BACKEND}/api/auth/login`, { email: this.email, password: this.password }, {
const response = await axios.post(`${import.meta.env.VITE_BACKEND || 'http://localhost:3005'}/api/auth/login`, { email: this.email, password: this.password }, {
timeout: 5000,
});
await this.login({ token: response.data.token, username: this.email });

View File

@@ -68,11 +68,41 @@
</div>
</div>
</div>
<div class="checkbox-group">
<label class="checkbox-item">
<input type="checkbox" v-model="showInactiveMembers">
<span>Inaktive Mitglieder anzeigen</span>
</label>
<div class="filters-section">
<div class="checkbox-group">
<label class="checkbox-item">
<input type="checkbox" v-model="showInactiveMembers">
<span>Inaktive Mitglieder anzeigen</span>
</label>
</div>
<div class="filter-controls">
<div class="filter-group">
<label>Altersklasse:</label>
<select v-model="selectedAgeGroup" class="filter-select">
<option value="">Alle</option>
<option value="adult">Erwachsene (20+)</option>
<option value="J19">J19 (19 und jünger)</option>
<option value="J17">J17 (17 und jünger)</option>
<option value="J15">J15 (15 und jünger)</option>
<option value="J13">J13 (13 und jünger)</option>
<option value="J11">J11 (11 und jünger)</option>
</select>
</div>
<div class="filter-group">
<label>Geschlecht:</label>
<select v-model="selectedGender" class="filter-select">
<option value="">Alle</option>
<option value="female">Weiblich</option>
<option value="male">Männlich</option>
<option value="diverse">Divers</option>
<option value="unknown">Unbekannt</option>
</select>
</div>
<button @click="clearFilters" class="btn-clear-filters">Filter zurücksetzen</button>
</div>
</div>
<div>
<table>
@@ -90,7 +120,7 @@
</tr>
</thead>
<tbody>
<template v-for="member in members" :key="member.id">
<template v-for="member in filteredMembers" :key="member.id">
<tr v-if="member.active || showInactiveMembers" class="member-row" :class="{ 'row-inactive': !member.active, 'row-test': member.testMembership }" @click="editMember(member)">
<td>
<img v-if="member.imageUrl" :src="member.imageUrl" alt="Mitgliedsbild"
@@ -237,6 +267,32 @@ export default {
inactiveMembersCount() {
return this.members.filter(member => !member.active).length;
},
filteredMembers() {
return this.members.filter(member => {
// Inaktive Mitglieder Filter
if (!member.active && !this.showInactiveMembers) {
return false;
}
// Altersklasse Filter
if (this.selectedAgeGroup) {
const age = this.getAge(member.birthDate);
if (!this.matchesAgeGroup(age, this.selectedAgeGroup)) {
return false;
}
}
// Geschlecht Filter
if (this.selectedGender) {
if (member.gender !== this.selectedGender) {
return false;
}
}
return true;
});
}
},
data() {
@@ -283,7 +339,9 @@ export default {
isUpdatingRatings: false,
showMemberInfo: false,
showActivitiesModal: false,
selectedMemberForActivities: null
selectedMemberForActivities: null,
selectedAgeGroup: '',
selectedGender: ''
}
},
async mounted() {
@@ -618,6 +676,60 @@ export default {
} finally {
this.isUpdatingRatings = false;
}
},
getAge(birthDate) {
if (!birthDate) return null;
const birth = new Date(birthDate);
if (isNaN(birth.getTime())) return null;
const today = new Date();
const age = today.getFullYear() - birth.getFullYear();
const monthDiff = today.getMonth() - birth.getMonth();
// Korrektur für Geburtstag in diesem Jahr
return monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())
? age - 1
: age;
},
matchesAgeGroup(age, ageGroup) {
if (age === null) return false;
// Tischtennis-Logik: Altersklassen basieren auf Jahrgängen mit Stichtag 1. Januar
// J19 = 19 Jahre und jünger am 1. Januar des aktuellen Jahres
// J17 = 17 Jahre und jünger am 1. Januar des aktuellen Jahres
// etc.
switch (ageGroup) {
case 'adult':
return age > 19; // Erwachsene = älter als 19
case 'J19':
return age <= 19; // J19 = 19 und jünger
case 'J17':
return age <= 17; // J17 = 17 und jünger
case 'J15':
return age <= 15; // J15 = 15 und jünger
case 'J13':
return age <= 13; // J13 = 13 und jünger
case 'J11':
return age <= 11; // J11 = 11 und jünger
default:
return true;
}
},
clearFilters() {
this.selectedAgeGroup = '';
this.selectedGender = '';
this.showInactiveMembers = false;
}
}
}
@@ -948,4 +1060,78 @@ table td {
.btn-activities:hover {
background-color: #218838;
}
/* Filter Styles */
.filters-section {
margin-bottom: 1.5rem;
padding: 1rem;
background-color: #f8f9fa;
border-radius: 6px;
border: 1px solid #e9ecef;
}
.filter-controls {
display: flex;
align-items: center;
gap: 1.5rem;
margin-top: 1rem;
flex-wrap: wrap;
}
.filter-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.filter-group label {
font-weight: 500;
color: #333;
white-space: nowrap;
}
.filter-group .checkbox-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
color: #333;
white-space: nowrap;
cursor: pointer;
}
.filter-group .checkbox-item input[type="checkbox"] {
margin: 0;
}
.filter-select {
padding: 0.5rem 0.75rem;
border: 1px solid #ced4da;
border-radius: 4px;
background-color: white;
font-size: 0.9em;
min-width: 120px;
cursor: pointer;
}
.filter-select:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.btn-clear-filters {
background-color: #6c757d;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
transition: background-color 0.2s ease;
}
.btn-clear-filters:hover {
background-color: #5a6268;
}
</style>

View File

@@ -358,7 +358,7 @@ export default {
this.selectedFile = e.target.files && e.target.files[0] ? e.target.files[0] : null;
},
imageUrl(img) {
return `http://localhost:3000/api/predefined-activities/${this.editModel.id}/image/${img.id}`;
return `/api/predefined-activities/${this.editModel.id}/image/${img.id}`;
},
async uploadImage() {
if (!this.editModel || !this.editModel.id || !this.selectedFile) {

View File

@@ -97,7 +97,7 @@ export default {
async register() {
try {
await axios.post(`${import.meta.env.VITE_BACKEND}/api/auth/register`, { email: this.email, password: this.password });
await axios.post(`${import.meta.env.VITE_BACKEND || 'http://localhost:3005'}/api/auth/register`, { email: this.email, password: this.password });
alert('Registration successful! Please check your email to activate your account.');
} catch (error) {
alert('Registrierung fehlgeschlagen');