diff --git a/backend/controllers/memberController.js b/backend/controllers/memberController.js
index 20454997..f71593fa 100755
--- a/backend/controllers/memberController.js
+++ b/backend/controllers/memberController.js
@@ -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,
diff --git a/backend/migrations/20260814_add_last_qttr_value_change_at.sql b/backend/migrations/20260814_add_last_qttr_value_change_at.sql
new file mode 100644
index 00000000..e3f19fe8
--- /dev/null
+++ b/backend/migrations/20260814_add_last_qttr_value_change_at.sql
@@ -0,0 +1 @@
+ALTER TABLE clubs ADD COLUMN last_qttr_value_change_at DATETIME NULL;
diff --git a/backend/models/Club.js b/backend/models/Club.js
index 45cf0afa..a8904bd9 100755
--- a/backend/models/Club.js
+++ b/backend/models/Club.js
@@ -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,
diff --git a/backend/routes/memberRoutes.js b/backend/routes/memberRoutes.js
index 2017e179..5d123638 100755
--- a/backend/routes/memberRoutes.js
+++ b/backend/routes/memberRoutes.js
@@ -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);
diff --git a/backend/services/memberService.js b/backend/services/memberService.js
index ac613fea..397a985a 100755
--- a/backend/services/memberService.js
+++ b/backend/services/memberService.js
@@ -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 ?? '–'}`);
diff --git a/frontend/src/components/members/MembersOverviewSection.vue b/frontend/src/components/members/MembersOverviewSection.vue
index 9988bf47..2f70daa3 100755
--- a/frontend/src/components/members/MembersOverviewSection.vue
+++ b/frontend/src/components/members/MembersOverviewSection.vue
@@ -10,7 +10,7 @@
-