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:
Torsten Schulz (local)
2025-10-16 21:09:13 +02:00
parent e0d56ddadd
commit ea3cca563b
8 changed files with 919 additions and 125 deletions

View File

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

View 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`;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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