From a1f509e23ea7ae520ad6396508c1344928156d25 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Tue, 22 Sep 2026 15:00:21 +0200 Subject: [PATCH] feat: add player cancellation handling and member deactivation tracking --- backend/controllers/matchController.js | 5 +- backend/models/DiaryDates.js | 8 ++ backend/models/Match.js | 6 ++ backend/models/Member.js | 5 ++ backend/scripts/runMigrations.js | 23 ++++++ backend/services/diaryService.js | 5 ++ backend/services/matchService.js | 20 +++-- backend/services/memberService.js | 8 ++ backend/services/trainingStatsService.js | 16 +++- frontend/src/views/ScheduleView.vue | 100 ++++++++++++++++------- frontend/src/views/TrainingStatsView.vue | 9 +- 11 files changed, 162 insertions(+), 43 deletions(-) diff --git a/backend/controllers/matchController.js b/backend/controllers/matchController.js index 64901262..d71155f7 100755 --- a/backend/controllers/matchController.js +++ b/backend/controllers/matchController.js @@ -123,14 +123,15 @@ export const updateMatchPlayers = async (req, res) => { try { const { authcode: userToken } = req.headers; const { matchId } = req.params; - const { playersReady, playersPlanned, playersPlayed } = req.body; + const { playersReady, playersPlanned, playersPlayed, playersCancelled } = req.body; const result = await MatchService.updateMatchPlayers( userToken, matchId, playersReady, playersPlanned, - playersPlayed + playersPlayed, + playersCancelled ); if (result.clubId) { diff --git a/backend/models/DiaryDates.js b/backend/models/DiaryDates.js index ef845f7f..81023b1a 100755 --- a/backend/models/DiaryDates.js +++ b/backend/models/DiaryDates.js @@ -29,6 +29,14 @@ const DiaryDate = sequelize.define('DiaryDate', { allowNull: false, defaultValue: false, field: 'exclude_from_billing', + }, + nonDeactivatedMemberCount: { + // Anzahl der zu diesem Zeitpunkt nicht deaktivierten Vereinsmitglieder. + // Dieser Wert ist ein Snapshot und darf nicht aus dem heutigen Mitgliederstand + // abgeleitet werden. + type: DataTypes.INTEGER, + allowNull: true, + field: 'non_deactivated_member_count', } }, { tableName: 'diary_dates', diff --git a/backend/models/Match.js b/backend/models/Match.js index c15316b2..aab6ceeb 100755 --- a/backend/models/Match.js +++ b/backend/models/Match.js @@ -127,6 +127,12 @@ const Match = sequelize.define('Match', { comment: 'Array of member IDs who actually played', field: 'players_played' }, + playersCancelled: { + type: DataTypes.JSON, + allowNull: true, + comment: 'Array of member IDs who declined to play', + field: 'players_cancelled' + }, fixtureType: { type: DataTypes.STRING, allowNull: false, diff --git a/backend/models/Member.js b/backend/models/Member.js index 9d47bde4..2d85e9e7 100755 --- a/backend/models/Member.js +++ b/backend/models/Member.js @@ -189,6 +189,11 @@ const Member = sequelize.define('Member', { allowNull: false, default: true, }, + deactivatedAt: { + type: DataTypes.DATE, + allowNull: true, + field: 'deactivated_at', + }, testMembership: { type: DataTypes.BOOLEAN, allowNull: false, diff --git a/backend/scripts/runMigrations.js b/backend/scripts/runMigrations.js index de0c389a..1e55c199 100644 --- a/backend/scripts/runMigrations.js +++ b/backend/scripts/runMigrations.js @@ -1,6 +1,29 @@ import sequelize from '../database.js'; const migrations = [ + { + id: '20260922_add_match_player_cancellations', + async up() { + const [columns] = await sequelize.query("SHOW COLUMNS FROM `match` LIKE 'players_cancelled'"); + if (columns.length === 0) { + await sequelize.query('ALTER TABLE `match` ADD COLUMN `players_cancelled` JSON NULL'); + } + }, + }, + { + id: '20260922_add_member_deactivation_and_diary_member_snapshot', + async up() { + const [memberColumns] = await sequelize.query("SHOW COLUMNS FROM `member` LIKE 'deactivated_at'"); + if (memberColumns.length === 0) { + await sequelize.query('ALTER TABLE `member` ADD COLUMN `deactivated_at` DATETIME NULL'); + } + + const [diaryDateColumns] = await sequelize.query("SHOW COLUMNS FROM `diary_dates` LIKE 'non_deactivated_member_count'"); + if (diaryDateColumns.length === 0) { + await sequelize.query('ALTER TABLE `diary_dates` ADD COLUMN `non_deactivated_member_count` INT NULL'); + } + }, + }, { id: '20260922_add_official_league_table_fields', async up() { diff --git a/backend/services/diaryService.js b/backend/services/diaryService.js index 2d0e9f3f..3296653e 100755 --- a/backend/services/diaryService.js +++ b/backend/services/diaryService.js @@ -1,4 +1,5 @@ import DiaryDate from '../models/DiaryDates.js'; +import Member from '../models/Member.js'; import DiaryDateActivity from '../models/DiaryDateActivity.js'; import Club from '../models/Club.js'; import DiaryNote from '../models/DiaryNote.js'; @@ -39,12 +40,16 @@ class DiaryService { if (trainingStart && trainingEnd && trainingStart >= trainingEnd) { throw new HttpError('Training start time must be before training end time', 400); } + // Die heutige Mitgliederliste ist für alte Trainingstage nicht aussagekräftig. + // Deshalb wird die Zahl beim Anlegen des Trainingstags dauerhaft gespeichert. + const nonDeactivatedMemberCount = await Member.count({ where: { clubId, active: true } }); const newDate = await DiaryDate.create({ date: parsedDate, clubId, trainingStart: trainingStart || null, trainingEnd: trainingEnd || null, excludeFromBilling: Boolean(excludeFromBilling), + nonDeactivatedMemberCount, }); return newDate; diff --git a/backend/services/matchService.js b/backend/services/matchService.js index 4ca60b10..e3328cfb 100755 --- a/backend/services/matchService.js +++ b/backend/services/matchService.js @@ -25,7 +25,7 @@ class MatchService { code: match.code, homePin: match.homePin, guestPin: match.guestPin, homeMatchPoints: match.homeMatchPoints || 0, guestMatchPoints: match.guestMatchPoints || 0, isCompleted: match.isCompleted || false, pdfUrl: match.pdfUrl, - playersReady: match.playersReady || [], playersPlanned: match.playersPlanned || [], playersPlayed: match.playersPlayed || [], + playersReady: match.playersReady || [], playersPlanned: match.playersPlanned || [], playersPlayed: match.playersPlayed || [], playersCancelled: match.playersCancelled || [], fixtureType: match.fixtureType || 'league', notes: match.notes || null, homeTeam: { name: 'Unbekannt' }, guestTeam: { name: 'Unbekannt' }, location: { name: 'Unbekannt', address: '', city: '', zip: '' }, leagueDetails: { name: 'Unbekannt' } @@ -492,6 +492,7 @@ class MatchService { playersReady: match.playersReady || [], playersPlanned: match.playersPlanned || [], playersPlayed: match.playersPlayed || [], + playersCancelled: match.playersCancelled || [], fixtureType: match.fixtureType || 'league', notes: match.notes || null, homeTeam: { name: 'Unbekannt' }, @@ -600,6 +601,7 @@ class MatchService { playersReady: match.playersReady || [], playersPlanned: match.playersPlanned || [], playersPlayed: match.playersPlayed || [], + playersCancelled: match.playersCancelled || [], fixtureType: match.fixtureType || 'league', notes: match.notes || null, homeTeam: { name: 'Unbekannt' }, @@ -726,7 +728,7 @@ class MatchService { } } - async updateMatchPlayers(userToken, matchId, playersReady, playersPlanned, playersPlayed) { + async updateMatchPlayers(userToken, matchId, playersReady, playersPlanned, playersPlayed, playersCancelled) { // Find the match and verify access const match = await Match.findByPk(matchId, { include: [ @@ -757,12 +759,18 @@ class MatchService { const readyList = normalizeList(playersReady); const plannedList = normalizeList(playersPlanned); const playedList = normalizeList(playersPlayed); + const cancelledList = normalizeList(playersCancelled); + + // Eine Absage schließt alle anderen Spielstatus aus. + const cancelledIds = new Set((cancelledList !== null ? cancelledList : (match.playersCancelled || [])).map(String)); + const excludeCancelled = (list) => (list || []).filter((id) => !cancelledIds.has(String(id))); // Wenn Listen übergeben wurden, gelten sie als Quelle der Wahrheit await match.update({ - playersReady: readyList !== null ? readyList : (match.playersReady || []), - playersPlanned: plannedList !== null ? plannedList : (match.playersPlanned || []), - playersPlayed: playedList !== null ? playedList : (match.playersPlayed || []) + playersReady: excludeCancelled(readyList !== null ? readyList : (match.playersReady || [])), + playersPlanned: excludeCancelled(plannedList !== null ? plannedList : (match.playersPlanned || [])), + playersPlayed: excludeCancelled(playedList !== null ? playedList : (match.playersPlayed || [])), + playersCancelled: cancelledList !== null ? cancelledList : (match.playersCancelled || []) }); // Aktualisiertes Match nochmals laden und für WebSocket-Broadcast anreichern (gleiche Struktur wie getMatchesForLeague) @@ -788,6 +796,7 @@ class MatchService { playersReady: updated.playersReady || [], playersPlanned: updated.playersPlanned || [], playersPlayed: updated.playersPlayed || [], + playersCancelled: updated.playersCancelled || [], fixtureType: updated.fixtureType || 'league', notes: updated.notes || null, homeTeam: { name: 'Unbekannt' }, @@ -818,6 +827,7 @@ class MatchService { playersReady: updated.playersReady, playersPlanned: updated.playersPlanned, playersPlayed: updated.playersPlayed, + playersCancelled: updated.playersCancelled, match: enriched }; } diff --git a/backend/services/memberService.js b/backend/services/memberService.js index 7728bf85..7620df18 100755 --- a/backend/services/memberService.js +++ b/backend/services/memberService.js @@ -304,6 +304,7 @@ class MemberService { const MemberContact = (await import('../models/MemberContact.js')).default; const normalizedContributionGroupCode = String(contributionGroupCode || '').trim() || null; if (member) { + const wasActive = member.active; member.firstName = firstName; member.lastName = lastName; member.street = street; @@ -313,6 +314,11 @@ class MemberService { member.phone = phone ? standardizePhoneNumber(phone) : phone; member.email = email; member.active = active; + if (wasActive && !active) { + member.deactivatedAt = new Date(); + } else if (!wasActive && active) { + member.deactivatedAt = null; + } member.testMembership = testMembership; member.picsInInternetAllowed = picsInInternetAllowed; if (gender) member.gender = gender; @@ -359,6 +365,7 @@ class MemberService { email: email, clubId: clubId, active: active, + deactivatedAt: active ? null : new Date(), testMembership: testMembership, picsInInternetAllowed: picsInInternetAllowed, gender: gender || 'unknown', @@ -1572,6 +1579,7 @@ class MemberService { } member.active = false; + member.deactivatedAt = new Date(); await member.save(); return { diff --git a/backend/services/trainingStatsService.js b/backend/services/trainingStatsService.js index e9f7b962..7ed58351 100755 --- a/backend/services/trainingStatsService.js +++ b/backend/services/trainingStatsService.js @@ -110,7 +110,7 @@ class TrainingStatsService { clubId, date: { [Op.gte]: twelveMonthsAgo } }, - attributes: ['date'], + attributes: ['date', 'nonDeactivatedMemberCount'], order: [['date', 'DESC']] }); const trainingDateValues12Months = trainingDates12Months.map(entry => entry.date); @@ -218,6 +218,11 @@ class TrainingStatsService { const formattedTrainingDays = trainingDays.map((day) => ({ id: day.id, date: day.date, + // Alte Trainingstage haben noch keinen Snapshot. Für sie bleibt die bisherige + // Näherung erhalten; alle neu angelegten Trainingstage sind exakt. + nonDeactivatedMemberCount: Number.isInteger(day.nonDeactivatedMemberCount) + ? day.nonDeactivatedMemberCount + : members.length, participantCount: day.participantList ? day.participantList.filter((participant) => !participant.attendanceStatus || participant.attendanceStatus === 'present').length : 0, @@ -240,8 +245,12 @@ class TrainingStatsService { const totalParticipants12Months = formattedTrainingDays.reduce((sum, day) => sum + (day.participantCount || 0), 0); const averageParticipants12Months = trainingsCount12Months > 0 ? totalParticipants12Months / trainingsCount12Months : 0; - const attendanceRate12Months = (members.length > 0 && trainingsCount12Months > 0) - ? (totalParticipants12Months / (members.length * trainingsCount12Months)) * 100 + const availableMemberSlots12Months = formattedTrainingDays.reduce( + (sum, day) => sum + Math.max(0, day.nonDeactivatedMemberCount || 0), + 0, + ); + const attendanceRate12Months = availableMemberSlots12Months > 0 + ? (totalParticipants12Months / availableMemberSlots12Months) * 100 : 0; const inactiveMembersCount = stats.filter((member) => member.notInTraining).length; const bestTrainingDay = formattedTrainingDays.reduce((best, day) => { @@ -306,6 +315,7 @@ class TrainingStatsService { totalParticipants12Months, averageParticipants12Months, attendanceRate12Months, + availableMemberSlots12Months, inactiveMembersCount, bestTrainingDay, }, diff --git a/frontend/src/views/ScheduleView.vue b/frontend/src/views/ScheduleView.vue index 3de78619..840a2206 100755 --- a/frontend/src/views/ScheduleView.vue +++ b/frontend/src/views/ScheduleView.vue @@ -382,7 +382,8 @@ {{ $t('schedule.player') }} - {{ $t('schedule.ready') }} + Rückmeldung + {{ $t('schedule.ready') }} {{ $t('schedule.planned') }} {{ $t('schedule.played') }} Beobachtung @@ -393,11 +394,22 @@ {{ member.firstName }} {{ member.lastName }} - + + + + @@ -405,7 +417,7 @@ @@ -413,7 +425,7 @@ @@ -1355,11 +1367,11 @@ export default { try { const normalizePlayersList = (value) => { - if (Array.isArray(value)) return value; + if (Array.isArray(value)) return value.map(Number).filter(Number.isFinite); if (typeof value === 'string') { try { const parsed = JSON.parse(value); - return Array.isArray(parsed) ? parsed : []; + return Array.isArray(parsed) ? parsed.map(Number).filter(Number.isFinite) : []; } catch (e) { return []; } @@ -1369,7 +1381,7 @@ export default { const readyIds = normalizePlayersList(match.playersReady); const plannedIds = normalizePlayersList(match.playersPlanned); const playedIds = normalizePlayersList(match.playersPlayed); - const preselectedIds = Array.from(new Set([...readyIds, ...plannedIds, ...playedIds])); + const cancelledIds = normalizePlayersList(match.playersCancelled); let allMembers = []; if (match.isFriendly && match.isSharedFriendly) { @@ -1391,12 +1403,11 @@ export default { .map((id) => Number(id)) .filter((id) => Number.isFinite(id)) ); - const visibleMembers = allowedIds.size > 0 - ? activeMembers.filter((member) => allowedIds.has(Number(member.id))) - : activeMembers; + const visibleMembers = match.isFriendly + ? activeMembers + : activeMembers.filter((member) => allowedIds.has(Number(member.id))); - // Keep the order defined in the team line-up. Members from lower teams - // (which are also eligible) follow afterwards in their own line-up order. + // Die Reihenfolge entspricht der Mannschaftsmeldung der ausgewählten Mannschaft. const lineupPositionByMemberId = new Map( eligibleMemberIds.map((id, index) => [Number(id), index]) ); @@ -1419,12 +1430,16 @@ export default { return la.localeCompare(lb); }); - this.playerSelectionDialog.members = sortedVisibleMembers.map(m => ({ - ...m, - isReady: readyIds.includes(m.id) || false, - isPlanned: plannedIds.includes(m.id) || false, - hasPlayed: playedIds.includes(m.id) || false - })); + this.playerSelectionDialog.members = sortedVisibleMembers.map(m => { + const isCancelled = cancelledIds.includes(m.id); + return { + ...m, + isReady: !isCancelled && readyIds.includes(m.id), + isPlanned: !isCancelled && plannedIds.includes(m.id), + hasPlayed: !isCancelled && playedIds.includes(m.id), + isCancelled + }; + }); } catch (error) { console.error('Error loading members:', error); @@ -1452,8 +1467,9 @@ export default { return []; } - // Niedrigere Mannschaften = ausgewaehltes Team + alle dahinter in der sortierten Teamliste - const candidateTeams = this.teams.slice(selectedIndex); + // Die Auswahl enthält ausschließlich die für diese Mannschaft gemeldeten + // Spieler – keine Spieler aus anderen Jugendmannschaften. + const candidateTeams = [this.teams[selectedIndex]]; const lineupResponses = await Promise.all(candidateTeams.map(async (team) => { try { const response = await apiClient.get(`/club-teams/${team.id}/lineup`, { @@ -1535,6 +1551,19 @@ export default { togglePlayerReady(member) { member.isReady = !member.isReady; }, + playerResponseValue(member) { + if (member.isCancelled) return 'cancelled'; + return member.isReady ? 'ready' : ''; + }, + setPlayerResponse(member, value) { + member.isReady = value === 'ready'; + member.isCancelled = value === 'cancelled'; + if (member.isCancelled) { + member.isReady = false; + member.isPlanned = false; + member.hasPlayed = false; + } + }, togglePlayerPlanned(member) { member.isPlanned = !member.isPlanned; @@ -1565,24 +1594,29 @@ export default { ...normalizePlayersList(existing).filter((id) => !visibleIds.has(Number(id))), ...this.playerSelectionDialog.members.filter(predicate).map((m) => Number(m.id)), ].filter((id, index, arr) => Number.isFinite(id) && arr.indexOf(id) === index); - const playersReady = mergeVisibleSelection(match.playersReady, (m) => m.isReady); - const playersPlanned = mergeVisibleSelection(match.playersPlanned, (m) => m.isPlanned); - const playersPlayed = mergeVisibleSelection(match.playersPlayed, (m) => m.hasPlayed); + const playersCancelled = mergeVisibleSelection(match.playersCancelled, (m) => m.isCancelled); + const cancelledIds = new Set(playersCancelled.map(String)); + const withoutCancelled = (ids) => ids.filter((id) => !cancelledIds.has(String(id))); + const playersReady = withoutCancelled(mergeVisibleSelection(match.playersReady, (m) => m.isReady)); + const playersPlanned = withoutCancelled(mergeVisibleSelection(match.playersPlanned, (m) => m.isPlanned)); + const playersPlayed = withoutCancelled(mergeVisibleSelection(match.playersPlayed, (m) => m.hasPlayed)); - console.log('[savePlayerSelection] Saving players:', { playersReady, playersPlanned, playersPlayed, matchId: match.id }); + console.log('[savePlayerSelection] Saving players:', { playersReady, playersPlanned, playersPlayed, playersCancelled, matchId: match.id }); try { const response = match.isFriendly ? await apiClient.patch(`${match.isSharedFriendly ? `/friendly-matches/shared/${this.currentClub}/${match.id}/players` : `/friendly-matches/${this.currentClub}/${match.id}/players`}`, { playersReady, playersPlanned, - playersPlayed + playersPlayed, + playersCancelled }) : await apiClient.patch(`/matches/${match.id}/players`, { clubId: this.currentClub, playersReady, playersPlanned, - playersPlayed + playersPlayed, + playersCancelled }); if (response.status >= 400) { throw new Error(response?.data?.error || 'Failed to update match players'); @@ -1593,12 +1627,14 @@ export default { match.playersReady = playersReady; match.playersPlanned = playersPlanned; match.playersPlayed = playersPlayed; + match.playersCancelled = playersCancelled; // Update all members in the list to reflect the current state this.playerSelectionDialog.members.forEach(m => { m.isReady = playersReady.includes(m.id); m.isPlanned = playersPlanned.includes(m.id); m.hasPlayed = playersPlayed.includes(m.id); + m.isCancelled = playersCancelled.includes(m.id); }); if (closeDialog) { @@ -3798,7 +3834,7 @@ li { width: 100%; border-collapse: collapse; margin: 20px 0; - min-width: 480px; + min-width: 620px; } .player-selection-table th, @@ -3811,9 +3847,13 @@ li { } .player-selection-table th { - background-color: #f8f9fa; + background-color: var(--primary-color, #218838); font-weight: 600; - color: #495057; + color: #fff; +} + +.player-selection-table select { + min-width: 130px; } .player-selection-table tbody tr:hover { diff --git a/frontend/src/views/TrainingStatsView.vue b/frontend/src/views/TrainingStatsView.vue index c1847926..7c5cb88e 100755 --- a/frontend/src/views/TrainingStatsView.vue +++ b/frontend/src/views/TrainingStatsView.vue @@ -439,9 +439,12 @@ export default { filteredOverview() { const totalParticipants = this.filteredTrainingDays.reduce((sum, day) => sum + (day.participantCount || 0), 0); const averageParticipants = this.filteredTrainingDays.length > 0 ? totalParticipants / this.filteredTrainingDays.length : 0; - const denominatorMembers = this.filteredMembers.length || 0; - const attendanceRate = denominatorMembers > 0 && this.filteredTrainingDays.length > 0 - ? (totalParticipants / (denominatorMembers * this.filteredTrainingDays.length)) * 100 + const availableMemberSlots = this.filteredTrainingDays.reduce( + (sum, day) => sum + Math.max(0, day.nonDeactivatedMemberCount || 0), + 0 + ); + const attendanceRate = availableMemberSlots > 0 + ? (totalParticipants / availableMemberSlots) * 100 : 0; const bestTrainingDay = this.filteredTrainingDays.reduce((best, day) => { if (!best || (day.participantCount || 0) > (best.participantCount || 0)) {