diff --git a/backend/controllers/apiLogController.js b/backend/controllers/apiLogController.js index 02a021f..af81323 100644 --- a/backend/controllers/apiLogController.js +++ b/backend/controllers/apiLogController.js @@ -62,6 +62,23 @@ class ApiLogController { next(error); } } + + /** + * GET /api/logs/scheduler/last-executions + * Get last execution info for scheduler jobs + */ + async getLastSchedulerExecutions(req, res, next) { + try { + const { clubId } = req.query; + const results = await apiLogService.getLastSchedulerExecutions(clubId ? parseInt(clubId) : null); + res.json({ + success: true, + data: results + }); + } catch (error) { + next(error); + } + } } export default new ApiLogController(); diff --git a/backend/controllers/memberController.js b/backend/controllers/memberController.js index 2c10110..c651565 100644 --- a/backend/controllers/memberController.js +++ b/backend/controllers/memberController.js @@ -100,6 +100,30 @@ const rotateMemberImage = async (req, res) => { } }; +const quickUpdateTestMembership = async (req, res) => { + try { + const { clubId, memberId } = req.params; + const { authcode: userToken } = req.headers; + const result = await MemberService.quickUpdateTestMembership(userToken, clubId, memberId); + res.status(result.status).json(result.response); + } catch (error) { + console.error('[quickUpdateTestMembership] - Error:', error); + res.status(500).json({ error: 'Failed to update test membership' }); + } +}; + +const quickUpdateMemberFormHandedOver = async (req, res) => { + try { + const { clubId, memberId } = req.params; + const { authcode: userToken } = req.headers; + const result = await MemberService.quickUpdateMemberFormHandedOver(userToken, clubId, memberId); + res.status(result.status).json(result.response); + } catch (error) { + console.error('[quickUpdateMemberFormHandedOver] - Error:', error); + res.status(500).json({ error: 'Failed to update member form status' }); + } +}; + const transferMembers = async (req, res) => { try { const { id: clubId } = req.params; @@ -132,4 +156,4 @@ const transferMembers = async (req, res) => { } }; -export { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis, rotateMemberImage, transferMembers }; \ No newline at end of file +export { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis, rotateMemberImage, transferMembers, quickUpdateTestMembership, quickUpdateMemberFormHandedOver }; \ No newline at end of file diff --git a/backend/routes/apiLogRoutes.js b/backend/routes/apiLogRoutes.js index 0bff6a3..6f958ce 100644 --- a/backend/routes/apiLogRoutes.js +++ b/backend/routes/apiLogRoutes.js @@ -11,6 +11,9 @@ router.use(authenticate); // Get logs - requires permissions or admin router.get('/', apiLogController.getLogs); +// Get last scheduler executions +router.get('/scheduler/last-executions', apiLogController.getLastSchedulerExecutions); + // Get single log by ID router.get('/:id', apiLogController.getLogById); diff --git a/backend/routes/memberRoutes.js b/backend/routes/memberRoutes.js index 86a6a00..5549d6f 100644 --- a/backend/routes/memberRoutes.js +++ b/backend/routes/memberRoutes.js @@ -1,4 +1,4 @@ -import { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis, rotateMemberImage, transferMembers } from '../controllers/memberController.js'; +import { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis, rotateMemberImage, transferMembers, quickUpdateTestMembership, quickUpdateMemberFormHandedOver } from '../controllers/memberController.js'; import express from 'express'; import { authenticate } from '../middleware/authMiddleware.js'; import { authorize } from '../middleware/authorizationMiddleware.js'; @@ -17,5 +17,7 @@ router.get('/notapproved/:id', authenticate, authorize('members', 'read'), getWa router.post('/update-ratings/:id', authenticate, authorize('mytischtennis', 'write'), updateRatingsFromMyTischtennis); router.post('/rotate-image/:clubId/:memberId', 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); +router.post('/quick-update-member-form/:clubId/:memberId', authenticate, authorize('members', 'write'), quickUpdateMemberFormHandedOver); export default router; diff --git a/backend/services/apiLogService.js b/backend/services/apiLogService.js index 6cae02b..d736d56 100644 --- a/backend/services/apiLogService.js +++ b/backend/services/apiLogService.js @@ -184,6 +184,130 @@ class ApiLogService { throw error; } } + + /** + * Get last execution info for scheduler jobs + */ + async getLastSchedulerExecutions(clubId = null) { + try { + const jobTypes = ['rating_updates', 'match_results']; + const results = {}; + + for (const jobType of jobTypes) { + const lastExecution = await ApiLog.findOne({ + where: { + logType: 'scheduler', + schedulerJobType: jobType + }, + order: [['createdAt', 'DESC']], + attributes: ['id', 'createdAt', 'statusCode', 'responseBody', 'executionTime', 'errorMessage'] + }); + + if (lastExecution) { + let parsedResponse = null; + let updatedCount = null; + let fetchedCount = null; + let teamDetails = []; + + // Parse responseBody to extract counts + try { + if (lastExecution.responseBody) { + parsedResponse = JSON.parse(lastExecution.responseBody); + // Extract counts from response + if (parsedResponse.totalUpdated !== undefined) { + updatedCount = parsedResponse.totalUpdated; + } else if (parsedResponse.updatedCount !== undefined) { + updatedCount = parsedResponse.updatedCount; + } + if (parsedResponse.totalFetched !== undefined) { + fetchedCount = parsedResponse.totalFetched; + } else if (parsedResponse.fetchedCount !== undefined) { + fetchedCount = parsedResponse.fetchedCount; + } + } + } catch (e) { + // Ignore parsing errors + } + + // For match_results, try to get team-specific details from API logs + if (jobType === 'match_results') { + try { + // Find all API logs created around the same time as the scheduler execution + // (within 5 minutes before and after) + const timeWindowStart = new Date(lastExecution.createdAt); + timeWindowStart.setMinutes(timeWindowStart.getMinutes() - 5); + const timeWindowEnd = new Date(lastExecution.createdAt); + timeWindowEnd.setMinutes(timeWindowEnd.getMinutes() + 5); + + const teamLogs = await ApiLog.findAll({ + where: { + logType: 'api_request', + schedulerJobType: 'mytischtennis_fetch', + createdAt: { + [Op.between]: [timeWindowStart, timeWindowEnd] + } + }, + order: [['createdAt', 'DESC']], + attributes: ['id', 'requestBody', 'statusCode', 'createdAt'] + }); + + // Extract team information from requestBody + const teamMap = new Map(); + for (const log of teamLogs) { + try { + if (log.requestBody) { + const requestData = JSON.parse(log.requestBody); + if (requestData.clubTeamId && requestData.teamName) { + const teamId = requestData.clubTeamId; + if (!teamMap.has(teamId)) { + teamMap.set(teamId, { + clubTeamId: teamId, + teamName: requestData.teamName, + success: log.statusCode === 200, + lastRun: log.createdAt + }); + } + } + } + } catch (e) { + // Ignore parsing errors for individual logs + } + } + + teamDetails = Array.from(teamMap.values()); + } catch (e) { + console.error('Error extracting team details:', e); + } + } + + results[jobType] = { + lastRun: lastExecution.createdAt, + success: lastExecution.statusCode === 200, + executionTime: lastExecution.executionTime, + updatedCount: updatedCount, + fetchedCount: fetchedCount, + errorMessage: lastExecution.errorMessage, + teamDetails: teamDetails + }; + } else { + results[jobType] = { + lastRun: null, + success: null, + executionTime: null, + updatedCount: null, + fetchedCount: null, + errorMessage: null, + teamDetails: [] + }; + } + } + + return results; + } catch (error) { + console.error('Error getting last scheduler executions:', error); + throw error; + } + } } export default new ApiLogService(); diff --git a/backend/services/memberService.js b/backend/services/memberService.js index 913846b..3bc2e62 100644 --- a/backend/services/memberService.js +++ b/backend/services/memberService.js @@ -1,6 +1,8 @@ import UserClub from "../models/UserClub.js"; import { checkAccess, getUserByToken, hasUserClubAccess } from "../utils/userUtils.js"; import Member from "../models/Member.js"; +import Participant from "../models/Participant.js"; +import DiaryDate from "../models/DiaryDates.js"; import path from 'path'; import fs from 'fs'; import sharp from 'sharp'; @@ -492,6 +494,56 @@ class MemberService { }; } } + + async quickUpdateTestMembership(userToken, clubId, memberId) { + try { + await checkAccess(userToken, clubId); + const member = await Member.findOne({ where: { id: memberId, clubId: clubId } }); + if (!member) { + return { status: 404, response: { error: 'Member not found in this club' } }; + } + + if (!member.testMembership) { + return { status: 400, response: { error: 'Member is not a test member' } }; + } + + member.testMembership = false; + await member.save(); + + return { + status: 200, + response: { success: true, message: 'Testmitgliedschaft entfernt' } + }; + } catch (error) { + console.error('[quickUpdateTestMembership] - Error:', error); + return { status: 500, response: { error: 'Failed to update test membership' } }; + } + } + + async quickUpdateMemberFormHandedOver(userToken, clubId, memberId) { + try { + await checkAccess(userToken, clubId); + const member = await Member.findOne({ where: { id: memberId, clubId: clubId } }); + if (!member) { + return { status: 404, response: { error: 'Member not found in this club' } }; + } + + if (member.memberFormHandedOver) { + return { status: 400, response: { error: 'Member form already handed over' } }; + } + + member.memberFormHandedOver = true; + await member.save(); + + return { + status: 200, + response: { success: true, message: 'Mitgliedsformular als ausgehändigt markiert' } + }; + } catch (error) { + console.error('[quickUpdateMemberFormHandedOver] - Error:', error); + return { status: 500, response: { error: 'Failed to update member form status' } }; + } + } } export default new MemberService(); \ No newline at end of file diff --git a/frontend/src/views/MembersView.vue b/frontend/src/views/MembersView.vue index d0c2d2c..3a29d84 100644 --- a/frontend/src/views/MembersView.vue +++ b/frontend/src/views/MembersView.vue @@ -121,7 +121,8 @@ Geburtsdatum Telefon-Nr. Email-Adresse - + Trainingsteilnahmen + Aktionen @@ -137,6 +138,7 @@ {{ genderSymbol(member.gender) }} + ⚠️ {{ member.lastName }}, {{ member.firstName }} inaktiv @@ -154,9 +156,21 @@ {{ getFormattedBirthdate(member.birthDate) }} {{ member.phone }} {{ member.email }} + + {{ member.trainingParticipations || 0 }} + - + - - +
+ + + + +
@@ -286,6 +300,10 @@ export default { return true; }); + }, + + hasTestMembers() { + return this.members.some(member => member.testMembership); } }, data() { @@ -396,6 +414,87 @@ export default { this.members.forEach(member => { this.loadMemberImage(member); }); + + // Lade Trainingsteilnahmen für alle Testmitglieder auf einmal über /training-stats + await this.loadTrainingParticipations(); + }, + + async loadTrainingParticipations() { + try { + const response = await apiClient.get(`/training-stats/${this.currentClub}`); + const trainingStats = response.data.members || []; + + console.log('[loadTrainingParticipations] Training Stats geladen:', trainingStats.length, 'Mitglieder'); + + // Erstelle eine Map für schnellen Zugriff: memberId -> participationTotal + // Verwende parseInt, um sicherzustellen, dass IDs als Zahlen verglichen werden + const participationMap = new Map(); + trainingStats.forEach(stat => { + const memberId = parseInt(stat.id, 10); + participationMap.set(memberId, stat.participationTotal || 0); + console.log(`[loadTrainingParticipations] Map gesetzt: ID ${memberId} -> ${stat.participationTotal || 0}`); + }); + + // Setze Trainingsteilnahmen für alle Testmitglieder + const testMembers = this.members.filter(m => m.testMembership); + console.log('[loadTrainingParticipations] Testmitglieder gefunden:', testMembers.length); + + testMembers.forEach(member => { + const memberId = parseInt(member.id, 10); + const count = participationMap.get(memberId); + console.log(`[loadTrainingParticipations] Mitglied ${memberId} (${member.firstName} ${member.lastName}): count=${count}`); + // Nur setzen, wenn ein Wert gefunden wurde (nicht undefined) + if (count !== undefined) { + this.$set(member, 'trainingParticipations', count); + console.log(`[loadTrainingParticipations] Trainingsteilnahmen gesetzt: ${count}`); + } else { + // Fallback: 0 setzen, wenn kein Wert gefunden wurde + this.$set(member, 'trainingParticipations', 0); + console.log(`[loadTrainingParticipations] Kein Wert gefunden, setze 0`); + } + }); + } catch (error) { + console.error('Fehler beim Laden der Trainingsteilnahmen:', error); + // Bei Fehler setze 0 für alle Testmitglieder + this.members.forEach(member => { + if (member.testMembership) { + this.$set(member, 'trainingParticipations', 0); + } + }); + } + }, + + async quickRemoveTestMembership(member) { + try { + const response = await apiClient.post(`/clubmembers/quick-update-test-membership/${this.currentClub}/${member.id}`); + if (response.data.success) { + member.testMembership = false; + member.trainingParticipations = undefined; // Entferne die Anzeige + this.showInfo('Erfolg', response.data.message || 'Testmitgliedschaft entfernt', '', 'success'); + } else { + this.showInfo('Fehler', response.data.error || 'Fehler beim Entfernen der Testmitgliedschaft', '', 'error'); + } + } catch (error) { + console.error('Fehler beim Entfernen der Testmitgliedschaft:', error); + const errorMessage = error.response?.data?.error || error.message || 'Fehler beim Entfernen der Testmitgliedschaft'; + this.showInfo('Fehler', errorMessage, '', 'error'); + } + }, + + async quickMarkFormHandedOver(member) { + try { + const response = await apiClient.post(`/clubmembers/quick-update-member-form/${this.currentClub}/${member.id}`); + if (response.data.success) { + member.memberFormHandedOver = true; + this.showInfo('Erfolg', response.data.message || 'Mitgliedsformular als ausgehändigt markiert', '', 'success'); + } else { + this.showInfo('Fehler', response.data.error || 'Fehler beim Markieren des Formulars', '', 'error'); + } + } catch (error) { + console.error('Fehler beim Markieren des Formulars:', error); + const errorMessage = error.response?.data?.error || error.message || 'Fehler beim Markieren des Formulars'; + this.showInfo('Fehler', errorMessage, '', 'error'); + } }, toggleNewMember() { this.memberFormIsOpen = !this.memberFormIsOpen; @@ -1172,4 +1271,33 @@ table td { .btn-transfer:hover { background-color: #138496; } + +.action-buttons-row { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + align-items: center; +} + +.btn-quick-action { + background-color: #ffc107; + color: #000; + border: none; + padding: 0.4rem 0.8rem; + border-radius: 4px; + cursor: pointer; + font-size: 0.85em; + font-weight: 500; + transition: background-color 0.2s ease; + white-space: nowrap; +} + +.btn-quick-action:hover { + background-color: #e0a800; +} + +.warning-icon { + margin-right: 0.25rem; + font-size: 1.1em; +} diff --git a/frontend/src/views/TeamManagementView.vue b/frontend/src/views/TeamManagementView.vue index 1283023..0a69c22 100644 --- a/frontend/src/views/TeamManagementView.vue +++ b/frontend/src/views/TeamManagementView.vue @@ -8,6 +8,39 @@ :show-current-season="true" /> + +
+
+ 🔄 Rating-Updates: + + Zuletzt: {{ formatJobDate(schedulerJobs.rating_updates.lastRun) }} + + ({{ schedulerJobs.rating_updates.updatedCount }} aktualisiert) + + ⚠️ Fehler + +
+
+
+ 📊 Spielergebnisse: + + Zuletzt: {{ formatJobDate(schedulerJobs.match_results.lastRun) }} + + ({{ schedulerJobs.match_results.fetchedCount }} abgerufen) + + ⚠️ Fehler + +
+
+
+ {{ team.teamName }} + + +
+
+
+
+
@@ -167,6 +200,25 @@
+ +
+ 🔄 Automatische Jobs +
+
+ Zuletzt aktualisiert: + {{ formatJobDate(getTeamJobInfo(teamToEdit).lastRun) }} +
+
+ Status: + ✓ Erfolgreich + ✗ Fehler +
+
+
+ Noch keine automatische Aktualisierung +
+
+
@@ -261,6 +313,20 @@ ✗ Nicht konfiguriert
+ + +
+ 🔄 Automatische Jobs: + + + {{ formatJobDate(getTeamJobInfo(team).lastRun) }} + + {{ getTeamJobInfo(team).success ? '✓' : '✗' }} + + + Nie + +
@@ -401,6 +467,12 @@ export default { const loadingStats = ref(false); const memberById = ref({}); + // Scheduler Jobs Info + const schedulerJobs = ref({ + rating_updates: null, + match_results: null + }); + // Computed const selectedClub = computed(() => store.state.currentClub); const authToken = computed(() => store.state.token); @@ -442,6 +514,9 @@ export default { // Lade alle Team-Dokumente nach dem Laden der Teams await loadAllTeamDocuments(); + + // Aktualisiere Job-Informationen, damit Team-Filterung korrekt funktioniert + await loadSchedulerJobsInfo(); } catch (error) { console.error('Fehler beim Laden der Club-Teams:', error); } @@ -494,12 +569,14 @@ export default { } }; - const editTeam = (team) => { + const editTeam = async (team) => { teamToEdit.value = team; newTeamName.value = team.name; newLeagueId.value = team.leagueId || ''; teamFormIsOpen.value = true; - loadTeamDocuments(); + await loadTeamDocuments(); + // Aktualisiere Job-Informationen, damit Team-spezifische Daten aktuell sind + await loadSchedulerJobsInfo(); }; const deleteTeam = async (team) => { @@ -568,6 +645,31 @@ export default { const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; + + const formatJobDate = (dateString) => { + if (!dateString) return 'Nie'; + const date = new Date(dateString); + const now = new Date(); + const diffMs = now - date; + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'gerade eben'; + if (diffMins < 60) return `vor ${diffMins} Min.`; + if (diffHours < 24) return `vor ${diffHours} Std.`; + if (diffDays === 1) return 'gestern'; + if (diffDays < 7) return `vor ${diffDays} Tagen`; + + // Format: DD.MM.YYYY HH:MM + return date.toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + }; const loadTeamDocuments = async () => { if (!teamToEdit.value) return; @@ -739,10 +841,54 @@ export default { loadLeagues(); }; + // Load scheduler jobs info + const loadSchedulerJobsInfo = async () => { + try { + const clubIdParam = selectedClub.value ? `?clubId=${selectedClub.value}` : ''; + const response = await apiClient.get(`/logs/scheduler/last-executions${clubIdParam}`); + if (response.data.success) { + schedulerJobs.value = response.data.data; + } + } catch (error) { + console.error('Fehler beim Laden der Job-Informationen:', error); + } + }; + + // Filter team details to only show teams from current club + const getFilteredTeamDetails = (teamDetails) => { + if (!teamDetails || !selectedClub.value) return teamDetails; + // Filter by checking if team exists in current club's teams + return teamDetails.filter(team => { + return teams.value.some(t => t.id === team.clubTeamId); + }); + }; + + // Get job info for a specific team + const getTeamJobInfo = (team) => { + if (!team || !team.id || !schedulerJobs.value.match_results?.teamDetails) { + return null; + } + + const teamDetail = schedulerJobs.value.match_results.teamDetails.find( + td => td.clubTeamId === team.id + ); + + if (!teamDetail) { + return null; + } + + return { + lastRun: teamDetail.lastRun, + success: teamDetail.success + }; + }; + // Lifecycle onMounted(() => { // Lade Ligen beim ersten Laden der Seite (ohne Saison-Filter) loadLeagues(); + // Lade Job-Informationen + loadSchedulerJobsInfo(); }); // PDF-Dialog Funktionen @@ -1188,7 +1334,12 @@ export default { getMyTischtennisStatus, fetchTeamDataManually, refreshPlayerStats - ,memberById + ,memberById, + schedulerJobs, + formatJobDate, + loadSchedulerJobsInfo, + getFilteredTeamDetails, + getTeamJobInfo }; } }; @@ -2259,4 +2410,170 @@ export default { color: #2e7d32; font-size: 0.9rem; } + +/* Scheduler Jobs Info */ +.scheduler-jobs-info { + margin-bottom: 1rem; + padding: 0.75rem; + background: var(--background-light); + border-radius: var(--border-radius); + border: 1px solid var(--border-color); + font-size: 0.875rem; +} + +.job-info { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.job-info:last-child { + margin-bottom: 0; +} + +.job-label { + font-weight: 600; + color: var(--text-color); + min-width: 150px; +} + +.job-details { + color: var(--text-muted); + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.job-count { + color: var(--primary-color); + font-weight: 500; +} + +.job-error { + color: #dc3545; + font-weight: 500; +} + +.job-header { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.team-details { + margin-top: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid var(--border-color); + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.team-detail-item { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8rem; + padding: 0.25rem 0.5rem; + background: white; + border-radius: 3px; +} + +.team-name { + flex: 1; + color: var(--text-color); +} + +.team-status { + font-weight: bold; + font-size: 0.9rem; +} + +.team-status.success { + color: #28a745; +} + +.team-status.error { + color: #dc3545; +} + +/* Team Job Info */ +.team-job-info.compact { + margin-top: 0.5rem; + padding: 0.5rem 0.75rem; + background: var(--background-light); + border-radius: 4px; + border: 1px solid var(--border-color); +} + +.team-job-details { + margin-top: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.team-job-item { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; +} + +.team-job-label { + font-weight: 600; + color: var(--text-muted); + min-width: 120px; +} + +.team-job-value { + color: var(--text-color); +} + +.team-job-status { + font-weight: 500; +} + +.team-job-status.success { + color: #28a745; +} + +.team-job-status.error { + color: #dc3545; +} + +.team-job-no-data { + color: var(--text-muted); + font-size: 0.85rem; + font-style: italic; +} + +.team-job-status-row { + margin-top: 0.3rem; + padding-top: 0.3rem; + border-top: 1px solid var(--border-color); +} + +.team-job-status-value { + display: flex; + align-items: center; + gap: 0.3rem; + font-size: 0.8rem; +} + +.team-job-status-icon { + font-weight: bold; + font-size: 0.9rem; +} + +.team-job-status-icon.success { + color: #28a745; +} + +.team-job-status-icon.error { + color: #dc3545; +}