feat: implement QTTR status tracking and update mechanism
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 56s
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 56s
This commit is contained in:
@@ -15,6 +15,8 @@ import MemberProfileChangeRequest from '../models/MemberProfileChangeRequest.js'
|
||||
import MemberEventResponse from '../models/MemberEventResponse.js';
|
||||
import Participant from '../models/Participant.js';
|
||||
import DiaryDate from '../models/DiaryDates.js';
|
||||
import Club from '../models/Club.js';
|
||||
import myTischtennisService from '../services/myTischtennisService.js';
|
||||
|
||||
import { devLog } from '../utils/logger.js';
|
||||
const getClubMembers = async(req, res) => {
|
||||
@@ -164,6 +166,15 @@ const updateRatingsFromMyTischtennis = async (req, res) => {
|
||||
const { id: clubId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const result = await MemberService.updateRatingsFromMyTischtennis(userToken, clubId);
|
||||
if (result.status >= 200 && result.status < 300) {
|
||||
await myTischtennisService.logUpdateAttempt(
|
||||
req.user.id,
|
||||
true,
|
||||
result.response?.message || 'TTR/QTTR manuell aktualisiert',
|
||||
null,
|
||||
result.response?.updated || 0
|
||||
);
|
||||
}
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[updateRatingsFromMyTischtennis] - Error:', error);
|
||||
@@ -171,6 +182,33 @@ const updateRatingsFromMyTischtennis = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getNextQttrPublication = (now = new Date()) => {
|
||||
const year = now.getFullYear();
|
||||
const dates = [1, 4, 7, 11].map((month) => new Date(year, month, 11));
|
||||
let cutoff = dates.find((date) => date >= new Date(now.getFullYear(), now.getMonth(), now.getDate()));
|
||||
if (!cutoff) cutoff = new Date(year + 1, 1, 11);
|
||||
const availableFrom = new Date(cutoff); availableFrom.setDate(availableFrom.getDate() + 3);
|
||||
const availableUntil = new Date(cutoff); availableUntil.setDate(availableUntil.getDate() + 7);
|
||||
return { cutoff, availableFrom, availableUntil };
|
||||
};
|
||||
|
||||
const getRatingsStatus = async (req, res) => {
|
||||
try {
|
||||
const clubId = Number(req.params.id);
|
||||
const club = await Club.findByPk(clubId, { attributes: ['lastQttrValueChangeAt'] });
|
||||
const next = getNextQttrPublication();
|
||||
res.json({
|
||||
lastQttrValueChangeAt: club?.lastQttrValueChangeAt || null,
|
||||
nextQttrCutoff: next.cutoff,
|
||||
expectedAvailableFrom: next.availableFrom,
|
||||
expectedAvailableUntil: next.availableUntil,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[getRatingsStatus] - Error:', error);
|
||||
res.status(500).json({ error: 'QTTR-Status konnte nicht geladen werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
const getMemberTtrHistory = async (req, res) => {
|
||||
try {
|
||||
const { clubId, memberId } = req.params;
|
||||
@@ -630,6 +668,7 @@ export {
|
||||
uploadMemberImage,
|
||||
getMemberImage,
|
||||
updateRatingsFromMyTischtennis,
|
||||
getRatingsStatus,
|
||||
getMemberTtrHistory,
|
||||
refreshMemberTtrHistory,
|
||||
rotateMemberImage,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE clubs ADD COLUMN last_qttr_value_change_at DATETIME NULL;
|
||||
@@ -30,6 +30,12 @@ const Club = sequelize.define('Club', {
|
||||
field: 'auto_fetch_rankings',
|
||||
comment: 'Enable automatic TTR/QTTR rankings fetch for this club'
|
||||
},
|
||||
lastQttrValueChangeAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'last_qttr_value_change_at',
|
||||
comment: 'Zeitpunkt der letzten echten QTTR-Wertänderung; Erstbefüllungen werden nicht gezählt'
|
||||
},
|
||||
countryCode: {
|
||||
type: DataTypes.STRING(2),
|
||||
allowNull: false,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
uploadMemberImage,
|
||||
getMemberImage,
|
||||
updateRatingsFromMyTischtennis,
|
||||
getRatingsStatus,
|
||||
getMemberTtrHistory,
|
||||
refreshMemberTtrHistory,
|
||||
rotateMemberImage,
|
||||
@@ -67,6 +68,7 @@ router.get('/play-interest/:clubId', authenticate, authorize('members', 'read'),
|
||||
router.post('/play-interest/:clubId', authenticate, authorize('members', 'write'), setMemberPlayInterest);
|
||||
router.get('/notapproved/:id', authenticate, authorize('members', 'read'), getWaitingApprovals);
|
||||
router.post('/update-ratings/:id', authenticate, authorize('mytischtennis', 'write'), updateRatingsFromMyTischtennis);
|
||||
router.get('/ratings-status/:id', authenticate, authorize('mytischtennis', 'read'), getRatingsStatus);
|
||||
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);
|
||||
|
||||
@@ -788,6 +788,16 @@ class MemberService {
|
||||
}
|
||||
await member.save();
|
||||
const ratingChanged = member.ttr !== oldTtr || member.qttr !== oldQttr;
|
||||
const oldQttrNumber = Number(oldQttr);
|
||||
const newQttrNumber = Number(member.qttr);
|
||||
const isRealQttrChange = Number.isFinite(oldQttrNumber)
|
||||
&& oldQttrNumber !== 0
|
||||
&& Number.isFinite(newQttrNumber)
|
||||
&& newQttrNumber !== oldQttrNumber;
|
||||
if (isRealQttrChange) {
|
||||
// Die Erstbefüllung (null/0 → Wert) ist kein neuer QTTR-Stichtag.
|
||||
await Club.update({ lastQttrValueChangeAt: new Date() }, { where: { id: clubId } });
|
||||
}
|
||||
if (ratingChanged) {
|
||||
const changes = [];
|
||||
if (member.ttr !== oldTtr) changes.push(`TTR ${oldTtr ?? '–'} → ${member.ttr ?? '–'}`);
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</button>
|
||||
<button @click="$emit('create-phone-list')">{{ $t('members.generatePhoneList') }}</button>
|
||||
<button @click="$emit('open-group-photo-crop')">{{ $t('members.groupPhotoCrop') }}</button>
|
||||
<button @click="$emit('update-ratings')" class="btn-update-ratings" :disabled="isUpdatingRatings">
|
||||
<button @click="$emit('update-ratings')" class="btn-update-ratings" :disabled="isUpdatingRatings" :title="ratingsStatus">
|
||||
{{ isUpdatingRatings ? $t('members.updating') : $t('members.updateRatings') }}
|
||||
</button>
|
||||
<button @click="$emit('open-transfer-dialog')" class="btn-transfer">
|
||||
@@ -244,7 +244,8 @@ export default {
|
||||
filteredTestMembersCount: { type: Number, required: true },
|
||||
filteredMembersWithPhonesCount: { type: Number, required: true },
|
||||
filteredMembersWithEmailsCount: { type: Number, required: true },
|
||||
exportPreviewNames: { type: Array, required: true }
|
||||
exportPreviewNames: { type: Array, required: true },
|
||||
ratingsStatus: { type: String, default: '' }
|
||||
},
|
||||
emits: [
|
||||
'toggle-new-member',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<MembersOverviewSection
|
||||
:member-form-is-open="memberFormIsOpen"
|
||||
:is-updating-ratings="isUpdatingRatings"
|
||||
:ratings-status="ratingsStatusTooltip"
|
||||
:active-members-count="activeMembersCount"
|
||||
:test-members-count="testMembersCount"
|
||||
:inactive-members-count="inactiveMembersCount"
|
||||
@@ -795,6 +796,20 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['isAuthenticated', 'currentClub', 'token', 'hasPermission']),
|
||||
|
||||
ratingsStatusTooltip() {
|
||||
const status = this.ratingsStatus;
|
||||
if (!status) return 'QTTR-Status wird geladen.';
|
||||
const lines = [];
|
||||
const last = this.formatQttrStatusDate(status.lastQttrValueChangeAt);
|
||||
lines.push(last ? `Letzte echte QTTR-Wertänderung: ${last}` : 'Bisher keine echte QTTR-Wertänderung erkannt. Erstbefüllungen zählen nicht.');
|
||||
const cutoff = this.formatQttrStatusDate(status.nextQttrCutoff);
|
||||
if (cutoff) lines.push(`Nächster QTTR-Stichtag: ${cutoff.split(',')[0]}.`);
|
||||
const availableFrom = this.formatQttrStatusDate(status.expectedAvailableFrom);
|
||||
const availableUntil = this.formatQttrStatusDate(status.expectedAvailableUntil);
|
||||
if (availableFrom && availableUntil) lines.push(`Voraussichtlich verfügbar: ${availableFrom.split(',')[0]} bis ${availableUntil.split(',')[0]} (myTischtennis veröffentlicht nach einigen Tagen; keine feste Uhrzeit).`);
|
||||
return lines.join('\n');
|
||||
},
|
||||
|
||||
activeMembersCount() {
|
||||
return this.members.filter(member => member.active && !member.testMembership).length;
|
||||
@@ -1227,6 +1242,7 @@ export default {
|
||||
newAdultReleaseApproved: false,
|
||||
newAdultReserveApproved: false,
|
||||
isUpdatingRatings: false,
|
||||
ratingsStatus: null,
|
||||
showMemberInfo: false,
|
||||
showActivitiesModal: false,
|
||||
showMemberTtrHistoryDialog: false,
|
||||
@@ -1281,6 +1297,7 @@ export default {
|
||||
await this.loadTrainingGroups();
|
||||
await this.loadMemberDataQualityRequirements();
|
||||
await this.init();
|
||||
await this.loadRatingsStatus();
|
||||
},
|
||||
methods: {
|
||||
// Dialog Helper Methods
|
||||
@@ -3029,6 +3046,7 @@ export default {
|
||||
|
||||
// Mitglieder neu laden um aktualisierte Werte anzuzeigen
|
||||
await this.loadMembers();
|
||||
await this.loadRatingsStatus();
|
||||
} catch (error) {
|
||||
console.error(this.$t('members.errorUpdatingRatings'), error);
|
||||
const message = getSafeErrorMessage(error, this.$t('members.errorUpdatingRatings'));
|
||||
@@ -3037,6 +3055,14 @@ export default {
|
||||
this.isUpdatingRatings = false;
|
||||
}
|
||||
},
|
||||
async loadRatingsStatus() {
|
||||
if (!this.currentClub) return;
|
||||
try { this.ratingsStatus = (await apiClient.get(`/clubmembers/ratings-status/${this.currentClub}`)).data; }
|
||||
catch (_error) { this.ratingsStatus = null; }
|
||||
},
|
||||
formatQttrStatusDate(value) {
|
||||
return value ? new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value)) : null;
|
||||
},
|
||||
|
||||
getAge(birthDate) {
|
||||
if (!birthDate) return null;
|
||||
|
||||
Reference in New Issue
Block a user