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

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