Enhance match management functionality by adding player selection capabilities. Introduce new endpoints for updating match players and retrieving player match statistics in matchController and matchService. Update Match model to include fields for players ready, planned, and played. Modify frontend components to support player selection dialog, allowing users to manage player statuses effectively. Improve UI for better user experience and data visibility.
This commit is contained in:
@@ -102,3 +102,46 @@ export const fetchLeagueTableFromMyTischtennis = async (req, res) => {
|
||||
return res.status(500).json({ error: 'Failed to fetch league table from MyTischtennis' });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateMatchPlayers = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { matchId } = req.params;
|
||||
const { playersReady, playersPlanned, playersPlayed } = req.body;
|
||||
|
||||
const result = await MatchService.updateMatchPlayers(
|
||||
userToken,
|
||||
matchId,
|
||||
playersReady,
|
||||
playersPlanned,
|
||||
playersPlayed
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
message: 'Match players updated successfully',
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating match players:', error);
|
||||
return res.status(error.statusCode || 500).json({
|
||||
error: error.message || 'Failed to update match players'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getPlayerMatchStats = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, leagueId } = req.params;
|
||||
const { seasonid: seasonId } = req.query;
|
||||
|
||||
const stats = await MatchService.getPlayerMatchStats(userToken, clubId, leagueId, seasonId);
|
||||
|
||||
return res.status(200).json(stats);
|
||||
} catch (error) {
|
||||
console.error('Error retrieving player match stats:', error);
|
||||
return res.status(error.statusCode || 500).json({
|
||||
error: error.message || 'Failed to retrieve player match stats'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
8
backend/migrations/add_player_tracking_to_match.sql
Normal file
8
backend/migrations/add_player_tracking_to_match.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- Add player tracking fields to match table
|
||||
-- These fields store arrays of member IDs for different participation states
|
||||
|
||||
ALTER TABLE `match`
|
||||
ADD COLUMN `players_ready` JSON NULL COMMENT 'Array of member IDs who are ready to play' AFTER `pdf_url`,
|
||||
ADD COLUMN `players_planned` JSON NULL COMMENT 'Array of member IDs who are planned to play' AFTER `players_ready`,
|
||||
ADD COLUMN `players_played` JSON NULL COMMENT 'Array of member IDs who actually played' AFTER `players_planned`;
|
||||
|
||||
@@ -109,6 +109,24 @@ const Match = sequelize.define('Match', {
|
||||
comment: 'PDF URL from myTischtennis',
|
||||
field: 'pdf_url'
|
||||
},
|
||||
playersReady: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
comment: 'Array of member IDs who are ready to play',
|
||||
field: 'players_ready'
|
||||
},
|
||||
playersPlanned: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
comment: 'Array of member IDs who are planned to play',
|
||||
field: 'players_planned'
|
||||
},
|
||||
playersPlayed: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
comment: 'Array of member IDs who actually played',
|
||||
field: 'players_played'
|
||||
},
|
||||
}, {
|
||||
underscored: true,
|
||||
tableName: 'match',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import express from 'express';
|
||||
import { uploadCSV, getLeaguesForCurrentSeason, getMatchesForLeagues, getMatchesForLeague, getLeagueTable, fetchLeagueTableFromMyTischtennis } from '../controllers/matchController.js';
|
||||
import { uploadCSV, getLeaguesForCurrentSeason, getMatchesForLeagues, getMatchesForLeague, getLeagueTable, fetchLeagueTableFromMyTischtennis, updateMatchPlayers, getPlayerMatchStats } from '../controllers/matchController.js';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import multer from 'multer';
|
||||
|
||||
@@ -13,6 +13,8 @@ router.get('/leagues/:clubId/matches/:leagueId', authenticate, getMatchesForLeag
|
||||
router.get('/leagues/:clubId/matches', authenticate, getMatchesForLeagues);
|
||||
router.get('/leagues/:clubId/table/:leagueId', authenticate, getLeagueTable);
|
||||
router.post('/leagues/:clubId/table/:leagueId/fetch', authenticate, fetchLeagueTableFromMyTischtennis);
|
||||
router.patch('/:matchId/players', authenticate, updateMatchPlayers);
|
||||
router.get('/leagues/:clubId/stats/:leagueId', authenticate, getPlayerMatchStats);
|
||||
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -49,7 +49,7 @@ const __dirname = path.dirname(__filename);
|
||||
app.use(cors({
|
||||
origin: true,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'authcode', 'userid']
|
||||
}));
|
||||
app.use(express.json());
|
||||
|
||||
@@ -241,6 +241,9 @@ class MatchService {
|
||||
guestMatchPoints: match.guestMatchPoints || 0,
|
||||
isCompleted: match.isCompleted || false,
|
||||
pdfUrl: match.pdfUrl,
|
||||
playersReady: match.playersReady || [],
|
||||
playersPlanned: match.playersPlanned || [],
|
||||
playersPlayed: match.playersPlayed || [],
|
||||
homeTeam: { name: 'Unbekannt' },
|
||||
guestTeam: { name: 'Unbekannt' },
|
||||
location: { name: 'Unbekannt', address: '', city: '', zip: '' },
|
||||
@@ -353,6 +356,9 @@ class MatchService {
|
||||
guestMatchPoints: match.guestMatchPoints || 0,
|
||||
isCompleted: match.isCompleted || false,
|
||||
pdfUrl: match.pdfUrl,
|
||||
playersReady: match.playersReady || [],
|
||||
playersPlanned: match.playersPlanned || [],
|
||||
playersPlayed: match.playersPlayed || [],
|
||||
homeTeam: { name: 'Unbekannt' },
|
||||
guestTeam: { name: 'Unbekannt' },
|
||||
location: { name: 'Unbekannt', address: '', city: '', zip: '' },
|
||||
@@ -430,6 +436,118 @@ class MatchService {
|
||||
}
|
||||
}
|
||||
|
||||
async updateMatchPlayers(userToken, matchId, playersReady, playersPlanned, playersPlayed) {
|
||||
// Find the match and verify access
|
||||
const match = await Match.findByPk(matchId, {
|
||||
include: [
|
||||
{ model: Club, as: 'club' }
|
||||
]
|
||||
});
|
||||
|
||||
if (!match) {
|
||||
throw new HttpError(404, 'Match not found');
|
||||
}
|
||||
|
||||
await checkAccess(userToken, match.clubId);
|
||||
|
||||
// Update player arrays
|
||||
await match.update({
|
||||
playersReady: playersReady || [],
|
||||
playersPlanned: playersPlanned || [],
|
||||
playersPlayed: playersPlayed || []
|
||||
});
|
||||
|
||||
return {
|
||||
id: match.id,
|
||||
playersReady: match.playersReady,
|
||||
playersPlanned: match.playersPlanned,
|
||||
playersPlayed: match.playersPlayed
|
||||
};
|
||||
}
|
||||
|
||||
async getPlayerMatchStats(userToken, clubId, leagueId, seasonId) {
|
||||
await checkAccess(userToken, clubId);
|
||||
|
||||
// Get all matches for this league/season
|
||||
const matches = await Match.findAll({
|
||||
where: {
|
||||
clubId: clubId,
|
||||
leagueId: leagueId
|
||||
},
|
||||
attributes: ['id', 'date', 'playersPlayed']
|
||||
});
|
||||
|
||||
// Get all members
|
||||
const Member = (await import('../models/Member.js')).default;
|
||||
const members = await Member.findAll({
|
||||
where: { clubId: clubId, active: true },
|
||||
attributes: ['id', 'firstName', 'lastName']
|
||||
});
|
||||
|
||||
// Calculate stats
|
||||
const stats = {};
|
||||
const now = new Date();
|
||||
|
||||
// Saison startet am 1. Juli
|
||||
const seasonStart = new Date();
|
||||
seasonStart.setMonth(6, 1); // 1. Juli
|
||||
seasonStart.setHours(0, 0, 0, 0);
|
||||
if (seasonStart > now) {
|
||||
seasonStart.setFullYear(seasonStart.getFullYear() - 1);
|
||||
}
|
||||
|
||||
// Vorrunde: 1. Juli bis 31. Dezember
|
||||
const firstHalfEnd = new Date(seasonStart.getFullYear(), 11, 31, 23, 59, 59, 999); // 31. Dezember
|
||||
|
||||
// Rückrunde startet am 1. Januar (im Jahr nach Saisonstart)
|
||||
const secondHalfStart = new Date(seasonStart.getFullYear() + 1, 0, 1, 0, 0, 0, 0); // 1. Januar
|
||||
|
||||
for (const member of members) {
|
||||
stats[member.id] = {
|
||||
memberId: member.id,
|
||||
firstName: member.firstName,
|
||||
lastName: member.lastName,
|
||||
totalSeason: 0,
|
||||
totalFirstHalf: 0,
|
||||
totalSecondHalf: 0
|
||||
};
|
||||
}
|
||||
|
||||
for (const match of matches) {
|
||||
// Parse playersPlayed if it's a JSON string
|
||||
let playersPlayed = match.playersPlayed;
|
||||
if (typeof playersPlayed === 'string') {
|
||||
try {
|
||||
playersPlayed = JSON.parse(playersPlayed);
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!playersPlayed || !Array.isArray(playersPlayed) || playersPlayed.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const matchDate = new Date(match.date);
|
||||
const isInSeason = matchDate >= seasonStart;
|
||||
const isInFirstHalf = matchDate >= seasonStart && matchDate <= firstHalfEnd;
|
||||
const isInSecondHalf = matchDate >= secondHalfStart;
|
||||
|
||||
for (const playerId of playersPlayed) {
|
||||
if (stats[playerId]) {
|
||||
if (isInSeason) stats[playerId].totalSeason++;
|
||||
if (isInFirstHalf) stats[playerId].totalFirstHalf++;
|
||||
if (isInSecondHalf) stats[playerId].totalSecondHalf++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to array, filter out players with 0 matches, and sort by totalSeason descending
|
||||
return Object.values(stats)
|
||||
.filter(player => player.totalSeason > 0)
|
||||
.sort((a, b) => b.totalSeason - a.totalSeason);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default new MatchService();
|
||||
|
||||
@@ -61,8 +61,12 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="match in matches" :key="match.id" @mouseover="hoveredMatch = match"
|
||||
@mouseleave="hoveredMatch = null" :class="getRowClass(match.date)">
|
||||
<tr v-for="match in matches" :key="match.id"
|
||||
@mouseover="hoveredMatch = match"
|
||||
@mouseleave="hoveredMatch = null"
|
||||
@click="openPlayerSelectionDialog(match)"
|
||||
:class="getRowClass(match.date)"
|
||||
style="cursor: pointer;">
|
||||
<td>{{ formatDate(match.date) }}</td>
|
||||
<td>{{ match.time ? match.time.toString().slice(0, 5) + ' Uhr' : 'N/A' }}</td>
|
||||
<td v-html="highlightClubName(match.homeTeam?.name || 'N/A')"></td>
|
||||
@@ -162,6 +166,75 @@
|
||||
<ConfirmDialog v-model="confirmDialog.isOpen" :title="confirmDialog.title" :message="confirmDialog.message"
|
||||
:details="confirmDialog.details" :type="confirmDialog.type" @confirm="handleConfirmResult(true)"
|
||||
@cancel="handleConfirmResult(false)" />
|
||||
|
||||
<!-- Player Selection Dialog -->
|
||||
<BaseDialog
|
||||
v-model="playerSelectionDialog.isOpen"
|
||||
:title="`Spielerauswahl - ${playerSelectionDialog.match?.homeTeam?.name || ''} vs ${playerSelectionDialog.match?.guestTeam?.name || ''}`"
|
||||
@close="closePlayerSelectionDialog"
|
||||
:max-width="800"
|
||||
>
|
||||
<div v-if="playerSelectionDialog.loading" class="loading-state">
|
||||
Lade Mitglieder...
|
||||
</div>
|
||||
|
||||
<div v-else class="player-selection-content">
|
||||
<div class="match-info">
|
||||
<p><strong>Datum:</strong> {{ formatDate(playerSelectionDialog.match?.date) }}</p>
|
||||
<p><strong>Uhrzeit:</strong> {{ playerSelectionDialog.match?.time ? playerSelectionDialog.match.time.toString().slice(0, 5) + ' Uhr' : 'N/A' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="player-list">
|
||||
<table class="player-selection-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Spieler</th>
|
||||
<th>Bereit</th>
|
||||
<th>Vorgesehen</th>
|
||||
<th>Gespielt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="member in playerSelectionDialog.members" :key="member.id">
|
||||
<td class="player-name">
|
||||
{{ member.firstName }} {{ member.lastName }}
|
||||
</td>
|
||||
<td class="checkbox-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="member.isReady"
|
||||
@change="togglePlayerReady(member)"
|
||||
/>
|
||||
</td>
|
||||
<td class="checkbox-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="member.isPlanned"
|
||||
@change="togglePlayerPlanned(member)"
|
||||
/>
|
||||
</td>
|
||||
<td class="checkbox-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="member.hasPlayed"
|
||||
@change="togglePlayerPlayed(member)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-if="playerSelectionDialog.members.length === 0" class="no-members">
|
||||
Keine aktiven Mitglieder gefunden
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dialog-actions">
|
||||
<button @click="savePlayerSelection" class="btn-save">Speichern</button>
|
||||
<button @click="closePlayerSelectionDialog" class="btn-cancel">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -217,6 +290,14 @@ export default {
|
||||
activeTab: 'schedule',
|
||||
leagueTable: [],
|
||||
fetchingTable: false,
|
||||
|
||||
// Player Selection Dialog
|
||||
playerSelectionDialog: {
|
||||
isOpen: false,
|
||||
match: null,
|
||||
members: [],
|
||||
loading: false
|
||||
},
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
@@ -280,6 +361,95 @@ export default {
|
||||
this.confirmDialog.isOpen = false;
|
||||
},
|
||||
|
||||
async openPlayerSelectionDialog(match) {
|
||||
console.log('Opening player selection for match:', match);
|
||||
this.playerSelectionDialog.match = match;
|
||||
this.playerSelectionDialog.isOpen = true;
|
||||
this.playerSelectionDialog.loading = true;
|
||||
|
||||
try {
|
||||
// Fetch members for the current club
|
||||
console.log('Fetching members for club:', this.currentClub);
|
||||
const response = await apiClient.get(`/clubmembers/get/${this.currentClub}/true`);
|
||||
console.log('Members response:', response.data);
|
||||
|
||||
const allMembers = response.data;
|
||||
|
||||
// Filter members by age class if league has age class info
|
||||
// For now, show all active members
|
||||
const activeMembers = allMembers.filter(m => m.active);
|
||||
console.log('Active members count:', activeMembers.length);
|
||||
|
||||
this.playerSelectionDialog.members = activeMembers.map(m => ({
|
||||
...m,
|
||||
isReady: match.playersReady?.includes(m.id) || false,
|
||||
isPlanned: match.playersPlanned?.includes(m.id) || false,
|
||||
hasPlayed: match.playersPlayed?.includes(m.id) || false
|
||||
}));
|
||||
|
||||
console.log('Player selection members:', this.playerSelectionDialog.members.length);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading members:', error);
|
||||
console.error('Error details:', error.response?.data);
|
||||
await this.showInfo('Fehler', 'Laden der Mitgliederliste fehlgeschlagen.', error.response?.data?.error || error.message, 'error');
|
||||
} finally {
|
||||
this.playerSelectionDialog.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
closePlayerSelectionDialog() {
|
||||
this.playerSelectionDialog.isOpen = false;
|
||||
this.playerSelectionDialog.match = null;
|
||||
this.playerSelectionDialog.members = [];
|
||||
},
|
||||
|
||||
togglePlayerReady(member) {
|
||||
member.isReady = !member.isReady;
|
||||
},
|
||||
|
||||
togglePlayerPlanned(member) {
|
||||
member.isPlanned = !member.isPlanned;
|
||||
},
|
||||
|
||||
togglePlayerPlayed(member) {
|
||||
member.hasPlayed = !member.hasPlayed;
|
||||
},
|
||||
|
||||
async savePlayerSelection() {
|
||||
const match = this.playerSelectionDialog.match;
|
||||
if (!match) return;
|
||||
|
||||
const playersReady = this.playerSelectionDialog.members
|
||||
.filter(m => m.isReady)
|
||||
.map(m => m.id);
|
||||
const playersPlanned = this.playerSelectionDialog.members
|
||||
.filter(m => m.isPlanned)
|
||||
.map(m => m.id);
|
||||
const playersPlayed = this.playerSelectionDialog.members
|
||||
.filter(m => m.hasPlayed)
|
||||
.map(m => m.id);
|
||||
|
||||
try {
|
||||
await apiClient.patch(`/matches/${match.id}/players`, {
|
||||
playersReady,
|
||||
playersPlanned,
|
||||
playersPlayed
|
||||
});
|
||||
|
||||
// Update local match data
|
||||
match.playersReady = playersReady;
|
||||
match.playersPlanned = playersPlanned;
|
||||
match.playersPlayed = playersPlayed;
|
||||
|
||||
await this.showInfo('Erfolg', 'Spielerauswahl gespeichert', '', 'success');
|
||||
this.closePlayerSelectionDialog();
|
||||
} catch (error) {
|
||||
console.error('Error saving player selection:', error);
|
||||
await this.showInfo('Fehler', 'Fehler beim Speichern der Spielerauswahl', '', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
...mapActions(['openDialog']),
|
||||
openImportModal() {
|
||||
this.showImportModal = true;
|
||||
@@ -963,4 +1133,109 @@ li {
|
||||
#league-table tr.our-team:hover {
|
||||
background-color: #d4edda;
|
||||
}
|
||||
|
||||
/* Player Selection Dialog */
|
||||
.player-selection-content {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.match-info {
|
||||
margin-bottom: 20px;
|
||||
padding: 10px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.match-info p {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.player-selection-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.player-selection-table th,
|
||||
.player-selection-table td {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.player-selection-table th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.player-selection-table tbody tr:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.player-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.checkbox-cell {
|
||||
text-align: center;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.checkbox-cell input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.no-members {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #dee2e6;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-save,
|
||||
.btn-cancel {
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-save:hover {
|
||||
background-color: #218838;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,130 +18,116 @@
|
||||
</div>
|
||||
|
||||
<div v-if="teamFormIsOpen" class="new-team-form">
|
||||
<label>
|
||||
<span>Team-Name:</span>
|
||||
<input type="text" v-model="newTeamName" placeholder="z.B. Herren 1, Damen 2">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Spielklasse:</span>
|
||||
<select v-model="newLeagueId">
|
||||
<option value="">Keine Spielklasse</option>
|
||||
<option v-for="league in filteredLeagues" :key="league.id" :value="league.id">
|
||||
{{ league.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="form-actions">
|
||||
<button @click="addNewTeam" :disabled="!newTeamName.trim()">
|
||||
{{ teamToEdit ? 'Ändern' : 'Anlegen & Bearbeiten' }}
|
||||
</button>
|
||||
<button @click="resetNewTeam" v-if="teamToEdit === null" class="cancel-action">
|
||||
Felder leeren
|
||||
</button>
|
||||
<button @click="resetToNewTeam" v-if="teamToEdit !== null" class="cancel-action">
|
||||
Neues Team anlegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Upload-Buttons nur beim Bearbeiten eines bestehenden Teams -->
|
||||
<div v-if="teamToEdit" class="upload-actions">
|
||||
<h4>Team-Dokumente hochladen</h4>
|
||||
<div class="upload-buttons">
|
||||
<button @click="uploadCodeList" class="upload-btn code-list-btn">
|
||||
📋 Code-Liste hochladen & parsen
|
||||
</button>
|
||||
<button @click="uploadPinList" class="upload-btn pin-list-btn">
|
||||
🔐 Pin-Liste hochladen & parsen
|
||||
</button>
|
||||
<div class="form-layout-two-columns">
|
||||
<!-- Linke Spalte: Grundeinstellungen -->
|
||||
<div class="basic-settings">
|
||||
<label>
|
||||
<span>Team-Name:</span>
|
||||
<input type="text" v-model="newTeamName" placeholder="z.B. Herren 1, Damen 2">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Spielklasse:</span>
|
||||
<select v-model="newLeagueId">
|
||||
<option value="">Keine Spielklasse</option>
|
||||
<option v-for="league in filteredLeagues" :key="league.id" :value="league.id">
|
||||
{{ league.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="form-actions">
|
||||
<button @click="addNewTeam" :disabled="!newTeamName.trim()">
|
||||
{{ teamToEdit ? 'Ändern' : 'Anlegen & Bearbeiten' }}
|
||||
</button>
|
||||
<button @click="resetNewTeam" v-if="teamToEdit === null" class="cancel-action">
|
||||
Felder leeren
|
||||
</button>
|
||||
<button @click="resetToNewTeam" v-if="teamToEdit !== null" class="cancel-action">
|
||||
Neues Team anlegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Spieler-Statistik -->
|
||||
<div v-if="teamToEdit && teamToEdit.leagueId" class="player-stats">
|
||||
<div class="stats-header">
|
||||
<span class="section-title">📊 Spieleinsätze</span>
|
||||
<button @click="refreshPlayerStats" :disabled="loadingStats" class="btn-sm">
|
||||
{{ loadingStats ? '⏳' : '🔄' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingStats" class="loading-stats">Lade Statistiken...</div>
|
||||
|
||||
<div v-else-if="playerStats.length === 0" class="no-stats">
|
||||
Keine Spieleinsätze erfasst.
|
||||
</div>
|
||||
|
||||
<table v-else class="stats-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Spieler</th>
|
||||
<th title="Gesamte Saison (ab 1. Juli)">Saison</th>
|
||||
<th :title="isSecondHalf ? 'Rückrunde (ab 1. Januar)' : 'Vorrunde (Juli - Dezember)'">
|
||||
{{ isSecondHalf ? 'Rückrunde' : 'Vorrunde' }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="stat in playerStats" :key="stat.memberId">
|
||||
<td class="player-name">{{ stat.firstName }} {{ stat.lastName }}</td>
|
||||
<td class="stat-value">{{ stat.totalSeason }}</td>
|
||||
<td class="stat-value">{{ isSecondHalf ? stat.totalSecondHalf : stat.totalFirstHalf }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MyTischtennis URL Konfiguration -->
|
||||
<div class="mytischtennis-config">
|
||||
<div class="mytischtennis-header">
|
||||
<h4>🏓 MyTischtennis Integration</h4>
|
||||
<div class="header-actions">
|
||||
<div v-if="teamToEdit" class="current-status">
|
||||
<span v-if="getMyTischtennisStatus(teamToEdit).complete" class="status-badge complete">
|
||||
✓ Vollständig konfiguriert
|
||||
</span>
|
||||
<span v-else-if="getMyTischtennisStatus(teamToEdit).partial" class="status-badge partial">
|
||||
⚠ {{ getMyTischtennisStatus(teamToEdit).missing }}
|
||||
</span>
|
||||
<span v-else class="status-badge missing">
|
||||
✗ Nicht konfiguriert
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="teamToEdit && getMyTischtennisStatus(teamToEdit).complete"
|
||||
@click="fetchTeamDataManually"
|
||||
:disabled="fetchingTeamData"
|
||||
class="fetch-btn"
|
||||
title="Spielergebnisse und Statistiken von myTischtennis abrufen"
|
||||
>
|
||||
{{ fetchingTeamData ? '⏳ Abrufen...' : '🔄 Daten jetzt abrufen' }}
|
||||
<!-- Rechte Spalte: Upload-Buttons, Dokumente, MyTischtennis -->
|
||||
<div v-if="teamToEdit" class="advanced-settings">
|
||||
<!-- Upload-Buttons -->
|
||||
<div class="upload-actions compact">
|
||||
<span class="section-title">📋 Dokumente</span>
|
||||
<div class="upload-buttons-compact">
|
||||
<button @click="uploadCodeList" class="btn-upload-sm">
|
||||
📋 Code-Liste
|
||||
</button>
|
||||
<button @click="uploadPinList" class="btn-upload-sm">
|
||||
🔐 Pin-Liste
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mytischtennis-url-input">
|
||||
<label>
|
||||
<span>MyTischtennis Team-URL einfügen und Enter drücken:</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="myTischtennisUrl"
|
||||
@keyup.enter="parseMyTischtennisUrl"
|
||||
@blur="parseMyTischtennisUrl"
|
||||
placeholder="https://www.mytischtennis.de/click-tt/..."
|
||||
class="url-input"
|
||||
:disabled="parsingUrl"
|
||||
|
||||
<!-- Upload-Bestätigung -->
|
||||
<div v-if="showLeagueSelection" class="upload-confirmation">
|
||||
<div class="selected-file-info">
|
||||
<strong>Ausgewählte Datei:</strong> {{ pendingUploadFile?.name }}
|
||||
<br>
|
||||
<strong>Typ:</strong> {{ pendingUploadType === 'code_list' ? 'Code-Liste' : 'Pin-Liste' }}
|
||||
<br>
|
||||
<strong>Team:</strong> {{ teamToEdit?.name }}
|
||||
<br>
|
||||
<strong>Liga:</strong> {{ getTeamLeagueName() }}
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
@click="confirmUploadAndParse"
|
||||
:disabled="parsingInProgress"
|
||||
class="confirm-parse-btn"
|
||||
>
|
||||
</label>
|
||||
<div v-if="parsingUrl" class="parsing-indicator">
|
||||
⏳ Konfiguriere automatisch...
|
||||
{{ parsingInProgress ? '⏳ Parse läuft...' : '🚀 Hochladen & Parsen' }}
|
||||
</button>
|
||||
<button @click="cancelUpload" class="cancel-parse-btn">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fehleranzeige -->
|
||||
<div v-if="myTischtennisError" class="error-message">
|
||||
⚠️ {{ myTischtennisError }}
|
||||
</div>
|
||||
|
||||
<!-- Erfolgsanzeige -->
|
||||
<div v-if="myTischtennisSuccess" class="success-message">
|
||||
✅ {{ myTischtennisSuccess }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload-Bestätigung -->
|
||||
<div v-if="showLeagueSelection" class="upload-confirmation">
|
||||
<div class="selected-file-info">
|
||||
<strong>Ausgewählte Datei:</strong> {{ pendingUploadFile?.name }}
|
||||
<br>
|
||||
<strong>Typ:</strong> {{ pendingUploadType === 'code_list' ? 'Code-Liste' : 'Pin-Liste' }}
|
||||
<br>
|
||||
<strong>Team:</strong> {{ teamToEdit?.name }}
|
||||
<br>
|
||||
<strong>Liga:</strong> {{ getTeamLeagueName() }}
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
@click="confirmUploadAndParse"
|
||||
:disabled="parsingInProgress"
|
||||
class="confirm-parse-btn"
|
||||
>
|
||||
{{ parsingInProgress ? '⏳ Parse läuft...' : '🚀 Hochladen & Parsen' }}
|
||||
</button>
|
||||
<button @click="cancelUpload" class="cancel-parse-btn">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PDF-Parsing Bereich für bereits hochgeladene Dokumente -->
|
||||
<div v-if="teamDocuments.length > 0" class="pdf-parsing-section">
|
||||
<h4>Bereits hochgeladene PDF-Dokumente parsen</h4>
|
||||
<table class="document-table">
|
||||
<!-- PDF-Parsing Bereich für bereits hochgeladene Dokumente -->
|
||||
<div v-if="teamDocuments.length > 0" class="pdf-parsing-section compact">
|
||||
<span class="section-title">📄 Hochgeladene Dokumente</span>
|
||||
<table class="document-table compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Dateiname</th>
|
||||
@@ -172,6 +158,42 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- MyTischtennis URL Konfiguration -->
|
||||
<div class="mytischtennis-config compact">
|
||||
<div class="mytischtennis-header-compact">
|
||||
<span class="section-title">🏓 MyTischtennis</span>
|
||||
<div class="status-inline">
|
||||
<span v-if="getMyTischtennisStatus(teamToEdit).complete" class="badge-sm complete">✓</span>
|
||||
<span v-else-if="getMyTischtennisStatus(teamToEdit).partial" class="badge-sm partial">⚠</span>
|
||||
<span v-else class="badge-sm missing">✗</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="getMyTischtennisStatus(teamToEdit).complete"
|
||||
@click="fetchTeamDataManually"
|
||||
:disabled="fetchingTeamData"
|
||||
class="btn-sm"
|
||||
>
|
||||
{{ fetchingTeamData ? '⏳' : '🔄' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="compact-input-row">
|
||||
<input
|
||||
type="text"
|
||||
v-model="myTischtennisUrl"
|
||||
@keyup.enter="parseMyTischtennisUrl"
|
||||
placeholder="MyTischtennis URL..."
|
||||
class="compact-url-input"
|
||||
:disabled="parsingUrl"
|
||||
>
|
||||
<span v-if="parsingUrl" class="inline-status">⏳</span>
|
||||
</div>
|
||||
|
||||
<div v-if="myTischtennisError" class="compact-message error">⚠️ {{ myTischtennisError }}</div>
|
||||
<div v-if="myTischtennisSuccess" class="compact-message success">✅ {{ myTischtennisSuccess }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -304,7 +326,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { ref, onMounted, computed, watch } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import SeasonSelector from '../components/SeasonSelector.vue';
|
||||
import apiClient from '../apiClient.js';
|
||||
@@ -367,6 +389,10 @@ export default {
|
||||
const myTischtennisError = ref('');
|
||||
const myTischtennisSuccess = ref('');
|
||||
|
||||
// Player Stats
|
||||
const playerStats = ref([]);
|
||||
const loadingStats = ref(false);
|
||||
|
||||
// Computed
|
||||
const selectedClub = computed(() => store.state.currentClub);
|
||||
const authToken = computed(() => store.state.token);
|
||||
@@ -374,6 +400,10 @@ export default {
|
||||
if (!selectedSeasonId.value) return [];
|
||||
return leagues.value.filter(league => league.seasonId == selectedSeasonId.value);
|
||||
});
|
||||
const isSecondHalf = computed(() => {
|
||||
const now = new Date();
|
||||
return now.getMonth() >= 0 && now.getMonth() <= 5; // Januar (0) bis Juni (5) = Rückrunde
|
||||
});
|
||||
|
||||
// Methods
|
||||
const toggleNewTeam = () => {
|
||||
@@ -897,6 +927,30 @@ export default {
|
||||
};
|
||||
};
|
||||
|
||||
const refreshPlayerStats = async () => {
|
||||
if (!teamToEdit.value || !teamToEdit.value.leagueId) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadingStats.value = true;
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(
|
||||
`/matches/leagues/${selectedClub.value}/stats/${teamToEdit.value.leagueId}`,
|
||||
{
|
||||
params: { seasonid: currentSeason.value?.id }
|
||||
}
|
||||
);
|
||||
|
||||
playerStats.value = response.data;
|
||||
} catch (error) {
|
||||
console.error('Error loading player stats:', error);
|
||||
await showInfo('Fehler', 'Statistiken konnten nicht geladen werden.', '', 'danger');
|
||||
} finally {
|
||||
loadingStats.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTeamDataManually = async () => {
|
||||
if (!teamToEdit.value || !teamToEdit.value.id) {
|
||||
return;
|
||||
@@ -956,6 +1010,15 @@ export default {
|
||||
}
|
||||
};
|
||||
|
||||
// Watch teamToEdit to load stats when a team is selected
|
||||
watch(teamToEdit, (newTeam) => {
|
||||
if (newTeam && newTeam.leagueId) {
|
||||
refreshPlayerStats();
|
||||
} else {
|
||||
playerStats.value = [];
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
infoDialog,
|
||||
confirmDialog,
|
||||
@@ -985,7 +1048,10 @@ export default {
|
||||
fetchingTeamData,
|
||||
myTischtennisError,
|
||||
myTischtennisSuccess,
|
||||
playerStats,
|
||||
loadingStats,
|
||||
filteredLeagues,
|
||||
isSecondHalf,
|
||||
toggleNewTeam,
|
||||
resetToNewTeam,
|
||||
resetNewTeam,
|
||||
@@ -1010,7 +1076,8 @@ export default {
|
||||
configureTeamFromUrl,
|
||||
clearParsedData,
|
||||
getMyTischtennisStatus,
|
||||
fetchTeamDataManually
|
||||
fetchTeamDataManually,
|
||||
refreshPlayerStats
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -1057,6 +1124,30 @@ export default {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-layout-two-columns {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.basic-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.advanced-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.form-layout-two-columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.new-team-form label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1237,6 +1328,45 @@ export default {
|
||||
}
|
||||
|
||||
/* Upload-Buttons Styles */
|
||||
.upload-actions.compact {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--background-light);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.upload-actions.compact .section-title {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.upload-buttons-compact {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.btn-upload-sm {
|
||||
padding: 4px 10px;
|
||||
font-size: 0.85rem;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-upload-sm:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
/* Legacy styles */
|
||||
.upload-actions {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
@@ -1388,6 +1518,45 @@ export default {
|
||||
}
|
||||
|
||||
/* PDF-Parsing Styles */
|
||||
.pdf-parsing-section.compact {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--background-light);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.pdf-parsing-section.compact .section-title {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.document-table.compact {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: white;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.document-table.compact th {
|
||||
background: var(--background-light);
|
||||
padding: 4px 8px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.document-table.compact td {
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Legacy styles */
|
||||
.pdf-parsing-section {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
@@ -1654,14 +1823,175 @@ export default {
|
||||
}
|
||||
|
||||
/* MyTischtennis URL Parser Styles */
|
||||
.mytischtennis-config {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem;
|
||||
.mytischtennis-config.compact {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--background-light);
|
||||
border-radius: var(--border-radius-medium);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.mytischtennis-header-compact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-inline {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.badge-sm {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-sm.complete {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.badge-sm.partial {
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.badge-sm.missing {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.85rem;
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-sm:hover:not(:disabled) {
|
||||
background-color: var(--primary-color-dark);
|
||||
}
|
||||
|
||||
.btn-sm:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.compact-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.compact-url-input {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.inline-status {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.compact-message {
|
||||
margin-top: 0.5rem;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.85rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.compact-message.error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.compact-message.success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
/* Player Stats */
|
||||
.player-stats {
|
||||
margin-top: 1rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--background-light);
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.stats-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.loading-stats, .no-stats {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.stats-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.stats-table thead th {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
padding: 4px 6px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.stats-table tbody tr {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.stats-table tbody tr:hover {
|
||||
background: var(--background-hover);
|
||||
}
|
||||
|
||||
.stats-table td {
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.stats-table .player-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stats-table .stat-value {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Legacy styles (can be removed if not used elsewhere) */
|
||||
.mytischtennis-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
Reference in New Issue
Block a user