From 563a7e8dde3c4905e4fb0197279b41b9ca011e9e Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 18 Mar 2026 15:34:10 +0100 Subject: [PATCH] feat(MemberTtrHistory): implement TTR history management and UI enhancements - Added new endpoints in the member controller for retrieving and refreshing TTR history. - Integrated TTR history functionality into the member service, allowing for seamless data retrieval and updates. - Updated the member model to include a field for TTR history player ID, enhancing data tracking. - Enhanced the MembersView to display TTR history with a dedicated dialog for better user interaction. - Improved the MyTischtennisClient to support fetching historical player IDs, enriching the data provided to users. - Refactored various components to ensure consistent styling and functionality across the application. --- backend/clients/myTischtennisClient.js | 72 +- backend/controllers/memberController.js | 26 + ...ischtennis_history_player_id_to_member.sql | 5 + backend/models/Member.js | 6 + backend/models/MemberTtrHistory.js | 79 +++ backend/models/index.js | 5 + backend/routes/memberRoutes.js | 4 + backend/routes/nuscoreApiRoutes.js | 46 -- backend/server.js | 3 +- backend/services/matchService.js | 9 +- backend/services/memberService.js | 469 +++++++++++- frontend/src/components/BaseDialog.vue | 19 + .../src/components/MatchReportApiDialog.vue | 103 ++- .../src/components/MemberTtrHistoryDialog.vue | 669 ++++++++++++++++++ frontend/src/views/MembersView.vue | 62 +- frontend/src/views/ScheduleView.vue | 2 - 16 files changed, 1471 insertions(+), 108 deletions(-) create mode 100644 backend/migrations/add_mytischtennis_history_player_id_to_member.sql create mode 100644 backend/models/MemberTtrHistory.js create mode 100644 frontend/src/components/MemberTtrHistoryDialog.vue diff --git a/backend/clients/myTischtennisClient.js b/backend/clients/myTischtennisClient.js index c17de5f6..d4c6cc7c 100644 --- a/backend/clients/myTischtennisClient.js +++ b/backend/clients/myTischtennisClient.js @@ -869,7 +869,8 @@ class MyTischtennisClient { * @param {string} fedNickname - Federation nickname (e.g., "HeTTV") * @returns {Promise} Rankings with player entries (all pages) */ - async getClubRankings(cookie, clubId, fedNickname, currentRanking = 'yes') { + async getClubRankings(cookie, clubId, fedNickname, currentRanking = 'yes', options = {}) { + const { includeHistoryPlayerIds = false } = options; const allEntries = []; let currentPage = 0; let hasMorePages = true; @@ -877,8 +878,6 @@ class MyTischtennisClient { while (hasMorePages) { const endpoint = `/rankings/andro-rangliste?all-players=on&clubnr=${clubId}&fednickname=${fedNickname}¤t-ranking=${currentRanking}&results-per-page=100&page=${currentPage}&_data=routes%2F%24`; - - const result = await this.authenticatedRequest(endpoint, cookie, { method: 'GET' }); @@ -917,15 +916,39 @@ class MyTischtennisClient { error: 'Keine entries in blockLoaderData gefunden' }; } + + let historyPlayerIdsByName = null; + if (includeHistoryPlayerIds) { + const htmlEndpoint = `/rankings/andro-rangliste?clubnr=${clubId}&fednickname=${fedNickname}&all-players=on&continent=all&country=all¤t-ranking=${currentRanking}&results-per-page=100&page=${currentPage + 1}`; + const htmlResult = await this.authenticatedRequest(htmlEndpoint, cookie, { + method: 'GET', + headers: { + Accept: 'text/html,application/xhtml+xml' + } + }); + historyPlayerIdsByName = htmlResult.success + ? this.extractHistoryPlayerIdsFromAndroRankingHtml(htmlResult.data) + : new Map(); + } + + const enrichedEntries = entries.map((entry) => { + const nameKey = this._buildRankingNameKey(entry?.firstname, entry?.lastname); + const historyPlayerId = historyPlayerIdsByName?.get(nameKey) || null; + return { + ...entry, + historyPlayerId, + myTischtennisHistoryPlayerId: historyPlayerId + }; + }); // Füge Entries hinzu - allEntries.push(...entries); + allEntries.push(...enrichedEntries); // Prüfe ob es weitere Seiten gibt // Wenn die aktuelle Seite weniger Einträge hat als das Limit, sind wir am Ende // Oder wenn wir alle erwarteten Einträge haben - if (entries.length === 0) { + if (enrichedEntries.length === 0) { hasMorePages = false; } else if (rankingData.numberOfPages && currentPage >= rankingData.numberOfPages - 1) { hasMorePages = false; @@ -946,6 +969,45 @@ class MyTischtennisClient { } }; } + + extractHistoryPlayerIdsFromAndroRankingHtml(html) { + const result = new Map(); + const source = typeof html === 'string' ? html : String(html || ''); + const anchorPattern = /href="\/community\/external-profile\?player-id=(P[A-Z0-9]+)"[^>]*>([^<]+)<\/a>/gi; + + let match = null; + while ((match = anchorPattern.exec(source)) !== null) { + const playerId = match[1]; + const fullName = this._decodeHtmlEntities(match[2] || ''); + const key = this._buildRankingFullNameKey(fullName); + if (key && playerId && !result.has(key)) { + result.set(key, playerId); + } + } + + return result; + } + + _buildRankingNameKey(firstname, lastname) { + return this._buildRankingFullNameKey(`${firstname || ''} ${lastname || ''}`); + } + + _buildRankingFullNameKey(name) { + return String(name || '') + .normalize('NFKC') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); + } + + _decodeHtmlEntities(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>'); + } } export default new MyTischtennisClient(); diff --git a/backend/controllers/memberController.js b/backend/controllers/memberController.js index 7e580e67..cddce0da 100644 --- a/backend/controllers/memberController.js +++ b/backend/controllers/memberController.js @@ -93,6 +93,30 @@ const updateRatingsFromMyTischtennis = async (req, res) => { } }; +const getMemberTtrHistory = async (req, res) => { + try { + const { clubId, memberId } = req.params; + const { authcode: userToken } = req.headers; + const result = await MemberService.getMemberTtrHistory(userToken, clubId, memberId); + res.status(result.status).json(result.response); + } catch (error) { + console.error('[getMemberTtrHistory] - Error:', error); + res.status(500).json({ success: false, error: 'TTR-Historie konnte nicht geladen werden.' }); + } +}; + +const refreshMemberTtrHistory = async (req, res) => { + try { + const { clubId, memberId } = req.params; + const { authcode: userToken } = req.headers; + const result = await MemberService.refreshMemberTtrHistory(userToken, clubId, memberId); + res.status(result.status).json(result.response); + } catch (error) { + console.error('[refreshMemberTtrHistory] - Error:', error); + res.status(500).json({ success: false, error: 'TTR-Historie konnte nicht aktualisiert werden.' }); + } +}; + const rotateMemberImage = async (req, res) => { try { const { clubId, memberId, imageId } = req.params; @@ -269,6 +293,8 @@ export { uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis, + getMemberTtrHistory, + refreshMemberTtrHistory, rotateMemberImage, transferMembers, quickUpdateTestMembership, diff --git a/backend/migrations/add_mytischtennis_history_player_id_to_member.sql b/backend/migrations/add_mytischtennis_history_player_id_to_member.sql new file mode 100644 index 00000000..9eef49ae --- /dev/null +++ b/backend/migrations/add_mytischtennis_history_player_id_to_member.sql @@ -0,0 +1,5 @@ +-- Add my_tischtennis_history_player_id column +ALTER TABLE member +ADD COLUMN my_tischtennis_history_player_id VARCHAR(255) NULL COMMENT 'TTR history player ID from myTischtennis (e.g. P14EC4981D)'; + +CREATE INDEX idx_member_my_tischtennis_history_player_id ON member(my_tischtennis_history_player_id); diff --git a/backend/models/Member.js b/backend/models/Member.js index e9616b7b..3207a00a 100644 --- a/backend/models/Member.js +++ b/backend/models/Member.js @@ -166,6 +166,12 @@ const Member = sequelize.define('Member', { allowNull: true, comment: 'Player ID from myTischtennis (e.g. NU2705037)', field: 'my_tischtennis_player_id' + }, + myTischtennisHistoryPlayerId: { + type: DataTypes.STRING, + allowNull: true, + comment: 'TTR history player ID from myTischtennis (e.g. P14EC4981D)', + field: 'my_tischtennis_history_player_id' } }, { underscored: true, diff --git a/backend/models/MemberTtrHistory.js b/backend/models/MemberTtrHistory.js new file mode 100644 index 00000000..af65b767 --- /dev/null +++ b/backend/models/MemberTtrHistory.js @@ -0,0 +1,79 @@ +import { DataTypes } from 'sequelize'; +import sequelize from '../database.js'; + +const MemberTtrHistory = sequelize.define('MemberTtrHistory', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false + }, + memberId: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'member_id' + }, + clubId: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'club_id' + }, + playerId: { + type: DataTypes.STRING, + allowNull: true, + field: 'player_id' + }, + sourceDate: { + type: DataTypes.DATEONLY, + allowNull: false, + field: 'source_date' + }, + ttr: { + type: DataTypes.INTEGER, + allowNull: true + }, + qttr: { + type: DataTypes.INTEGER, + allowNull: true + }, + label: { + type: DataTypes.STRING, + allowNull: true + }, + sourceType: { + type: DataTypes.STRING, + allowNull: true, + field: 'source_type' + }, + fetchedAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + field: 'fetched_at' + }, + rawPayload: { + type: DataTypes.JSON, + allowNull: true, + field: 'raw_payload' + } +}, { + underscored: true, + tableName: 'member_ttr_history', + timestamps: true, + indexes: [ + { + fields: ['member_id'] + }, + { + fields: ['club_id'] + }, + { + fields: ['player_id'] + }, + { + fields: ['source_date'] + } + ] +}); + +export default MemberTtrHistory; diff --git a/backend/models/index.js b/backend/models/index.js index 4025b220..fa72c51a 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -48,6 +48,7 @@ import ApiLog from './ApiLog.js'; import MemberTransferConfig from './MemberTransferConfig.js'; import MemberContact from './MemberContact.js'; import MemberImage from './MemberImage.js'; +import MemberTtrHistory from './MemberTtrHistory.js'; import TrainingGroup from './TrainingGroup.js'; import MemberTrainingGroup from './MemberTrainingGroup.js'; import ClubDisabledPresetGroup from './ClubDisabledPresetGroup.js'; @@ -90,6 +91,9 @@ DiaryNote.belongsTo(Member, { foreignKey: 'memberId' }); Member.hasMany(MemberNote, { as: 'memberNotes', foreignKey: 'memberId' }); MemberNote.belongsTo(Member, { foreignKey: 'memberId' }); +Member.hasMany(MemberTtrHistory, { as: 'ttrHistoryEntries', foreignKey: 'memberId' }); +MemberTtrHistory.belongsTo(Member, { as: 'member', foreignKey: 'memberId' }); + DiaryDate.hasMany(DiaryNote, { as: 'diaryNotes', foreignKey: 'diaryDateId' }); DiaryNote.belongsTo(DiaryDate, { foreignKey: 'diaryDateId' }); @@ -417,6 +421,7 @@ export { MemberTransferConfig, MemberContact, MemberImage, + MemberTtrHistory, TrainingGroup, MemberTrainingGroup, ClubDisabledPresetGroup, diff --git a/backend/routes/memberRoutes.js b/backend/routes/memberRoutes.js index b327af02..97cbdba5 100644 --- a/backend/routes/memberRoutes.js +++ b/backend/routes/memberRoutes.js @@ -5,6 +5,8 @@ import { uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis, + getMemberTtrHistory, + refreshMemberTtrHistory, rotateMemberImage, transferMembers, quickUpdateTestMembership, @@ -35,6 +37,8 @@ router.get('/gallery/:clubId', authenticate, authorize('members', 'read'), gener router.post('/set/:id', authenticate, authorize('members', 'write'), setClubMembers); router.get('/notapproved/:id', authenticate, authorize('members', 'read'), getWaitingApprovals); router.post('/update-ratings/:id', authenticate, authorize('mytischtennis', 'write'), updateRatingsFromMyTischtennis); +router.get('/ttr-history/:clubId/:memberId', authenticate, authorize('members', 'read'), getMemberTtrHistory); +router.post('/ttr-history/:clubId/:memberId/refresh', authenticate, authorize('members', 'read'), refreshMemberTtrHistory); router.post('/rotate-image/:clubId/:memberId/:imageId', authenticate, authorize('members', 'write'), rotateMemberImage); router.post('/transfer/:id', authenticate, authorize('members', 'write'), transferMembers); router.post('/quick-update-test-membership/:clubId/:memberId', authenticate, authorize('members', 'write'), quickUpdateTestMembership); diff --git a/backend/routes/nuscoreApiRoutes.js b/backend/routes/nuscoreApiRoutes.js index af9ad438..b3ab363d 100644 --- a/backend/routes/nuscoreApiRoutes.js +++ b/backend/routes/nuscoreApiRoutes.js @@ -188,18 +188,6 @@ router.put('/submit/:uuid', async (req, res) => { const { uuid } = req.params; const reportData = req.body; - console.log('[nuscore submit] request', { - uuid, - wo: reportData?.wo ?? null, - isCompleted: reportData?.isCompleted ?? null, - homePin: reportData?.homePin ?? null, - guestPin: reportData?.guestPin ?? null, - releaseSignatureHome: reportData?.signature?.releaseSignatureHome ?? null, - releaseSignatureGuest: reportData?.signature?.releaseSignatureGuest ?? null, - lineupSignatureHome: reportData?.signature?.lineupSignatureHome ?? null, - lineupSignatureGuest: reportData?.signature?.lineupSignatureGuest ?? null - }); - try { // Hole Cookies für diese UUID (falls vorhanden) // Versuche zuerst UUID, dann Code als Fallback @@ -241,17 +229,6 @@ router.put('/submit/:uuid', async (req, res) => { responseData = { message: responseText }; } - console.log('[nuscore submit] response', { - uuid, - httpStatus: response.status, - resultState: responseData?.resultState ?? null, - validationErrors: responseData?.validationErrors ?? [], - releaseSignatureHome: responseData?.object?.signature?.releaseSignatureHome ?? null, - releaseSignatureGuest: responseData?.object?.signature?.releaseSignatureGuest ?? null, - lineupSignatureHome: responseData?.object?.signature?.lineupSignatureHome ?? null, - lineupSignatureGuest: responseData?.object?.signature?.lineupSignatureGuest ?? null - }); - const resultState = responseData?.resultState; const validationErrors = Array.isArray(responseData?.validationErrors) ? responseData.validationErrors : []; @@ -320,18 +297,6 @@ router.put('/validate/:uuid', async (req, res) => { const { uuid } = req.params; const reportData = req.body; - console.log('[nuscore validate] request', { - uuid, - wo: reportData?.wo ?? null, - isCompleted: reportData?.isCompleted ?? null, - homePin: reportData?.homePin ?? null, - guestPin: reportData?.guestPin ?? null, - releaseSignatureHome: reportData?.signature?.releaseSignatureHome ?? null, - releaseSignatureGuest: reportData?.signature?.releaseSignatureGuest ?? null, - lineupSignatureHome: reportData?.signature?.lineupSignatureHome ?? null, - lineupSignatureGuest: reportData?.signature?.lineupSignatureGuest ?? null - }); - try { // Hole Cookies für diese UUID (falls vorhanden) // Versuche zuerst UUID, dann Code als Fallback @@ -373,17 +338,6 @@ router.put('/validate/:uuid', async (req, res) => { responseData = { message: responseText }; } - console.log('[nuscore validate] response', { - uuid, - httpStatus: response.status, - resultState: responseData?.resultState ?? null, - validationErrors: responseData?.validationErrors ?? [], - releaseSignatureHome: responseData?.object?.signature?.releaseSignatureHome ?? null, - releaseSignatureGuest: responseData?.object?.signature?.releaseSignatureGuest ?? null, - lineupSignatureHome: responseData?.object?.signature?.lineupSignatureHome ?? null, - lineupSignatureGuest: responseData?.object?.signature?.lineupSignatureGuest ?? null - }); - // Speichere neue Cookies falls vorhanden const newCookies = extractCookies(response.headers.raw()['set-cookie']); if (Object.keys(newCookies).length > 0) { diff --git a/backend/server.js b/backend/server.js index 770b6ca8..90960246 100644 --- a/backend/server.js +++ b/backend/server.js @@ -13,7 +13,7 @@ import { DiaryNote, DiaryTag, MemberDiaryTag, DiaryDateTag, DiaryMemberNote, DiaryMemberTag, PredefinedActivity, PredefinedActivityImage, DiaryDateActivity, DiaryMemberActivity, Match, League, Team, ClubTeam, TeamDocument, Group, GroupActivity, Tournament, TournamentGroup, TournamentMatch, TournamentResult, - TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, ClickTtAccount, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact + TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, ClickTtAccount, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact, MemberTtrHistory } from './models/index.js'; import authRoutes from './routes/authRoutes.js'; import clubRoutes from './routes/clubRoutes.js'; @@ -327,6 +327,7 @@ app.use((err, req, res, next) => { await safeSync(ApiLog); await safeSync(MemberTransferConfig); await safeSync(MemberContact); + await safeSync(MemberTtrHistory); await safeSync(ClubTeam); await safeSync(TeamDocument); diff --git a/backend/services/matchService.js b/backend/services/matchService.js index c560b8a4..5224bfb9 100644 --- a/backend/services/matchService.js +++ b/backend/services/matchService.js @@ -65,15 +65,14 @@ class MatchService { } else { seasonStartYear = currentYear - 1; } - const seasonEndYear = seasonStartYear + 1; + const seasonEndYear = seasonStartYear + 1; return `${seasonStartYear}/${seasonEndYear}`; } - async importCSV(userToken, clubId, filePath) { - await checkAccess(userToken, clubId); + async importCSV(userToken, clubId, filePath) { + await checkAccess(userToken, clubId); let seasonString = ''; - const matches = []; - try { + try { const fileStream = fs.createReadStream(filePath) .pipe(iconv.decodeStream('utf8')) .pipe(csv({ separator: ';' })); diff --git a/backend/services/memberService.js b/backend/services/memberService.js index f18c4066..0beb5a5a 100644 --- a/backend/services/memberService.js +++ b/backend/services/memberService.js @@ -3,6 +3,7 @@ import Club from "../models/Club.js"; import { checkAccess, getUserByToken, hasUserClubAccess } from "../utils/userUtils.js"; import Member from "../models/Member.js"; import MemberImage from "../models/MemberImage.js"; +import MemberTtrHistory from "../models/MemberTtrHistory.js"; import Participant from "../models/Participant.js"; import DiaryDate from "../models/DiaryDates.js"; import path from 'path'; @@ -419,7 +420,17 @@ class MemberService { }; } - // 2. Ranglisten vom Verein abrufen (Logging hinzufügen) + // 2. Mitglieder laden und prüfen, ob wir zusätzlich die TTR-History-ID auflösen müssen + const members = await Member.findAll({ where: { clubId } }); + const shouldResolveHistoryPlayerIds = members.some((member) => + !member.myTischtennisHistoryPlayerId && ( + member.myTischtennisPlayerId || + member.ttr != null || + member.qttr != null + ) + ); + + // 3. Ranglisten vom Verein abrufen // TTR (aktuell) try { await (await import('./apiLogService.js')).default.logRequest({ @@ -440,7 +451,8 @@ class MemberService { session.cookie, effectiveClubId, effectiveFedNickname, - 'yes' + 'yes', + { includeHistoryPlayerIds: shouldResolveHistoryPlayerIds } ); try { await (await import('./apiLogService.js')).default.logRequest({ @@ -477,7 +489,8 @@ class MemberService { session.cookie, effectiveClubId, effectiveFedNickname, - 'no' + 'no', + { includeHistoryPlayerIds: shouldResolveHistoryPlayerIds } ); let qttrWarning = null; try { @@ -515,9 +528,6 @@ class MemberService { qttrWarning = rankingsQuarter.error || 'QTTR Abruf fehlgeschlagen'; } - // 3. Alle Mitglieder des Clubs laden - const members = await Member.findAll({ where: { clubId } }); - let updated = 0; const errors = []; if (qttrWarning) { @@ -561,6 +571,7 @@ class MemberService { try { const oldTtr = member.ttr; const oldQttr = member.qttr; + const historyPlayerId = this._extractTtrHistoryPlayerId(rankingEntry) || this._extractTtrHistoryPlayerId(rankingQuarterEntry); if (rankingEntry && typeof rankingEntry.fedRank === 'number') { member.ttr = rankingEntry.fedRank; if (member.ttr !== oldTtr) updatedTtr++; @@ -576,6 +587,9 @@ class MemberService { if (member.qttr !== oldQttr) updatedQttr++; } } + if (historyPlayerId && member.myTischtennisHistoryPlayerId !== historyPlayerId) { + member.myTischtennisHistoryPlayerId = historyPlayerId; + } await member.save(); updated++; matched.push({ @@ -583,7 +597,8 @@ class MemberService { oldTtr: oldTtr, newTtr: member.ttr, oldQttr: oldQttr, - newQttr: member.qttr + newQttr: member.qttr, + historyPlayerId: member.myTischtennisHistoryPlayerId || null }); } catch (error) { console.error(`[updateRatingsFromMyTischtennis] - Error updating ${firstName} ${lastName}:`, error); @@ -654,6 +669,444 @@ class MemberService { return this._updateRatingsInternal(userId, clubId); } + async getMemberTtrHistory(userToken, clubId, memberId) { + await checkAccess(userToken, clubId); + + const member = await Member.findOne({ + where: { id: memberId, clubId } + }); + + if (!member) { + return { + status: 404, + response: { success: false, error: 'Mitglied nicht gefunden.' } + }; + } + + const history = await MemberTtrHistory.findAll({ + where: { memberId: member.id, clubId }, + order: [['sourceDate', 'DESC'], ['createdAt', 'DESC']] + }); + + const entries = history.map(entry => ({ + id: entry.id, + sourceDate: entry.sourceDate, + ttr: entry.ttr, + qttr: entry.qttr, + label: entry.label, + sourceType: entry.sourceType, + fetchedAt: entry.fetchedAt + })); + + const latestFetchedAt = history.reduce((latest, entry) => { + if (!entry.fetchedAt) { + return latest; + } + const fetchedAt = new Date(entry.fetchedAt); + if (!latest || fetchedAt > latest) { + return fetchedAt; + } + return latest; + }, null); + const resolvedHistoryPlayerId = member.myTischtennisHistoryPlayerId || history.find(entry => entry.playerId)?.playerId || null; + + return { + status: 200, + response: { + success: true, + member: { + id: member.id, + firstName: member.firstName, + lastName: member.lastName, + ttr: member.ttr, + qttr: member.qttr, + myTischtennisPlayerId: member.myTischtennisPlayerId || null, + myTischtennisHistoryPlayerId: resolvedHistoryPlayerId + }, + history: entries, + meta: { + count: entries.length, + lastFetchedAt: latestFetchedAt ? latestFetchedAt.toISOString() : null, + refreshPolicy: this._buildTtrHistoryRefreshPolicy(latestFetchedAt) + } + } + }; + } + + async refreshMemberTtrHistory(userToken, clubId, memberId) { + await checkAccess(userToken, clubId); + + const user = await getUserByToken(userToken); + + const member = await Member.findOne({ + where: { id: memberId, clubId } + }); + + if (!member) { + return { + status: 404, + response: { success: false, error: 'Mitglied nicht gefunden.' } + }; + } + + const latestHistoryEntry = await MemberTtrHistory.findOne({ + where: { memberId: member.id, clubId }, + order: [['fetchedAt', 'DESC'], ['createdAt', 'DESC']] + }); + + const historyPlayerId = member.myTischtennisHistoryPlayerId || latestHistoryEntry?.playerId || null; + const refreshPolicy = this._buildTtrHistoryRefreshPolicy(latestHistoryEntry?.fetchedAt || null); + + if (!refreshPolicy.canRefresh) { + return { + status: 200, + response: { + success: true, + skipped: true, + message: refreshPolicy.message, + member: { + id: member.id, + myTischtennisPlayerId: member.myTischtennisPlayerId || null, + myTischtennisHistoryPlayerId: historyPlayerId + }, + meta: { + lastFetchedAt: latestHistoryEntry?.fetchedAt ? new Date(latestHistoryEntry.fetchedAt).toISOString() : null, + refreshPolicy + } + } + }; + } + + if (!historyPlayerId) { + return { + status: 409, + response: { + success: false, + error: 'Für dieses Mitglied ist noch keine myTischtennis-TTR-History-ID vorhanden. Bitte zuerst die myTischtennis-Ranglisten aktualisieren.' + } + }; + } + + const myTischtennisService = (await import('./myTischtennisService.js')).default; + const myTischtennisClient = (await import('../clients/myTischtennisClient.js')).default; + + let session; + try { + session = await myTischtennisService.getSession(user.id); + } catch (sessionError) { + try { + await myTischtennisService.verifyLogin(user.id); + session = await myTischtennisService.getSession(user.id); + } catch (loginError) { + return { + status: 401, + response: { + success: false, + needsReauth: true, + error: 'myTischtennis-Session abgelaufen. Bitte einmal neu einloggen.' + } + }; + } + } + + const rootEndpoint = `/rankings/ttr-historie?player-id=${encodeURIComponent(historyPlayerId)}&_data=root`; + const historyEndpoint = `/rankings/ttr-historie?player-id=${encodeURIComponent(historyPlayerId)}&show=everything&_data=routes%2F%24`; + + const [rootResult, historyResult] = await Promise.all([ + myTischtennisClient.authenticatedRequest(rootEndpoint, session.cookie, { method: 'GET' }), + myTischtennisClient.authenticatedRequest(historyEndpoint, session.cookie, { method: 'GET' }) + ]); + + if (!rootResult.success) { + return { + status: 502, + response: { + success: false, + error: rootResult.error || 'myTischtennis-Root-Endpunkt konnte nicht geladen werden.' + } + }; + } + + if (!historyResult.success) { + return { + status: 502, + response: { + success: false, + error: historyResult.error || 'myTischtennis-TTR-Historie konnte nicht geladen werden.' + } + }; + } + + const parsedHistory = this._parseMyTischtennisHistoryResponse(historyResult.data); + if (!parsedHistory.success) { + return { + status: 502, + response: { + success: false, + error: parsedHistory.error || 'Die myTischtennis-TTR-Historie konnte nicht verarbeitet werden.' + } + }; + } + + const fetchedAt = new Date(); + const historyEntries = this._mapTtrHistoryEventsToRows({ + clubId, + memberId: member.id, + playerId: historyPlayerId, + fetchedAt, + historyData: parsedHistory.historyData + }); + + await MemberTtrHistory.destroy({ + where: { memberId: member.id, clubId } + }); + + if (historyEntries.length > 0) { + await MemberTtrHistory.bulkCreate(historyEntries); + } + + if (member.myTischtennisHistoryPlayerId !== historyPlayerId) { + member.myTischtennisHistoryPlayerId = historyPlayerId; + await member.save(); + } + + return { + status: 200, + response: { + success: true, + message: `${historyEntries.length} TTR-Historien-Einträge aktualisiert.`, + member: { + id: member.id, + myTischtennisPlayerId: member.myTischtennisPlayerId || null, + myTischtennisHistoryPlayerId: historyPlayerId + }, + meta: { + count: historyEntries.length, + lastFetchedAt: fetchedAt.toISOString(), + refreshPolicy: this._buildTtrHistoryRefreshPolicy(fetchedAt) + } + } + }; + } + + _extractTtrHistoryPlayerId(entry) { + if (!entry || typeof entry !== 'object') { + return null; + } + + const directCandidates = [ + entry.myTischtennisHistoryPlayerId, + entry.historyPlayerId, + entry.history_player_id, + entry.ttrHistoryPlayerId, + entry.ttr_history_player_id + ]; + + for (const candidate of directCandidates) { + if (typeof candidate === 'string' && /^P[A-Z0-9]+$/i.test(candidate.trim())) { + return candidate.trim(); + } + } + + const serialized = JSON.stringify(entry); + const urlMatch = serialized.match(/player-id=([P][A-Z0-9]+)/i); + if (urlMatch?.[1]) { + return urlMatch[1]; + } + + const fieldMatch = serialized.match(/"(?:historyPlayerId|history_player_id|ttrHistoryPlayerId|ttr_history_player_id|playerId|player_id)"\s*:\s*"(P[A-Z0-9]+)"/i); + return fieldMatch?.[1] || null; + } + + _getBerlinDateParts(value = new Date()) { + const formatter = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Europe/Berlin', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23' + }); + + const parts = formatter.formatToParts(value).reduce((acc, part) => { + if (part.type !== 'literal') { + acc[part.type] = part.value; + } + return acc; + }, {}); + + return { + year: parts.year, + month: parts.month, + day: parts.day, + hour: Number(parts.hour), + minute: Number(parts.minute), + second: Number(parts.second), + dateKey: `${parts.year}-${parts.month}-${parts.day}` + }; + } + + _buildTtrHistoryRefreshPolicy(lastFetchedAt) { + const now = new Date(); + const nowParts = this._getBerlinDateParts(now); + const hour = nowParts.hour; + + if (hour < 7) { + return { + canRefresh: false, + reason: 'before_7am', + message: 'Die TTR-Historie wird nur einmal täglich und erst nach 07:00 Uhr aktualisiert.', + nextRefreshDate: nowParts.dateKey + }; + } + + if (!lastFetchedAt) { + return { + canRefresh: true, + reason: 'no_cache', + message: 'Die TTR-Historie kann jetzt aktualisiert werden.', + nextRefreshDate: nowParts.dateKey + }; + } + + const fetchedParts = this._getBerlinDateParts(new Date(lastFetchedAt)); + if (fetchedParts.dateKey === nowParts.dateKey) { + return { + canRefresh: false, + reason: 'already_refreshed_today', + message: 'Die TTR-Historie wurde heute bereits aktualisiert.', + nextRefreshDate: nowParts.dateKey + }; + } + + return { + canRefresh: true, + reason: 'stale_cache', + message: 'Die TTR-Historie kann jetzt aktualisiert werden.', + nextRefreshDate: nowParts.dateKey + }; + } + + _parseMyTischtennisHistoryResponse(rawPayload) { + try { + const parsed = this._splitDeferredJsonPayload(rawPayload); + const deferredData = Object.values(parsed.deferred || {}).find((value) => value && Array.isArray(value.event)); + + if (!deferredData) { + return { + success: false, + error: 'Keine TTR-Historien-Daten in der myTischtennis-Antwort gefunden.' + }; + } + + return { + success: true, + root: parsed.root, + historyData: deferredData + }; + } catch (error) { + return { + success: false, + error: error.message || 'TTR-Historien-Antwort konnte nicht geparst werden.' + }; + } + } + + _splitDeferredJsonPayload(rawPayload) { + if (rawPayload && typeof rawPayload === 'object' && !Array.isArray(rawPayload)) { + return { root: rawPayload, deferred: {} }; + } + + const text = typeof rawPayload === 'string' ? rawPayload : String(rawPayload || ''); + const lines = text.split(/\r?\n/); + let root = null; + const deferred = {}; + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line) { + continue; + } + + if (line.startsWith('data:')) { + const payload = line.slice(5).trim(); + if (!payload) { + continue; + } + const parsed = JSON.parse(payload); + Object.assign(deferred, parsed); + continue; + } + + if (!root && line.startsWith('{')) { + root = JSON.parse(line); + } + } + + return { root, deferred }; + } + + _mapTtrHistoryEventsToRows({ clubId, memberId, playerId, fetchedAt, historyData }) { + const events = Array.isArray(historyData?.event) ? historyData.event : []; + const rows = []; + const seenKeys = new Set(); + + for (const event of events) { + const sourceDate = this._normalizeTtrHistoryDate(event?.event_date_time || event?.formattedEventDate); + if (!sourceDate) { + continue; + } + + const key = `${event?.event_id || 'event'}:${sourceDate}:${event?.ttr_after ?? ''}`; + if (seenKeys.has(key)) { + continue; + } + seenKeys.add(key); + + rows.push({ + memberId, + clubId, + playerId, + sourceDate, + ttr: Number.isFinite(Number(event?.ttr_after)) ? Number(event.ttr_after) : null, + qttr: null, + label: event?.event_name || null, + sourceType: event?.type || null, + fetchedAt, + rawPayload: event + }); + } + + return rows.sort((a, b) => String(b.sourceDate).localeCompare(String(a.sourceDate))); + } + + _normalizeTtrHistoryDate(value) { + if (!value) { + return null; + } + + if (typeof value === 'string') { + const isoMatch = value.match(/^(\d{4}-\d{2}-\d{2})/); + if (isoMatch?.[1]) { + return isoMatch[1]; + } + + const germanMatch = value.match(/^(\d{2})\.(\d{2})\.(\d{4})$/); + if (germanMatch) { + return `${germanMatch[3]}-${germanMatch[2]}-${germanMatch[1]}`; + } + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return null; + } + + return date.toISOString().slice(0, 10); + } + async rotateMemberImage(userToken, clubId, memberId, imageId, direction) { try { await checkAccess(userToken, clubId); @@ -1335,4 +1788,4 @@ class MemberService { } } -export default new MemberService(); \ No newline at end of file +export default new MemberService(); diff --git a/frontend/src/components/BaseDialog.vue b/frontend/src/components/BaseDialog.vue index 7669eb27..67539462 100644 --- a/frontend/src/components/BaseDialog.vue +++ b/frontend/src/components/BaseDialog.vue @@ -78,6 +78,16 @@ export default { default: 'medium', validator: (value) => ['small', 'medium', 'large', 'fullscreen'].includes(value) }, + + width: { + type: [String, Number], + default: null + }, + + maxWidth: { + type: [String, Number], + default: null + }, // Position für nicht-modale Dialoge position: { @@ -136,6 +146,14 @@ export default { const style = { zIndex: this.zIndex }; + + if (this.width !== null) { + style.width = typeof this.width === 'number' ? `${this.width}px` : this.width; + } + + if (this.maxWidth !== null) { + style.maxWidth = typeof this.maxWidth === 'number' ? `${this.maxWidth}px` : this.maxWidth; + } if (!this.isModal) { style.left = `${this.localPosition.x}px`; @@ -311,6 +329,7 @@ export default { font-size: 1rem; font-weight: 600; flex: 1; + color: var(--text-on-primary); } /* Controls */ diff --git a/frontend/src/components/MatchReportApiDialog.vue b/frontend/src/components/MatchReportApiDialog.vue index ed7bc452..fd64462f 100644 --- a/frontend/src/components/MatchReportApiDialog.vue +++ b/frontend/src/components/MatchReportApiDialog.vue @@ -731,6 +731,8 @@ export default { home: null, guest: null }, + initialCompletionState: false, + submitSucceeded: false, isHomeLineupCertified: false, isGuestLineupCertified: false, isGreetingCompleted: false, @@ -885,13 +887,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr // Prüfe ob das Match bereits abgeschlossen ist isMatchCompleted() { - if (typeof this.meetingDetails?.isCompleted === 'boolean') { - return this.meetingDetails.isCompleted; - } - if (typeof this.meetingData?.isCompleted === 'boolean') { - return this.meetingData.isCompleted; - } - return this.match && this.match.isCompleted === true; + return this.submitSucceeded || this.initialCompletionState === true; }, isSubmitDisabled() { if (this.isMatchCompleted) { @@ -975,18 +971,38 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr } }, methods: { - logSignatureState(label, payload) { - const signature = payload?.signature || null; - console.log(`[match-report] ${label}`, { - wo: payload?.wo ?? null, - isCompleted: payload?.isCompleted ?? null, - homePin: payload?.homePin ?? null, - guestPin: payload?.guestPin ?? null, - releaseSignatureHome: signature?.releaseSignatureHome ?? null, - releaseSignatureGuest: signature?.releaseSignatureGuest ?? null, - lineupSignatureHome: signature?.lineupSignatureHome ?? null, - lineupSignatureGuest: signature?.lineupSignatureGuest ?? null - }); + getFriendlySubmitErrorMessage(message) { + const normalized = (message || '').toString(); + + if (normalized.includes('Meeting wurde bereits freigegeben')) { + return 'Der Spielbericht ist in nuScore bereits freigegeben.\n\nDie dort gespeicherten Daten sind offenbar nicht vollständig und können aus dieser Ansicht nicht mehr zuverlässig überschrieben werden.\n\nBitte den Spielbericht in nuScore bzw. durch eine Person mit den nötigen Rechten prüfen und dort korrigieren lassen.'; + } + + return normalized || 'Fehler beim Absenden des Spielberichts.'; + }, + + hasReleaseSignatureForCompletion(source) { + if (!source) { + return false; + } + + const signature = source.signature || {}; + const fallbackWo = this.teamNotAppeared === 'home' + ? 'A' + : (this.teamNotAppeared === 'guest' ? 'B' : null); + const wo = source.wo ?? fallbackWo; + + const hasHomeRelease = typeof signature.releaseSignatureHome === 'string' && signature.releaseSignatureHome.trim() !== ''; + const hasGuestRelease = typeof signature.releaseSignatureGuest === 'string' && signature.releaseSignatureGuest.trim() !== ''; + + if (wo === 'A') { + return hasGuestRelease; + } + if (wo === 'B') { + return hasHomeRelease; + } + + return hasHomeRelease && hasGuestRelease; }, // Effektive Spieleranzahl (berücksichtigt Braunschweiger-Regel) @@ -1580,10 +1596,8 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr // Aktualisiere die Match-Daten mit unseren Eingaben console.log('🔄 Aktualisiere Match-Daten...'); - this.updateMatchData(matchData); + this.updateMatchData(matchData, { finalizeReport: true }); console.log('✅ Match-Daten aktualisiert'); - this.logSignatureState('payload before validate', matchData); - // Für WebSocket-Broadcast: clubId und gameCode mitsenden const clubId = this.$store?.getters?.currentClub; if (clubId) matchData.clubId = String(clubId); @@ -1597,10 +1611,13 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr throw new Error('nuLigaMeetingUuid nicht gefunden'); } - const validationResult = await this.validateReport(matchData); - if (validationResult?.resultState === 'VALIDATION_ERROR') { - const validationMessage = Array.isArray(validationResult.validationErrors) && validationResult.validationErrors.length > 0 - ? validationResult.validationErrors.join('\n') + const validationPayload = JSON.parse(JSON.stringify(matchData)); + validationPayload.isCompleted = false; + const validationResult = await this.validateReport(validationPayload); + const validationErrors = Array.isArray(validationResult?.validationErrors) ? validationResult.validationErrors : []; + if (validationResult?.resultState === 'VALIDATION_ERROR' || validationErrors.length > 0) { + const validationMessage = validationErrors.length > 0 + ? validationErrors.join('\n') : 'Validierung fehlgeschlagen'; throw new Error(validationMessage); } @@ -1608,8 +1625,16 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr const submitPayload = validationResult?.object ? JSON.parse(JSON.stringify(validationResult.object)) : matchData; - this.logSignatureState('payload before submit', submitPayload); - + submitPayload.isCompleted = true; + if (!submitPayload.signature || typeof submitPayload.signature !== 'object') { + submitPayload.signature = {}; + } + if (!isHomeNotAppeared && this.finalHomePin && this.finalHomePin.trim() !== '') { + submitPayload.homePin = this.finalHomePin.trim(); + } + if (!isGuestNotAppeared && this.finalGuestPin && this.finalGuestPin.trim() !== '') { + submitPayload.guestPin = this.finalGuestPin.trim(); + } const response = await fetch(`${backendBaseUrl}/api/nuscore/submit/${uuid}`, { method: 'PUT', headers: { @@ -1619,7 +1644,6 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr }); const result = await response.json(); - console.log('[match-report] submit response', result); if (!response.ok) { const validationMessage = Array.isArray(result?.details?.validationErrors) && result.details.validationErrors.length > 0 @@ -1629,6 +1653,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr } console.log('✅ Spielbericht erfolgreich abgesendet:', result); + this.submitSucceeded = true; alert('✅ Spielbericht erfolgreich abgesendet!'); // Dialog schließen @@ -1637,7 +1662,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr } catch (error) { console.error('❌ Fehler beim Absenden:', error); console.error('❌ Fehler-Stack:', error.stack); - alert(`Fehler beim Absenden des Spielberichts: ${error.message}`); + alert(this.getFriendlySubmitErrorMessage(error.message)); } }, @@ -1653,7 +1678,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr : JSON.parse(JSON.stringify(this.match)); if (!matchDataOverride) { - this.updateMatchData(matchData); + this.updateMatchData(matchData, { finalizeReport: false }); } const uuid = this.meetingData.nuLigaMeetingUuid; @@ -1669,7 +1694,6 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr }); const result = await response.json(); - console.log('[match-report] validate response', result); if (result?.object) { this.meetingDetails = JSON.parse(JSON.stringify(result.object)); @@ -1705,9 +1729,10 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr } }, - updateMatchData(matchData) { + updateMatchData(matchData, options = {}) { try { console.log('🔄 updateMatchData: Verwende kompletten Original-Meeting-Daten...'); + const { finalizeReport = false } = options; // Verwende ausschließlich die Meeting-Details vom /meetingdetails/:uuid Endpoint if (!this.meetingDetails) { @@ -1748,6 +1773,9 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr matchData.guestPin = this.finalGuestPin.trim(); } + // Zwischenstände dürfen nie als bereits freigegeben an nuscore zurückgeschickt werden. + matchData.isCompleted = finalizeReport; + // Wenn eine Mannschaft nicht angetreten ist, nur wo-Flag und PIN der angetretenen Mannschaft setzen if (this.teamNotAppeared !== null) { console.log('⚠️ Mannschaft nicht angetreten - nur wo-Flag und PIN der angetretenen Mannschaft werden gesetzt'); @@ -1782,8 +1810,8 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr // 5. PINs - Original-Hashes beibehalten (werden vom Backend beim Senden aktualisiert) // matchData.homePin und matchData.guestPin bleiben die ursprünglichen Hashes aus baseData - // 6. Match-Status auf abgeschlossen setzen - matchData.isCompleted = true; + // 6. Match-Status nur beim finalen Submit auf abgeschlossen setzen + matchData.isCompleted = finalizeReport; // 7. Gesamtstatistik berechnen und eintragen const overallScore = this.getOverallMatchScore(); @@ -2061,6 +2089,11 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr } } + this.initialCompletionState = Boolean( + ((typeof this.meetingDetails?.isCompleted === 'boolean' && this.meetingDetails.isCompleted) && this.hasReleaseSignatureForCompletion(this.meetingDetails)) || + ((typeof this.meetingData?.isCompleted === 'boolean' && this.meetingData.isCompleted) && this.hasReleaseSignatureForCompletion(this.meetingData)) + ); + // PINs automatisch laden this.loadPinsAutomatically(); diff --git a/frontend/src/components/MemberTtrHistoryDialog.vue b/frontend/src/components/MemberTtrHistoryDialog.vue new file mode 100644 index 00000000..11517dd7 --- /dev/null +++ b/frontend/src/components/MemberTtrHistoryDialog.vue @@ -0,0 +1,669 @@ + + + + + diff --git a/frontend/src/views/MembersView.vue b/frontend/src/views/MembersView.vue index 9e459b83..760eed14 100644 --- a/frontend/src/views/MembersView.vue +++ b/frontend/src/views/MembersView.vue @@ -326,12 +326,20 @@ - - {{ member.ttr }} - / - {{ member.qttr }} - - - +
@@ -467,6 +475,14 @@ @success="handleTransferSuccess" @error="handleTransferError" /> + +
@@ -483,6 +499,7 @@ import BaseDialog from '../components/BaseDialog.vue'; import MemberNotesDialog from '../components/MemberNotesDialog.vue'; import MemberActivitiesDialog from '../components/MemberActivitiesDialog.vue'; import MemberTransferDialog from '../components/MemberTransferDialog.vue'; +import MemberTtrHistoryDialog from '../components/MemberTtrHistoryDialog.vue'; import MembersOverviewSection from '../components/members/MembersOverviewSection.vue'; import { debounce } from '../utils/debounce.js'; import { buildInfoConfig, buildConfirmConfig, safeErrorMessage, sanitizeText } from '../utils/dialogUtils.js'; @@ -496,6 +513,7 @@ export default { MemberNotesDialog, MemberActivitiesDialog, MemberTransferDialog, + MemberTtrHistoryDialog, MembersOverviewSection }, computed: { @@ -750,7 +768,9 @@ export default { isUpdatingRatings: false, showMemberInfo: false, showActivitiesModal: false, + showMemberTtrHistoryDialog: false, selectedMemberForActivities: null, + selectedMemberForTtrHistory: null, memberTrainingGroups: [], trainingGroups: [], selectedGroupToAdd: '', @@ -2072,6 +2092,17 @@ export default { this.editMember(member); } }, + openTtrHistoryDialog(member) { + if (!member) { + return; + } + this.selectedMemberForTtrHistory = member; + this.showMemberTtrHistoryDialog = true; + }, + closeTtrHistoryDialog() { + this.showMemberTtrHistoryDialog = false; + this.selectedMemberForTtrHistory = null; + }, async updateRatingsFromMyTischtennis() { this.isUpdatingRatings = true; try { @@ -2364,6 +2395,25 @@ table td { font-size: 0.95em; } +.rating-button { + border: none; + background: transparent; + padding: 0; + font: inherit; + cursor: pointer; + color: inherit; +} + +.rating-button:disabled { + cursor: default; +} + +.rating-button:not(:disabled):hover .ttr-value, +.rating-button:not(:disabled):hover .qttr-value, +.rating-button:not(:disabled):hover .no-rating { + text-decoration: underline; +} + .ttr-value { font-weight: 600; color: #1a73e8; diff --git a/frontend/src/views/ScheduleView.vue b/frontend/src/views/ScheduleView.vue index ce38c001..123bb765 100644 --- a/frontend/src/views/ScheduleView.vue +++ b/frontend/src/views/ScheduleView.vue @@ -319,7 +319,6 @@ import apiClient from '../apiClient.js'; import PDFGenerator from '../components/PDFGenerator.js'; import { getSafeErrorMessage } from '../utils/errorMessages.js'; import SeasonSelector from '../components/SeasonSelector.vue'; -import MatchReportApiDialog from '../components/MatchReportApiDialog.vue'; import InfoDialog from '../components/InfoDialog.vue'; import ConfirmDialog from '../components/ConfirmDialog.vue'; @@ -338,7 +337,6 @@ export default { name: 'ScheduleView', components: { SeasonSelector, - MatchReportApiDialog, InfoDialog, ConfirmDialog, BaseDialog,