feat: add player cancellation handling and member deactivation tracking
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 57s

This commit is contained in:
Torsten Schulz (local)
2026-09-22 15:00:21 +02:00
parent 57fe139e67
commit a1f509e23e
11 changed files with 162 additions and 43 deletions

View File

@@ -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) {

View File

@@ -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',

View File

@@ -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,

View File

@@ -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,

View File

@@ -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() {

View File

@@ -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;

View File

@@ -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
};
}

View File

@@ -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 {

View File

@@ -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,
},

View File

@@ -382,7 +382,8 @@
<thead>
<tr>
<th>{{ $t('schedule.player') }}</th>
<th>{{ $t('schedule.ready') }}</th>
<th v-if="!playerSelectionDialog.match?.isFriendly">Rückmeldung</th>
<th v-else>{{ $t('schedule.ready') }}</th>
<th>{{ $t('schedule.planned') }}</th>
<th>{{ $t('schedule.played') }}</th>
<th>Beobachtung</th>
@@ -393,11 +394,22 @@
<td class="player-name">
{{ member.firstName }} {{ member.lastName }}
</td>
<td class="checkbox-cell">
<td v-if="!playerSelectionDialog.match?.isFriendly" class="checkbox-cell">
<select
:value="playerResponseValue(member)"
:disabled="playerSelectionDialog.readonly"
@change="setPlayerResponse(member, $event.target.value)"
>
<option value="">Keine Auswahl</option>
<option value="ready">Will spielen</option>
<option value="cancelled">Absage</option>
</select>
</td>
<td v-else class="checkbox-cell">
<input
type="checkbox"
:checked="member.isReady"
:disabled="playerSelectionDialog.readonly"
:disabled="playerSelectionDialog.readonly || member.isCancelled"
@change="togglePlayerReady(member)"
/>
</td>
@@ -405,7 +417,7 @@
<input
type="checkbox"
:checked="member.isPlanned"
:disabled="playerSelectionDialog.readonly"
:disabled="playerSelectionDialog.readonly || member.isCancelled"
@change="togglePlayerPlanned(member)"
/>
</td>
@@ -413,7 +425,7 @@
<input
type="checkbox"
:checked="member.hasPlayed"
:disabled="playerSelectionDialog.readonly"
:disabled="playerSelectionDialog.readonly || member.isCancelled"
@change="togglePlayerPlayed(member)"
/>
</td>
@@ -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 {

View File

@@ -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)) {