feat: add player cancellation handling and member deactivation tracking
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 57s
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 57s
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user