Add tournament update functionality and enhance UI for tournament management

This commit introduces the ability to update tournament details, including name and date, in the backend and frontend. The new `updateTournament` method is added to the `tournamentController` and `tournamentService`, allowing for validation and error handling. The frontend `TournamentsView` is updated to include input fields for editing tournament details, with real-time updates reflected in the UI. Additionally, new CSS styles are introduced for improved layout and user interaction, enhancing the overall experience in tournament management.
This commit is contained in:
Torsten Schulz (local)
2025-11-14 10:44:18 +01:00
parent 9b8dcd8561
commit d48cc4385f
5 changed files with 414 additions and 119 deletions

View File

@@ -135,6 +135,23 @@ export const getTournament = async (req, res) => {
}
};
// Update Turnier
export const updateTournament = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId } = req.params;
const { name, date } = req.body;
try {
const tournament = await tournamentService.updateTournament(token, clubId, tournamentId, name, date);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
res.status(200).json(tournament);
} catch (error) {
console.error('[updateTournament] Error:', error);
const status = error.message.includes('existiert bereits') ? 400 : 500;
res.status(status).json({ error: error.message });
}
};
// 10. Alle Spiele eines Turniers abfragen
export const getTournamentMatches = async (req, res) => {
const { authcode: token } = req.headers;

View File

@@ -0,0 +1,29 @@
-- Migration: Add name column to tournament table
-- Date: 2025-01-13
-- For MariaDB/MySQL
-- Add name column if it doesn't exist
-- Check if column exists and add it if not
SET @dbname = DATABASE();
SET @tablename = 'tournament';
SET @columnname = 'name';
SET @preparedStatement = (SELECT IF(
(
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE
(TABLE_SCHEMA = @dbname)
AND (TABLE_NAME = @tablename)
AND (COLUMN_NAME = @columnname)
) > 0,
'SELECT 1',
CONCAT('ALTER TABLE `', @tablename, '` ADD COLUMN `', @columnname, '` VARCHAR(255) NOT NULL DEFAULT "" AFTER `id`')
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;
-- Update existing tournaments: set name to formatted date if name is empty
UPDATE `tournament`
SET `name` = DATE_FORMAT(`date`, '%d.%m.%Y')
WHERE `name` = '' OR `name` IS NULL;

View File

@@ -2,6 +2,7 @@ import express from 'express';
import {
getTournaments,
addTournament,
updateTournament,
addParticipant,
getParticipants,
setModus,
@@ -39,6 +40,7 @@ router.delete('/match/result', authenticate, deleteMatchResult);
router.post("/match/reopen", reopenMatch);
router.post('/match/finish', authenticate, finishMatch);
router.get('/matches/:clubId/:tournamentId', authenticate, getTournamentMatches);
router.put('/:clubId/:tournamentId', authenticate, updateTournament);
router.get('/:clubId/:tournamentId', authenticate, getTournament);
router.get('/:clubId', authenticate, getTournaments);
router.post('/knockout', authenticate, startKnockout);

View File

@@ -161,17 +161,12 @@ class TournamentService {
throw new Error('Turnier nicht gefunden');
}
// 1) Hole vorhandene Gruppen
let groups = await TournamentGroup.findAll({ where: { tournamentId } });
// **Neu**: Falls noch keine Gruppen existieren, lege sie nach numberOfGroups an
if (!groups.length) {
const desired = tournament.numberOfGroups || 1; // Fallback auf 1, wenn undefiniert
for (let i = 0; i < desired; i++) {
await TournamentGroup.create({ tournamentId });
}
groups = await TournamentGroup.findAll({ where: { tournamentId } });
}
// 1) Stelle sicher, dass die richtige Anzahl von Gruppen existiert
await this.createGroups(userToken, clubId, tournamentId);
let groups = await TournamentGroup.findAll({
where: { tournamentId },
order: [['id', 'ASC']] // Stelle sicher, dass Gruppen sortiert sind
});
const members = await TournamentMember.findAll({ where: { tournamentId } });
if (!members.length) {
@@ -181,32 +176,39 @@ class TournamentService {
// 2) Alte Matches löschen
await TournamentMatch.destroy({ where: { tournamentId } });
// 3) Prüfe, ob Spieler bereits manuell zugeordnet wurden
const alreadyAssigned = members.filter(m => m.groupId !== null);
const unassigned = members.filter(m => m.groupId === null);
if (alreadyAssigned.length > 0) {
// Spieler sind bereits manuell zugeordnet - nicht neu verteilen
devLog(`${alreadyAssigned.length} Spieler bereits zugeordnet, ${unassigned.length} noch nicht zugeordnet`);
} else {
// Keine manuellen Zuordnungen - zufällig verteilen
const shuffled = members.slice();
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
groups.forEach((g, idx) => {
shuffled
.filter((_, i) => i % groups.length === idx)
.forEach(m => m.update({ groupId: g.id }));
});
}
// 3) Alle Zuordnungen löschen und zufällig neu verteilen
// (Bei "Zufällig verteilen" sollen alle alten Zuordnungen gelöscht werden)
await TournamentMember.update(
{ groupId: null },
{ where: { tournamentId } }
);
// 4) RoundRobin anlegen wie gehabt - NUR innerhalb jeder Gruppe
for (const g of groups) {
// 4) Zufällig verteilen
const shuffled = members.slice();
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
// Warte auf alle Updates, damit die Zuordnungen korrekt sind
const updatePromises = [];
groups.forEach((g, idx) => {
const groupMembers = shuffled.filter((_, i) => i % groups.length === idx);
groupMembers.forEach(m => {
updatePromises.push(m.update({ groupId: g.id }));
});
});
await Promise.all(updatePromises);
// 5) RoundRobin anlegen wie gehabt - NUR innerhalb jeder Gruppe
// Stelle sicher, dass Gruppen sortiert sind
const sortedGroups = groups.sort((a, b) => a.id - b.id);
for (const g of sortedGroups) {
const gm = await TournamentMember.findAll({ where: { groupId: g.id } });
if (gm.length < 2) {
console.log(`⚠️ Gruppe ${g.id} hat weniger als 2 Spieler (${gm.length}), überspringe Spiele-Erstellung`);
continue;
}
@@ -214,11 +216,10 @@ class TournamentService {
for (let roundIndex = 0; roundIndex < rounds.length; roundIndex++) {
for (const [p1Id, p2Id] of rounds[roundIndex]) {
// Prüfe, ob beide Spieler zur gleichen Gruppe gehören
const p1 = gm.find(p => p.id === p1Id);
const p2 = gm.find(p => p.id === p2Id);
if (p1 && p2 && p1.groupId === p2.groupId && p1.groupId === g.id) {
const match = await TournamentMatch.create({
// p1Id und p2Id sind bereits aus gm, also müssen sie zur Gruppe g gehören
// Prüfe nur, ob beide IDs vorhanden sind (nicht null, falls Bye)
if (p1Id && p2Id) {
await TournamentMatch.create({
tournamentId,
groupId: g.id,
round: 'group',
@@ -283,6 +284,29 @@ class TournamentService {
return t;
}
// Update Turnier (Name und Datum)
async updateTournament(userToken, clubId, tournamentId, name, date) {
await checkAccess(userToken, clubId);
const tournament = await Tournament.findOne({ where: { id: tournamentId, clubId } });
if (!tournament) {
throw new Error('Turnier nicht gefunden');
}
// Prüfe auf Duplikat, wenn Datum geändert wird
if (date && date !== tournament.date) {
const existing = await Tournament.findOne({ where: { clubId, date, id: { [Op.ne]: tournamentId } } });
if (existing) {
throw new Error('Ein Turnier mit diesem Datum existiert bereits');
}
}
if (name !== undefined) tournament.name = name;
if (date !== undefined) tournament.date = date;
await tournament.save();
return JSON.parse(JSON.stringify(tournament));
}
// 11. Spiele eines Turniers
async getTournamentMatches(userToken, clubId, tournamentId) {
await checkAccess(userToken, clubId);
@@ -296,8 +320,8 @@ class TournamentService {
{ model: TournamentResult, as: 'tournamentResults' }
],
order: [
['group_id', 'ASC'],
['group_round', 'ASC'],
['group_round', 'ASC'], // Zuerst nach Runde sortieren
// group_id wird nicht sortiert, da die logische groupNumber im Frontend verwendet wird
['id', 'ASC'],
[{ model: TournamentResult, as: 'tournamentResults' }, 'set', 'ASC']
]

View File

@@ -6,19 +6,45 @@
<select v-model="selectedDate">
<option value="new">Neues Turnier</option>
<option v-for="date in dates" :key="date.id" :value="date.id">
{{ date.tournamentName || (date.date ? new Date(date.date).toLocaleDateString('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
}) : 'Unbekanntes Datum') }}
<template v-if="date.name">
{{ date.name }} ({{ date.date ? new Date(date.date).toLocaleDateString('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
}) : 'Unbekanntes Datum' }})
</template>
<template v-else>
{{ date.date ? new Date(date.date).toLocaleDateString('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
}) : 'Unbekanntes Datum' }}
</template>
</option>
</select>
<div v-if="selectedDate === 'new'" class="new-tournament">
<input type="date" v-model="newDate" />
<label>
Name:
<input type="text" v-model="newTournamentName" placeholder="Turniername" />
</label>
<label>
Datum:
<input type="date" v-model="newDate" />
</label>
<button @click="createTournament">Erstellen</button>
</div>
</div>
<div v-if="selectedDate !== 'new'" class="tournament-setup">
<div class="tournament-info">
<label>
Name:
<input type="text" v-model="currentTournamentName" @blur="updateTournament" />
</label>
<label>
Datum:
<input type="date" v-model="currentTournamentDate" @blur="updateTournament" />
</label>
</div>
<label class="checkbox-item">
<input type="checkbox" v-model="isGroupTournament" @change="onModusChange" />
<span>Spielen in Gruppen</span>
@@ -29,56 +55,36 @@
<span class="collapse-icon" :class="{ 'expanded': showParticipants }"></span>
</div>
<div v-show="showParticipants" class="participants-content">
<ul>
<li v-for="participant in sortedParticipants" :key="participant.id">
{{ participant.member?.firstName || 'Unbekannt' }}
{{ participant.member?.lastName || '' }}
<ul class="participants-list">
<li v-for="participant in sortedParticipants" :key="participant.id" class="participant-item">
<span class="participant-name">
{{ participant.member?.firstName || 'Unbekannt' }}
{{ participant.member?.lastName || '' }}
</span>
<template v-if="isGroupTournament">
<label class="inline-label">
Gruppe:
<select v-model.number="participant.groupNumber" @change="updateParticipantGroup(participant, $event)">
<span class="participant-group-cell">
<select v-model.number="participant.groupNumber" @change="updateParticipantGroup(participant, $event)" class="group-select-small">
<option :value="null"></option>
<option v-for="group in groups" :key="group.groupId" :value="group.groupNumber">
Gruppe {{ group.groupNumber }}
{{ group.groupNumber }}
</option>
</select>
</label>
<!-- Info Dialog -->
<InfoDialog
v-model="infoDialog.isOpen"
:title="infoDialog.title"
:message="infoDialog.message"
:details="infoDialog.details"
:type="infoDialog.type"
/>
<!-- Confirm Dialog -->
<ConfirmDialog
v-model="confirmDialog.isOpen"
:title="confirmDialog.title"
:message="confirmDialog.message"
:details="confirmDialog.details"
:type="confirmDialog.type"
@confirm="handleConfirmResult(true)"
@cancel="handleConfirmResult(false)"
/>
</template>
<button @click="removeParticipant(participant)" style="margin-left:0.5rem" class="trash-btn">
🗑
</button>
</span>
</template>
<span class="participant-action-cell">
<button @click="removeParticipant(participant)" class="trash-btn-small" title="Löschen">🗑</button>
</span>
</li>
</ul>
<div class="add-participant">
<select v-model="selectedMember">
<select v-model="selectedMember" class="member-select">
<option :value="null">-- Teilnehmer auswählen --</option>
<option v-for="member in clubMembers" :key="member.id" :value="member.id">
{{ member.firstName }}
{{ member.lastName }}
</option>
</select>
<button @click="addParticipant">Hinzufügen</button>
<button @click="addParticipant" class="btn-add">Hinzufügen</button>
<button v-if="hasTrainingToday" @click="loadParticipantsFromTraining" class="training-btn">
📅 Aus Trainingstag laden
</button>
@@ -102,6 +108,7 @@
<button @click="createGroups">Gruppen erstellen</button>
<button @click="randomizeGroups">Zufällig verteilen</button>
<button @click="resetGroups">Gruppen zurücksetzen</button>
</section>
<section v-if="groups.length" class="groups-overview">
<h3>Gruppenübersicht</h3>
@@ -149,10 +156,7 @@
</table>
</div>
<div class="reset-controls" style="margin-top:1rem">
<button @click="resetGroups">
Gruppen zurücksetzen
</button>
<button @click="resetMatches" style="margin-left:0.5rem" class="trash-btn">
<button @click="resetMatches" class="trash-btn">
🗑 Gruppenspiele
</button>
</div>
@@ -698,6 +702,9 @@ export default {
},
selectedDate: 'new',
newDate: '',
newTournamentName: '',
currentTournamentName: '',
currentTournamentDate: '',
dates: [],
participants: [],
selectedMember: null,
@@ -756,16 +763,55 @@ export default {
},
groupMatches() {
return this.matches
.filter(m => m.round === 'group')
.sort((a, b) => {
// zuerst nach Runde
if (a.groupRound !== b.groupRound) {
return a.groupRound - b.groupRound;
const filtered = this.matches.filter(m => m.round === 'group');
// Gruppiere nach Runde
const byRound = {};
filtered.forEach(m => {
const round = m.groupRound || 0;
if (!byRound[round]) {
byRound[round] = [];
}
byRound[round].push(m);
});
// Sortiere die Runden
const sortedRounds = Object.keys(byRound).sort((a, b) => Number(a) - Number(b));
// Für jede Runde: Sortiere nach Gruppe, dann interleaved zusammenführen
const result = [];
sortedRounds.forEach(round => {
const matchesInRound = byRound[round];
// Gruppiere nach groupNumber
const byGroup = {};
matchesInRound.forEach(m => {
const groupNum = m.groupNumber || 0;
if (!byGroup[groupNum]) {
byGroup[groupNum] = [];
}
// dann nach Gruppe
return a.groupNumber - b.groupNumber;
byGroup[groupNum].push(m);
});
// Sortiere die Gruppen
const sortedGroups = Object.keys(byGroup).sort((a, b) => Number(a) - Number(b));
// Interleaved: Nimm abwechselnd aus jeder Gruppe
let maxLength = 0;
sortedGroups.forEach(groupNum => {
maxLength = Math.max(maxLength, byGroup[groupNum].length);
});
for (let i = 0; i < maxLength; i++) {
sortedGroups.forEach(groupNum => {
if (i < byGroup[groupNum].length) {
result.push(byGroup[groupNum][i]);
}
});
}
});
return result;
},
groupRankings() {
@@ -1011,9 +1057,14 @@ export default {
`/tournament/${this.currentClub}/${this.selectedDate}`
);
const tournament = tRes.data;
this.currentTournamentName = tournament.name || '';
this.currentTournamentDate = tournament.date || '';
this.isGroupTournament = tournament.type === 'groups';
this.numberOfGroups = tournament.numberOfGroups;
this.advancingPerGroup = tournament.advancingPerGroup;
// Prüfe, ob es einen Trainingstag für das Turnierdatum gibt
await this.checkTrainingForDate(tournament.date);
const pRes = await apiClient.post('/tournament/participants', {
clubId: this.currentClub,
tournamentId: this.selectedDate
@@ -1035,15 +1086,19 @@ export default {
}, {});
this.matches = mRes.data.map(m => {
// Bestimme groupId basierend auf den Spielern, da die Matches groupId: null haben
const player1GroupId = m.player1?.groupId;
const player2GroupId = m.player2?.groupId;
const matchGroupId = player1GroupId || player2GroupId;
// Verwende groupId aus dem Backend, falls vorhanden, sonst aus den Spielern
const matchGroupId = m.groupId || m.player1?.groupId || m.player2?.groupId;
// Stelle sicher, dass groupRound vorhanden ist (kann als group_round vom Backend kommen)
const groupRound = m.groupRound || m.group_round || 0;
const groupNumber = grpMap[matchGroupId] || 0;
return {
...m,
groupId: matchGroupId, // Überschreibe null mit der korrekten groupId
groupNumber: grpMap[matchGroupId] || 0,
groupId: matchGroupId,
groupNumber: groupNumber,
groupRound: groupRound, // Stelle sicher, dass groupRound gesetzt ist
resultInput: ''
};
});
@@ -1123,24 +1178,30 @@ export default {
}
}
// Prüfe, ob es einen Trainingstag heute gibt
await this.checkTrainingToday();
// Prüfe nicht hier, da kein Turnier ausgewählt ist
} catch (error) {
console.error('Fehler beim Laden der Turniere:', error);
this.dates = [];
}
},
async checkTrainingToday() {
async checkTrainingForDate(date) {
try {
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD Format
if (!date) {
this.hasTrainingToday = false;
return;
}
// Konvertiere das Datum ins YYYY-MM-DD Format, falls es noch nicht in diesem Format ist
const dateStr = date instanceof Date
? date.toISOString().split('T')[0]
: date.split('T')[0]; // Falls es ein ISO-String ist, nimm nur den Datumsteil
const response = await apiClient.get(`/diary/${this.currentClub}`);
// Die API gibt alle Trainingstage zurück, filtere nach heute
// Die API gibt alle Trainingstage zurück, filtere nach dem Turnierdatum
const trainingData = response.data;
if (Array.isArray(trainingData)) {
this.hasTrainingToday = trainingData.some(training => training.date === today);
this.hasTrainingToday = trainingData.some(training => training.date === dateStr);
} else {
this.hasTrainingToday = false;
}
@@ -1150,11 +1211,38 @@ export default {
}
},
async updateTournament() {
if (!this.currentTournamentDate) {
await this.showInfo('Fehler', 'Bitte geben Sie ein Datum ein!', '', 'error');
return;
}
try {
await apiClient.put(`/tournament/${this.currentClub}/${this.selectedDate}`, {
name: this.currentTournamentName || this.currentTournamentDate,
date: this.currentTournamentDate
});
// Prüfe, ob es einen Trainingstag für das neue Datum gibt
await this.checkTrainingForDate(this.currentTournamentDate);
// Lade Turnierliste neu, damit der Name in der Dropdown-Liste aktualisiert wird
await this.loadTournaments();
} catch (error) {
console.error('Fehler beim Aktualisieren des Turniers:', error);
const message = safeErrorMessage(error, 'Fehler beim Aktualisieren des Turniers.');
await this.showInfo('Fehler', message, '', 'error');
// Lade Daten neu, um die ursprünglichen Werte wiederherzustellen
await this.loadTournamentData();
}
},
async createTournament() {
if (!this.newDate) {
await this.showInfo('Fehler', 'Bitte geben Sie ein Datum ein!', '', 'error');
return;
}
try {
const r = await apiClient.post('/tournament', {
clubId: this.currentClub,
tournamentName: this.newDate,
tournamentName: this.newTournamentName || this.newDate,
date: this.newDate
});
@@ -1169,6 +1257,7 @@ export default {
this.selectedDate = newTournamentId;
this.newDate = '';
this.newTournamentName = '';
} catch (error) {
console.error('Fehler beim Erstellen des Turniers:', error);
const message = safeErrorMessage(error, 'Fehler beim Erstellen des Turniers.');
@@ -1637,21 +1726,29 @@ export default {
async loadParticipantsFromTraining() {
try {
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD Format
if (!this.currentTournamentDate) {
await this.showInfo('Hinweis', 'Kein Turnierdatum vorhanden!', '', 'info');
return;
}
// Konvertiere das Turnierdatum ins YYYY-MM-DD Format
const tournamentDate = this.currentTournamentDate instanceof Date
? this.currentTournamentDate.toISOString().split('T')[0]
: this.currentTournamentDate.split('T')[0]; // Falls es ein ISO-String ist, nimm nur den Datumsteil
const response = await apiClient.get(`/diary/${this.currentClub}`);
// Die API gibt alle Trainingstage zurück, filtere nach heute
// Die API gibt alle Trainingstage zurück, filtere nach dem Turnierdatum
const trainingData = response.data;
if (Array.isArray(trainingData)) {
// Finde den Trainingstag für heute
const todayTraining = trainingData.find(training => training.date === today);
// Finde den Trainingstag für das Turnierdatum
const trainingForDate = trainingData.find(training => training.date === tournamentDate);
if (todayTraining) {
if (trainingForDate) {
// Lade die Teilnehmer für diesen Trainingstag über die Participant-API
const participantsResponse = await apiClient.get(`/participants/${todayTraining.id}`);
const participantsResponse = await apiClient.get(`/participants/${trainingForDate.id}`);
const participants = participantsResponse.data;
@@ -1684,16 +1781,16 @@ export default {
// Lade Turnierdaten neu
await this.loadTournamentData();
} else {
await this.showInfo('Hinweis', 'Keine gültigen Teilnehmer im heutigen Trainingstag gefunden!', '', 'info');
await this.showInfo('Hinweis', 'Keine gültigen Teilnehmer im Trainingstag für dieses Datum gefunden!', '', 'info');
}
} else {
await this.showInfo('Hinweis', 'Keine Teilnehmer im heutigen Trainingstag gefunden!', '', 'info');
await this.showInfo('Hinweis', 'Keine Teilnehmer im Trainingstag für dieses Datum gefunden!', '', 'info');
}
} else {
await this.showInfo('Hinweis', 'Kein Trainingstag für heute gefunden!', '', 'info');
await this.showInfo('Hinweis', `Kein Trainingstag für ${tournamentDate} gefunden!`, '', 'info');
}
} else {
await this.showInfo('Hinweis', 'Kein Trainingstag für heute gefunden!', '', 'info');
await this.showInfo('Hinweis', `Kein Trainingstag für ${tournamentDate} gefunden!`, '', 'info');
}
} catch (error) {
console.error('Fehler beim Laden der Trainingsteilnehmer:', error);
@@ -1852,6 +1949,58 @@ export default {
padding: 1rem;
}
.new-tournament {
margin-top: 1rem;
display: flex;
gap: 1rem;
align-items: flex-end;
flex-wrap: wrap;
}
.new-tournament label {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.new-tournament input[type="text"],
.new-tournament input[type="date"] {
padding: 0.4rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1em;
}
.new-tournament input[type="text"] {
min-width: 200px;
}
.tournament-info {
margin-bottom: 1rem;
display: flex;
gap: 1rem;
align-items: flex-end;
flex-wrap: wrap;
}
.tournament-info label {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.tournament-info input[type="text"],
.tournament-info input[type="date"] {
padding: 0.4rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1em;
}
.tournament-info input[type="text"] {
min-width: 200px;
}
.participants,
.group-controls,
.groups-overview,
@@ -1999,6 +2148,57 @@ button {
}
}
.participants-list {
list-style: none;
padding: 0;
margin: 0;
width: auto;
display: table;
}
.participant-item {
display: table-row;
font-size: 0.9em;
line-height: 1.1;
}
.participant-name {
display: table-cell;
white-space: nowrap;
}
.participant-group-cell {
display: table-cell;
white-space: nowrap;
}
.participant-action-cell {
display: table-cell;
white-space: nowrap;
}
.group-select-small {
font-size: 0.85em;
padding: 2px 4px;
width: 50px;
border: 1px solid #ccc;
border-radius: 3px;
}
.trash-btn-small {
background: none;
border: none;
cursor: pointer;
font-size: 0.9em;
padding: 0;
opacity: 0.7;
transition: opacity 0.2s;
}
.trash-btn-small:hover {
opacity: 1;
}
.add-participant {
margin-top: 1rem;
padding-top: 1rem;
@@ -2009,6 +2209,29 @@ button {
flex-wrap: wrap;
}
.member-select {
flex: 1;
min-width: 200px;
padding: 0.4rem;
border: 1px solid #ccc;
border-radius: 4px;
}
.btn-add {
padding: 0.4rem 0.8rem;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
white-space: nowrap;
}
.btn-add:hover {
background-color: #0056b3;
}
.training-btn {
background-color: #28a745;
color: white;