Add last scheduler executions endpoint and member quick update functions

Implemented a new endpoint in ApiLogController to retrieve the last execution information for scheduler jobs. Added quick update functions in MemberService and corresponding routes for updating test membership status and marking member forms as handed over. Enhanced the MembersView to support quick actions for managing test memberships and form statuses, improving user experience and operational efficiency.
This commit is contained in:
Torsten Schulz (local)
2025-11-06 14:25:15 +01:00
parent c9d82827ff
commit f1a29e4111
8 changed files with 675 additions and 8 deletions

View File

@@ -121,7 +121,8 @@
<th>Geburtsdatum</th>
<th>Telefon-Nr.</th>
<th>Email-Adresse</th>
<th></th>
<th v-if="hasTestMembers">Trainingsteilnahmen</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
@@ -137,6 +138,7 @@
<td>
<span class="gender-symbol" :class="['gender-' + (member.gender || 'unknown'), { 'is-inactive': !member.active }]" :title="labelGender(member.gender)">{{ genderSymbol(member.gender) }}</span>
<span class="gender-name" :class="['gender-' + (member.gender || 'unknown'), { 'is-inactive': !member.active }]">
<span v-if="member.testMembership && member.trainingParticipations >= 5" class="warning-icon" title="5 oder mehr Trainingsteilnahmen"></span>
{{ member.lastName }}, {{ member.firstName }}
<span v-if="!member.active && showInactiveMembers" class="inactive-badge">inaktiv</span>
</span>
@@ -154,9 +156,21 @@
<td>{{ getFormattedBirthdate(member.birthDate) }}</td>
<td>{{ member.phone }}</td>
<td>{{ member.email }}</td>
<td v-if="hasTestMembers">
<span v-if="member.testMembership">{{ member.trainingParticipations || 0 }}</span>
<span v-else>-</span>
</td>
<td>
<button @click.stop="openNotesModal(member)">Notizen</button>
<button @click.stop="openActivitiesModal(member)" class="btn-activities">Übungen</button>
<div class="action-buttons-row">
<button v-if="member.testMembership" @click.stop="quickRemoveTestMembership(member)" class="btn-quick-action" title="Keine Testmitgliedschaft mehr">
Keine Testmitgliedschaft
</button>
<button v-if="!member.memberFormHandedOver" @click.stop="quickMarkFormHandedOver(member)" class="btn-quick-action" title="Mitgliedsformular ausgehändigt">
Formular ausgehändigt
</button>
<button @click.stop="openNotesModal(member)">Notizen</button>
<button @click.stop="openActivitiesModal(member)" class="btn-activities">Übungen</button>
</div>
</td>
</tr>
</template>
@@ -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;
}
</style>

View File

@@ -8,6 +8,39 @@
:show-current-season="true"
/>
<!-- Automatische Jobs Info -->
<div v-if="schedulerJobs.rating_updates || schedulerJobs.match_results" class="scheduler-jobs-info">
<div class="job-info" v-if="schedulerJobs.rating_updates?.lastRun">
<span class="job-label">🔄 Rating-Updates:</span>
<span class="job-details">
Zuletzt: {{ formatJobDate(schedulerJobs.rating_updates.lastRun) }}
<span v-if="schedulerJobs.rating_updates.updatedCount !== null" class="job-count">
({{ schedulerJobs.rating_updates.updatedCount }} aktualisiert)
</span>
<span v-if="!schedulerJobs.rating_updates.success" class="job-error"> Fehler</span>
</span>
</div>
<div class="job-info" v-if="schedulerJobs.match_results?.lastRun">
<div class="job-header">
<span class="job-label">📊 Spielergebnisse:</span>
<span class="job-details">
Zuletzt: {{ formatJobDate(schedulerJobs.match_results.lastRun) }}
<span v-if="schedulerJobs.match_results.fetchedCount !== null" class="job-count">
({{ schedulerJobs.match_results.fetchedCount }} abgerufen)
</span>
<span v-if="!schedulerJobs.match_results.success" class="job-error"> Fehler</span>
</span>
</div>
<div v-if="schedulerJobs.match_results.teamDetails && schedulerJobs.match_results.teamDetails.length > 0" class="team-details">
<div v-for="team in getFilteredTeamDetails(schedulerJobs.match_results.teamDetails)" :key="team.clubTeamId" class="team-detail-item">
<span class="team-name">{{ team.teamName }}</span>
<span v-if="team.success" class="team-status success"></span>
<span v-else class="team-status error"></span>
</div>
</div>
</div>
</div>
<div class="newteam">
<div class="toggle-new-team">
<span @click="toggleNewTeam">
@@ -167,6 +200,25 @@
</table>
</div>
<!-- Automatische Jobs Info für dieses Team -->
<div v-if="getTeamJobInfo(teamToEdit)" class="team-job-info compact">
<span class="section-title">🔄 Automatische Jobs</span>
<div v-if="getTeamJobInfo(teamToEdit).lastRun" class="team-job-details">
<div class="team-job-item">
<span class="team-job-label">Zuletzt aktualisiert:</span>
<span class="team-job-value">{{ formatJobDate(getTeamJobInfo(teamToEdit).lastRun) }}</span>
</div>
<div v-if="getTeamJobInfo(teamToEdit).success !== null" class="team-job-item">
<span class="team-job-label">Status:</span>
<span v-if="getTeamJobInfo(teamToEdit).success" class="team-job-status success"> Erfolgreich</span>
<span v-else class="team-job-status error"> Fehler</span>
</div>
</div>
<div v-else class="team-job-details">
<span class="team-job-no-data">Noch keine automatische Aktualisierung</span>
</div>
</div>
<!-- MyTischtennis URL Konfiguration -->
<div class="mytischtennis-config compact">
<div class="mytischtennis-header-compact">
@@ -261,6 +313,20 @@
Nicht konfiguriert
</span>
</div>
<!-- Automatische Jobs Info für dieses Team -->
<div class="info-row team-job-status-row">
<span class="label">🔄 Automatische Jobs:</span>
<span class="value">
<span v-if="getTeamJobInfo(team) && getTeamJobInfo(team).lastRun" class="team-job-status-value">
{{ formatJobDate(getTeamJobInfo(team).lastRun) }}
<span v-if="getTeamJobInfo(team).success !== null" class="team-job-status-icon" :class="getTeamJobInfo(team).success ? 'success' : 'error'">
{{ getTeamJobInfo(team).success ? '' : '' }}
</span>
</span>
<span v-else class="team-job-no-data">Nie</span>
</span>
</div>
</div>
<!-- PDF-Dokumente Icons -->
@@ -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;
}
</style>