Compare commits
2 Commits
3ac4039097
...
e2e8ba0d54
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2e8ba0d54 | ||
|
|
63a5db1196 |
@@ -1,9 +1,20 @@
|
||||
import { Op } from 'sequelize';
|
||||
import MemberService from "../services/memberService.js";
|
||||
import MemberTransferService from "../services/memberTransferService.js";
|
||||
import clickTtPlayerRegistrationService from "../services/clickTtPlayerRegistrationService.js";
|
||||
import { emitMemberChanged } from '../services/socketService.js';
|
||||
import Member from '../models/Member.js';
|
||||
import UserClub from '../models/UserClub.js';
|
||||
import CalendarEvent from '../models/CalendarEvent.js';
|
||||
import TrainingGroup from '../models/TrainingGroup.js';
|
||||
import ClubCommunicationRecipient from '../models/ClubCommunicationRecipient.js';
|
||||
import ClubCommunicationThread from '../models/ClubCommunicationThread.js';
|
||||
import ClubCommunicationMessage from '../models/ClubCommunicationMessage.js';
|
||||
import ClubSepaMandate from '../models/ClubSepaMandate.js';
|
||||
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 { devLog } from '../utils/logger.js';
|
||||
const getClubMembers = async(req, res) => {
|
||||
@@ -375,6 +386,239 @@ const linkMemberUser = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getApprovedLinkedMember = async (clubId, userId) => {
|
||||
const clubUser = await UserClub.findOne({ where: { clubId, userId, approved: true } });
|
||||
if (!clubUser) return { error: 'Kein Zugriff auf diesen Verein.', status: 403 };
|
||||
const member = await Member.findOne({ where: { clubId, userId, active: true } });
|
||||
if (!member) return { error: 'Für dieses Konto ist kein aktives Mitgliedsprofil verknüpft.', status: 404 };
|
||||
return { member };
|
||||
};
|
||||
|
||||
const getMemberDashboard = async (req, res) => {
|
||||
try {
|
||||
const clubId = Number(req.params.clubId);
|
||||
const userId = Number(req.user?.id);
|
||||
const access = await getApprovedLinkedMember(clubId, userId);
|
||||
if (access.error) return res.status(access.status).json({ error: access.error });
|
||||
const { member } = access;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const [events, trainingGroups, recipients, sepaMandate, changeRequests, trainingDates] = await Promise.all([
|
||||
CalendarEvent.findAll({
|
||||
where: { clubId, startDate: { [Op.gte]: today }, status: { [Op.notIn]: ['cancelled', 'archived'] } },
|
||||
attributes: ['id', 'title', 'startDate', 'endDate', 'eventType', 'location', 'registrationDeadline'],
|
||||
order: [['startDate', 'ASC']], limit: 5,
|
||||
}),
|
||||
TrainingGroup.findAll({ where: { clubId }, include: [{ model: Member, as: 'members', where: { id: member.id }, attributes: [], through: { attributes: [] } }, { association: 'trainingTimes', required: false }], order: [['name', 'ASC']] }),
|
||||
ClubCommunicationRecipient.findAll({ where: { clubId, memberId: member.id, deliveryStatus: 'sent' }, include: [{ model: ClubCommunicationThread, as: 'thread', attributes: ['id', 'subject', 'sentAt'] }], order: [['deliveredAt', 'DESC']], limit: 5 }),
|
||||
ClubSepaMandate.findOne({ where: { clubId, memberId: member.id, status: 'active', revokedAt: null }, attributes: ['id'] }),
|
||||
MemberProfileChangeRequest.findAll({ where: { clubId, memberId: member.id, status: 'open' }, attributes: ['id', 'requestedChanges', 'createdAt'], order: [['createdAt', 'DESC']] }),
|
||||
DiaryDate.findAll({ where: { clubId, date: { [Op.gte]: today } }, include: [{ model: Member, as: 'participants', where: { id: member.id }, required: false, through: { attributes: ['attendanceStatus'] } }], attributes: ['id', 'date', 'trainingStart', 'trainingEnd'], order: [['date', 'ASC']], limit: 5 }),
|
||||
]);
|
||||
const responses = await MemberEventResponse.findAll({ where: { clubId, memberId: member.id, eventId: events.map((event) => event.id) } });
|
||||
const responseByEvent = new Map(responses.map((response) => [response.eventId, response.status]));
|
||||
res.json({
|
||||
profile: { id: member.id, firstName: member.firstName, lastName: member.lastName, email: member.email, phone: member.phone, street: member.street, postalCode: member.postalCode, city: member.city },
|
||||
membership: { status: member.membershipStatus, type: member.membershipType, joinedOn: member.joinedOn, needsSepaMandate: member.needsSepaMandate, hasActiveSepaMandate: Boolean(sepaMandate) },
|
||||
openProfileChangeRequests: changeRequests,
|
||||
upcomingEvents: events.map((event) => ({ ...event.toJSON(), responseStatus: responseByEvent.get(event.id) || null })),
|
||||
trainingGroups: trainingGroups.map((group) => ({ id: group.id, name: group.name, trainingTimes: group.trainingTimes || [] })),
|
||||
upcomingTrainingDates: trainingDates.map((date) => ({ id: date.id, date: date.date, trainingStart: date.trainingStart, trainingEnd: date.trainingEnd, attendanceStatus: date.participants?.[0]?.Participant?.attendanceStatus || 'present' })),
|
||||
messages: recipients.map((recipient) => ({ id: recipient.thread?.id || recipient.id, subject: recipient.thread?.subject || 'Vereinsnachricht', sentAt: recipient.deliveredAt || recipient.thread?.sentAt || null })),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[getMemberDashboard] - Error:', error);
|
||||
res.status(500).json({ error: 'Persönlicher Bereich konnte nicht geladen werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
const createProfileChangeRequest = async (req, res) => {
|
||||
try {
|
||||
const clubId = Number(req.params.clubId);
|
||||
const userId = Number(req.user?.id);
|
||||
const access = await getApprovedLinkedMember(clubId, userId);
|
||||
if (access.error) return res.status(access.status).json({ error: access.error });
|
||||
const allowedFields = ['email', 'phone', 'street', 'postalCode', 'city'];
|
||||
const requestedChanges = Object.fromEntries(allowedFields
|
||||
.filter((field) => typeof req.body?.[field] === 'string' && req.body[field].trim() !== '')
|
||||
.map((field) => [field, req.body[field].trim()]));
|
||||
if (!Object.keys(requestedChanges).length) return res.status(400).json({ error: 'Bitte mindestens ein gültiges Kontaktfeld angeben.' });
|
||||
const request = await MemberProfileChangeRequest.create({ clubId, memberId: access.member.id, requestedByUserId: userId, requestedChanges });
|
||||
res.status(201).json({ id: request.id, status: request.status, requestedChanges: request.requestedChanges, createdAt: request.createdAt });
|
||||
} catch (error) {
|
||||
console.error('[createProfileChangeRequest] - Error:', error);
|
||||
res.status(500).json({ error: 'Änderungsanfrage konnte nicht eingereicht werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
const respondToCalendarEvent = async (req, res) => {
|
||||
try {
|
||||
const clubId = Number(req.params.clubId);
|
||||
const eventId = Number(req.params.eventId);
|
||||
const status = req.body?.status;
|
||||
if (!['attending', 'declined'].includes(status)) return res.status(400).json({ error: 'Ungültige Rückmeldung.' });
|
||||
const access = await getApprovedLinkedMember(clubId, Number(req.user?.id));
|
||||
if (access.error) return res.status(access.status).json({ error: access.error });
|
||||
const event = await CalendarEvent.findOne({ where: { id: eventId, clubId } });
|
||||
if (!event) return res.status(404).json({ error: 'Veranstaltung nicht gefunden.' });
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
if (event.startDate < today || (event.registrationDeadline && event.registrationDeadline < today)) return res.status(409).json({ error: 'Für diese Veranstaltung sind keine Rückmeldungen mehr möglich.' });
|
||||
const [response] = await MemberEventResponse.upsert({ clubId, eventId, memberId: access.member.id, status }, { returning: true });
|
||||
res.json({ eventId, status: response.status });
|
||||
} catch (error) {
|
||||
console.error('[respondToCalendarEvent] - Error:', error);
|
||||
res.status(500).json({ error: 'Rückmeldung konnte nicht gespeichert werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
const updateOwnTrainingAttendance = async (req, res) => {
|
||||
try {
|
||||
const clubId = Number(req.params.clubId);
|
||||
const diaryDateId = Number(req.params.diaryDateId);
|
||||
const attendanceStatus = req.body?.attendanceStatus;
|
||||
if (!['present', 'excused', 'cancelled'].includes(attendanceStatus)) return res.status(400).json({ error: 'Ungültiger Trainingsstatus.' });
|
||||
const access = await getApprovedLinkedMember(clubId, Number(req.user?.id));
|
||||
if (access.error) return res.status(access.status).json({ error: access.error });
|
||||
const diaryDate = await DiaryDate.findOne({ where: { id: diaryDateId, clubId } });
|
||||
if (!diaryDate) return res.status(404).json({ error: 'Trainingstag nicht gefunden.' });
|
||||
const [participant] = await Participant.findOrCreate({ where: { diaryDateId, memberId: access.member.id }, defaults: { attendanceStatus } });
|
||||
if (!participant.isNewRecord) await participant.update({ attendanceStatus });
|
||||
res.json({ diaryDateId, attendanceStatus: participant.attendanceStatus });
|
||||
} catch (error) {
|
||||
console.error('[updateOwnTrainingAttendance] - Error:', error);
|
||||
res.status(500).json({ error: 'Trainingsrückmeldung konnte nicht gespeichert werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
const getEventResponses = async (req, res) => {
|
||||
try {
|
||||
const clubId = Number(req.params.clubId);
|
||||
const eventId = Number(req.params.eventId);
|
||||
const responses = await MemberEventResponse.findAll({ where: { clubId, eventId }, include: [{ model: Member, as: 'member', attributes: ['id', 'firstName', 'lastName'] }], order: [['updatedAt', 'DESC']] });
|
||||
res.json(responses.map((response) => ({ id: response.id, memberId: response.memberId, status: response.status, updatedAt: response.updatedAt, member: response.member ? { id: response.member.id, firstName: response.member.firstName, lastName: response.member.lastName } : null })));
|
||||
} catch (error) {
|
||||
console.error('[getEventResponses] - Error:', error);
|
||||
res.status(500).json({ error: 'Rückmeldungen konnten nicht geladen werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
const getProfileChangeRequests = async (req, res) => {
|
||||
try {
|
||||
const clubId = Number(req.params.clubId);
|
||||
const requests = await MemberProfileChangeRequest.findAll({
|
||||
where: { clubId, ...(req.query.status ? { status: req.query.status } : {}) },
|
||||
include: [{ model: Member, as: 'member', attributes: ['id', 'firstName', 'lastName'] }],
|
||||
order: [['createdAt', 'DESC']],
|
||||
});
|
||||
res.json(requests.map((request) => ({ id: request.id, memberId: request.memberId, status: request.status, requestedChanges: request.requestedChanges, reviewNote: request.reviewNote, createdAt: request.createdAt, member: request.member ? { id: request.member.id, firstName: request.member.firstName, lastName: request.member.lastName } : null })));
|
||||
} catch (error) {
|
||||
console.error('[getProfileChangeRequests] - Error:', error);
|
||||
res.status(500).json({ error: 'Änderungsanfragen konnten nicht geladen werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
const reviewProfileChangeRequest = async (req, res) => {
|
||||
try {
|
||||
const clubId = Number(req.params.clubId);
|
||||
const requestId = Number(req.params.requestId);
|
||||
const status = req.body?.status;
|
||||
const reviewNote = typeof req.body?.reviewNote === 'string' ? req.body.reviewNote.trim() : null;
|
||||
if (!['approved', 'rejected'].includes(status)) return res.status(400).json({ error: 'Ungültiger Prüfstatus.' });
|
||||
const request = await MemberProfileChangeRequest.findOne({ where: { id: requestId, clubId, status: 'open' }, include: [{ model: Member, as: 'member' }] });
|
||||
if (!request || !request.member) return res.status(404).json({ error: 'Offene Änderungsanfrage nicht gefunden.' });
|
||||
if (status === 'approved') await request.member.update(request.requestedChanges);
|
||||
await request.update({ status, reviewNote, reviewedByUserId: req.user.id, reviewedAt: new Date() });
|
||||
if (status === 'approved') emitMemberChanged(clubId);
|
||||
res.json({ id: request.id, status: request.status });
|
||||
} catch (error) {
|
||||
console.error('[reviewProfileChangeRequest] - Error:', error);
|
||||
res.status(500).json({ error: 'Änderungsanfrage konnte nicht geprüft werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
const getMemberInbox = async (req, res) => {
|
||||
try {
|
||||
const clubId = Number(req.params.clubId);
|
||||
const userId = Number(req.user?.id);
|
||||
const access = await getApprovedLinkedMember(clubId, userId);
|
||||
if (access.error) return res.status(access.status).json({ error: access.error });
|
||||
const recipients = await ClubCommunicationRecipient.findAll({
|
||||
where: { clubId, memberId: access.member.id, deliveryStatus: 'sent' },
|
||||
include: [{
|
||||
model: ClubCommunicationThread,
|
||||
as: 'thread',
|
||||
required: true,
|
||||
where: { status: 'sent' },
|
||||
attributes: ['id', 'subject', 'threadType', 'replyTo', 'sentAt'],
|
||||
include: [{ model: ClubCommunicationMessage, as: 'messages', required: false, attributes: ['id', 'body', 'direction', 'messageType', 'createdByUserId', 'createdAt'] }],
|
||||
}],
|
||||
order: [['deliveredAt', 'DESC']],
|
||||
});
|
||||
const items = recipients.map((recipient) => {
|
||||
const thread = recipient.thread;
|
||||
const messages = (thread?.messages || [])
|
||||
.filter((message) => message.messageType === 'message' && (message.direction === 'outbound' || (message.direction === 'inbound' && Number(message.createdByUserId) === userId)))
|
||||
.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
|
||||
.map((message) => ({ id: message.id, body: message.body, direction: message.direction, createdAt: message.createdAt }));
|
||||
const latestOutbound = [...messages].reverse().find((message) => message.direction === 'outbound');
|
||||
return {
|
||||
recipientId: recipient.id,
|
||||
threadId: thread.id,
|
||||
subject: thread.subject,
|
||||
threadType: thread.threadType,
|
||||
sentAt: recipient.deliveredAt || thread.sentAt,
|
||||
readAt: recipient.readAt,
|
||||
canAskQuestion: Boolean(thread.replyTo),
|
||||
preview: latestOutbound?.body || '',
|
||||
messages,
|
||||
};
|
||||
});
|
||||
res.json({ items, unreadCount: items.filter((item) => !item.readAt).length });
|
||||
} catch (error) {
|
||||
console.error('[getMemberInbox] - Error:', error);
|
||||
res.status(500).json({ error: 'Nachrichten konnten nicht geladen werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
const markInboxItemRead = async (req, res) => {
|
||||
try {
|
||||
const clubId = Number(req.params.clubId);
|
||||
const recipientId = Number(req.params.recipientId);
|
||||
const access = await getApprovedLinkedMember(clubId, Number(req.user?.id));
|
||||
if (access.error) return res.status(access.status).json({ error: access.error });
|
||||
const recipient = await ClubCommunicationRecipient.findOne({ where: { id: recipientId, clubId, memberId: access.member.id, deliveryStatus: 'sent' } });
|
||||
if (!recipient) return res.status(404).json({ error: 'Nachricht nicht gefunden.' });
|
||||
if (!recipient.readAt) await recipient.update({ readAt: new Date() });
|
||||
res.json({ recipientId, readAt: recipient.readAt });
|
||||
} catch (error) {
|
||||
console.error('[markInboxItemRead] - Error:', error);
|
||||
res.status(500).json({ error: 'Lesestatus konnte nicht gespeichert werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
const askInboxQuestion = async (req, res) => {
|
||||
try {
|
||||
const clubId = Number(req.params.clubId);
|
||||
const recipientId = Number(req.params.recipientId);
|
||||
const userId = Number(req.user?.id);
|
||||
const body = String(req.body?.body || '').trim();
|
||||
if (!body) return res.status(400).json({ error: 'Bitte formuliere deine Rückfrage.' });
|
||||
if (body.length > 4000) return res.status(400).json({ error: 'Die Rückfrage ist zu lang.' });
|
||||
const access = await getApprovedLinkedMember(clubId, userId);
|
||||
if (access.error) return res.status(access.status).json({ error: access.error });
|
||||
const recipient = await ClubCommunicationRecipient.findOne({
|
||||
where: { id: recipientId, clubId, memberId: access.member.id, deliveryStatus: 'sent' },
|
||||
include: [{ model: ClubCommunicationThread, as: 'thread', required: true, attributes: ['id', 'status', 'replyTo'] }],
|
||||
});
|
||||
if (!recipient?.thread || recipient.thread.status !== 'sent' || !recipient.thread.replyTo) return res.status(409).json({ error: 'Für diese Nachricht ist keine Rückfrage vorgesehen.' });
|
||||
const message = await ClubCommunicationMessage.create({ threadId: recipient.thread.id, clubId, body, direction: 'inbound', messageType: 'message', createdByUserId: userId });
|
||||
if (!recipient.readAt) await recipient.update({ readAt: new Date() });
|
||||
res.status(201).json({ id: message.id, body: message.body, direction: message.direction, createdAt: message.createdAt });
|
||||
} catch (error) {
|
||||
console.error('[askInboxQuestion] - Error:', error);
|
||||
res.status(500).json({ error: 'Rückfrage konnte nicht gesendet werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
getClubMembers,
|
||||
getWaitingApprovals,
|
||||
@@ -397,5 +641,15 @@ export {
|
||||
deleteMemberImage,
|
||||
setPrimaryMemberImage,
|
||||
generateMemberGallery,
|
||||
linkMemberUser
|
||||
linkMemberUser,
|
||||
getMemberDashboard,
|
||||
createProfileChangeRequest,
|
||||
respondToCalendarEvent,
|
||||
updateOwnTrainingAttendance,
|
||||
getEventResponses,
|
||||
getProfileChangeRequests,
|
||||
reviewProfileChangeRequest,
|
||||
getMemberInbox,
|
||||
markInboxItemRead,
|
||||
askInboxQuestion
|
||||
};
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
CREATE TABLE member_profile_change_requests (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
club_id INT NOT NULL,
|
||||
member_id INT NOT NULL,
|
||||
requested_by_user_id INT NOT NULL,
|
||||
requested_changes JSON NOT NULL,
|
||||
status ENUM('open', 'approved', 'rejected') NOT NULL DEFAULT 'open',
|
||||
review_note TEXT NULL,
|
||||
reviewed_by_user_id INT NULL,
|
||||
reviewed_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
INDEX member_profile_change_requests_club_status (club_id, status),
|
||||
INDEX member_profile_change_requests_member_status (member_id, status)
|
||||
);
|
||||
|
||||
CREATE TABLE member_event_responses (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
club_id INT NOT NULL,
|
||||
event_id INT NOT NULL,
|
||||
member_id INT NOT NULL,
|
||||
status ENUM('attending', 'declined') NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
UNIQUE KEY member_event_responses_event_member (event_id, member_id),
|
||||
INDEX member_event_responses_club_event (club_id, event_id)
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE club_communication_recipients
|
||||
ADD COLUMN read_at DATETIME NULL AFTER delivered_at,
|
||||
ADD INDEX club_communication_recipients_member_read (club_id, member_id, read_at);
|
||||
@@ -44,6 +44,11 @@ const ClubCommunicationRecipient = sequelize.define('ClubCommunicationRecipient'
|
||||
allowNull: true,
|
||||
field: 'delivered_at',
|
||||
},
|
||||
readAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'read_at',
|
||||
},
|
||||
lastAttemptAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
|
||||
17
backend/models/MemberEventResponse.js
Normal file
17
backend/models/MemberEventResponse.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const MemberEventResponse = sequelize.define('MemberEventResponse', {
|
||||
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
|
||||
clubId: { type: DataTypes.INTEGER, allowNull: false, field: 'club_id' },
|
||||
eventId: { type: DataTypes.INTEGER, allowNull: false, field: 'event_id' },
|
||||
memberId: { type: DataTypes.INTEGER, allowNull: false, field: 'member_id' },
|
||||
status: { type: DataTypes.ENUM('attending', 'declined'), allowNull: false },
|
||||
}, {
|
||||
tableName: 'member_event_responses',
|
||||
underscored: true,
|
||||
timestamps: true,
|
||||
indexes: [{ unique: true, fields: ['event_id', 'member_id'] }, { fields: ['club_id', 'event_id'] }],
|
||||
});
|
||||
|
||||
export default MemberEventResponse;
|
||||
21
backend/models/MemberProfileChangeRequest.js
Normal file
21
backend/models/MemberProfileChangeRequest.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const MemberProfileChangeRequest = sequelize.define('MemberProfileChangeRequest', {
|
||||
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
|
||||
clubId: { type: DataTypes.INTEGER, allowNull: false, field: 'club_id' },
|
||||
memberId: { type: DataTypes.INTEGER, allowNull: false, field: 'member_id' },
|
||||
requestedByUserId: { type: DataTypes.INTEGER, allowNull: false, field: 'requested_by_user_id' },
|
||||
requestedChanges: { type: DataTypes.JSON, allowNull: false, field: 'requested_changes' },
|
||||
status: { type: DataTypes.ENUM('open', 'approved', 'rejected'), allowNull: false, defaultValue: 'open' },
|
||||
reviewNote: { type: DataTypes.TEXT, allowNull: true, field: 'review_note' },
|
||||
reviewedByUserId: { type: DataTypes.INTEGER, allowNull: true, field: 'reviewed_by_user_id' },
|
||||
reviewedAt: { type: DataTypes.DATE, allowNull: true, field: 'reviewed_at' },
|
||||
}, {
|
||||
tableName: 'member_profile_change_requests',
|
||||
underscored: true,
|
||||
timestamps: true,
|
||||
indexes: [{ fields: ['club_id', 'status'] }, { fields: ['member_id', 'status'] }],
|
||||
});
|
||||
|
||||
export default MemberProfileChangeRequest;
|
||||
@@ -93,6 +93,8 @@ import ClubCommunicationDeliveryLog from './ClubCommunicationDeliveryLog.js';
|
||||
import ClubCommunicationTemplate from './ClubCommunicationTemplate.js';
|
||||
import ClubDistributionGroup from './ClubDistributionGroup.js';
|
||||
import ClubDistributionGroupMember from './ClubDistributionGroupMember.js';
|
||||
import MemberProfileChangeRequest from './MemberProfileChangeRequest.js';
|
||||
import MemberEventResponse from './MemberEventResponse.js';
|
||||
// Official tournaments relations
|
||||
OfficialTournament.hasMany(OfficialCompetition, { foreignKey: 'tournamentId', as: 'competitions' });
|
||||
OfficialCompetition.belongsTo(OfficialTournament, { foreignKey: 'tournamentId', as: 'tournament' });
|
||||
@@ -500,6 +502,20 @@ ClubSepaMandate.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
Member.hasMany(ClubSepaMandate, { foreignKey: 'memberId', as: 'sepaMandates' });
|
||||
ClubSepaMandate.belongsTo(Member, { foreignKey: 'memberId', as: 'member', constraints: false });
|
||||
|
||||
Club.hasMany(MemberProfileChangeRequest, { foreignKey: 'clubId', as: 'memberProfileChangeRequests' });
|
||||
MemberProfileChangeRequest.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
Member.hasMany(MemberProfileChangeRequest, { foreignKey: 'memberId', as: 'profileChangeRequests' });
|
||||
MemberProfileChangeRequest.belongsTo(Member, { foreignKey: 'memberId', as: 'member' });
|
||||
User.hasMany(MemberProfileChangeRequest, { foreignKey: 'requestedByUserId', as: 'requestedMemberProfileChanges' });
|
||||
MemberProfileChangeRequest.belongsTo(User, { foreignKey: 'requestedByUserId', as: 'requestedByUser' });
|
||||
|
||||
Club.hasMany(MemberEventResponse, { foreignKey: 'clubId', as: 'memberEventResponses' });
|
||||
MemberEventResponse.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
CalendarEvent.hasMany(MemberEventResponse, { foreignKey: 'eventId', as: 'memberResponses' });
|
||||
MemberEventResponse.belongsTo(CalendarEvent, { foreignKey: 'eventId', as: 'event' });
|
||||
Member.hasMany(MemberEventResponse, { foreignKey: 'memberId', as: 'eventResponses' });
|
||||
MemberEventResponse.belongsTo(Member, { foreignKey: 'memberId', as: 'member' });
|
||||
|
||||
Club.hasMany(ClubPaymentClaim, { foreignKey: 'clubId', as: 'paymentClaims' });
|
||||
ClubPaymentClaim.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
Member.hasMany(ClubPaymentClaim, { foreignKey: 'memberId', as: 'paymentClaims' });
|
||||
@@ -688,5 +704,7 @@ export {
|
||||
ClubCommunicationDeliveryLog,
|
||||
ClubCommunicationTemplate,
|
||||
ClubDistributionGroup,
|
||||
MemberProfileChangeRequest,
|
||||
MemberEventResponse,
|
||||
ClubDistributionGroupMember,
|
||||
};
|
||||
|
||||
@@ -20,7 +20,17 @@ import {
|
||||
deleteMemberImage,
|
||||
setPrimaryMemberImage,
|
||||
generateMemberGallery,
|
||||
linkMemberUser
|
||||
linkMemberUser,
|
||||
getMemberDashboard,
|
||||
createProfileChangeRequest,
|
||||
respondToCalendarEvent,
|
||||
updateOwnTrainingAttendance,
|
||||
getEventResponses,
|
||||
getProfileChangeRequests,
|
||||
reviewProfileChangeRequest,
|
||||
getMemberInbox,
|
||||
markInboxItemRead,
|
||||
askInboxQuestion
|
||||
} from '../controllers/memberController.js';
|
||||
import express from 'express';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
@@ -41,6 +51,16 @@ router.get('/get/:id/:showAll', authenticate, authorize('members', 'read'), getC
|
||||
router.get('/gallery/:clubId', authenticate, authorize('members', 'read'), generateMemberGallery);
|
||||
router.post('/set/:id', authenticate, authorize('members', 'write'), setClubMembers);
|
||||
router.put('/link-user/:clubId/:memberId', authenticate, authorize('members', 'write'), linkMemberUser);
|
||||
router.get('/dashboard/:clubId', authenticate, getMemberDashboard);
|
||||
router.post('/profile/:clubId/change-requests', authenticate, createProfileChangeRequest);
|
||||
router.put('/dashboard/:clubId/events/:eventId/response', authenticate, respondToCalendarEvent);
|
||||
router.put('/dashboard/:clubId/training/:diaryDateId/attendance', authenticate, updateOwnTrainingAttendance);
|
||||
router.get('/event-responses/:clubId/:eventId', authenticate, authorize('schedule', 'read'), getEventResponses);
|
||||
router.get('/profile-change-requests/:clubId', authenticate, authorize('members', 'read'), getProfileChangeRequests);
|
||||
router.patch('/profile-change-requests/:clubId/:requestId', authenticate, authorize('members', 'write'), reviewProfileChangeRequest);
|
||||
router.get('/inbox/:clubId', authenticate, getMemberInbox);
|
||||
router.patch('/inbox/:clubId/:recipientId/read', authenticate, markInboxItemRead);
|
||||
router.post('/inbox/:clubId/:recipientId/questions', authenticate, askInboxQuestion);
|
||||
router.get('/sepa/:clubId/:memberId', authenticate, authorize('members', 'read'), getMemberSepaMandate);
|
||||
router.put('/sepa/:clubId/:memberId', authenticate, authorize('members', 'write'), saveMemberSepaMandate);
|
||||
router.get('/play-interest/:clubId', authenticate, authorize('members', 'read'), getMemberPlayInterests);
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, ClickTtAccount, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact, MemberTtrHistory, MemberPlayInterest,
|
||||
MemberOrder, MemberOrderHistory, MemberGroupPhoto, BillingTemplate, BillingTemplateField, BillingRun, BillingDocument, BillingDocumentValue, BillingUserSetting, FriendlyMatch, TrainingCancellation
|
||||
, FriendlyMatchShared, FriendlyMatchInvitation
|
||||
, CalendarEvent, ClubVenue, ClubRequest, ClubRequestNote, ClubSepaMandate, ClubPaymentClaim, ClubAccount, ClubAccountTransaction, ClubInvoiceParty, ClubInvoice, ClubInvoiceItem, ClubRole, ClubUserRole, ClubCommunicationThread, ClubCommunicationMessage, ClubCommunicationRecipient, ClubCommunicationDeliveryLog, ClubCommunicationTemplate, ClubDistributionGroup, ClubDistributionGroupMember
|
||||
, CalendarEvent, ClubVenue, ClubRequest, ClubRequestNote, ClubSepaMandate, ClubPaymentClaim, ClubAccount, ClubAccountTransaction, ClubInvoiceParty, ClubInvoice, ClubInvoiceItem, ClubRole, ClubUserRole, ClubCommunicationThread, ClubCommunicationMessage, ClubCommunicationRecipient, ClubCommunicationDeliveryLog, ClubCommunicationTemplate, ClubDistributionGroup, ClubDistributionGroupMember, MemberProfileChangeRequest, MemberEventResponse
|
||||
} from './models/index.js';
|
||||
import authRoutes from './routes/authRoutes.js';
|
||||
import clubRoutes from './routes/clubRoutes.js';
|
||||
@@ -662,6 +662,8 @@ app.use((err, req, res, next) => {
|
||||
await safeSync(ClubTeam);
|
||||
await safeSync(TrainingCancellation);
|
||||
await safeSync(CalendarEvent);
|
||||
await safeSync(MemberProfileChangeRequest);
|
||||
await safeSync(MemberEventResponse);
|
||||
await safeSync(TeamDocument);
|
||||
|
||||
// Foreign Keys wieder aktivieren
|
||||
|
||||
@@ -648,6 +648,7 @@ class ClubCommunicationService {
|
||||
await recipient.update({
|
||||
deliveryStatus: 'sent',
|
||||
deliveredAt: now,
|
||||
readAt: null,
|
||||
lastAttemptAt: now,
|
||||
attemptCount: nextAttemptNo,
|
||||
retryable: false,
|
||||
|
||||
@@ -46,6 +46,58 @@ Rueckwaertskompatibilitaet:
|
||||
|
||||
- Bestehende Vorgange behalten eine leere Antwortadresse und verwenden weiterhin die globale Absenderadresse.
|
||||
|
||||
## 2026-07-23
|
||||
|
||||
### Persönlicher Mitgliederbereich: Änderungsanfragen und Terminrückmeldungen
|
||||
|
||||
```sql
|
||||
CREATE TABLE member_profile_change_requests (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
club_id INT NOT NULL,
|
||||
member_id INT NOT NULL,
|
||||
requested_by_user_id INT NOT NULL,
|
||||
requested_changes JSON NOT NULL,
|
||||
status ENUM('open', 'approved', 'rejected') NOT NULL DEFAULT 'open',
|
||||
review_note TEXT NULL,
|
||||
reviewed_by_user_id INT NULL,
|
||||
reviewed_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
INDEX member_profile_change_requests_club_status (club_id, status),
|
||||
INDEX member_profile_change_requests_member_status (member_id, status)
|
||||
);
|
||||
|
||||
CREATE TABLE member_event_responses (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
club_id INT NOT NULL,
|
||||
event_id INT NOT NULL,
|
||||
member_id INT NOT NULL,
|
||||
status ENUM('attending', 'declined') NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
UNIQUE KEY member_event_responses_event_member (event_id, member_id),
|
||||
INDEX member_event_responses_club_event (club_id, event_id)
|
||||
);
|
||||
```
|
||||
|
||||
Die Daten enthalten keine Zahlungsdaten. Bestehende Vereins- und
|
||||
Mitgliedsdaten bleiben unverändert; die neuen Tabellen werden erst durch die
|
||||
persönlichen Rückmeldungen bzw. Änderungsanfragen befüllt.
|
||||
|
||||
## 2026-07-24
|
||||
|
||||
### `club_communication_recipients.read_at`
|
||||
|
||||
Persönlicher Gelesen-Status für Nachrichten. Er wird ausschließlich für das
|
||||
jeweilige Empfänger-Mitglied erfasst; die Kommunikationsverwaltung kann daraus
|
||||
keine Kontakt- oder Zahlungsdaten ableiten.
|
||||
|
||||
```sql
|
||||
ALTER TABLE club_communication_recipients
|
||||
ADD COLUMN read_at DATETIME NULL AFTER delivered_at,
|
||||
ADD INDEX club_communication_recipients_member_read (club_id, member_id, read_at);
|
||||
```
|
||||
|
||||
## 2026-03-17
|
||||
|
||||
### `predefined_activities.exclude_from_stats`
|
||||
|
||||
46
docs/member-pilot-checklist.md
Normal file
46
docs/member-pilot-checklist.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Pilot-Checkliste: persönlicher Mitgliederbereich
|
||||
|
||||
## Ziel und Rahmen
|
||||
|
||||
Mit 3–5 echten Mitgliedern auf unterschiedlichen Geräten prüfen. Keine
|
||||
Produktivdaten, Bankdaten oder Screenshots von Nachrichten an unbeteiligte
|
||||
Personen weitergeben.
|
||||
|
||||
## Rollen- und Rechte-Smoke-Test
|
||||
|
||||
| Rolle | Erwartetes Ergebnis |
|
||||
| --- | --- |
|
||||
| Mitglied | Kann ausschließlich `/my-club` und eigene Daten, Termine, Nachrichten sowie Rückmeldungen nutzen. Kein Zugriff auf Finanzen, Archiv, Rollen, Mitgliederlisten oder fremde Nachrichten. |
|
||||
| Trainer | Sieht die erlaubten Trainings-/Terminwerkzeuge und Rückmeldungen entsprechend den Rollenrechten; kein Zugriff auf Finanz- und Rollenverwaltung ohne zusätzliche Rechte. |
|
||||
| Vorstand | Kann die Verwaltungsansichten, Profiländerungsanfragen und Kommunikationsvorgänge entsprechend seiner Rechte nutzen. |
|
||||
|
||||
Pro Rolle testen:
|
||||
|
||||
1. Login, Verein auswählen und Direktaufruf eines fremden/geschützten Pfads.
|
||||
2. Eigenes Profil, Termin-Zusage/Absage und Nachrichteneingang öffnen.
|
||||
3. Für ein Mitglied prüfen, dass die API keine fremde Nachricht oder Rückfrage
|
||||
zurückgibt (abweichende `recipientId` ausprobieren).
|
||||
|
||||
## Mobile Hauptabläufe
|
||||
|
||||
Auf mindestens einem Gerät mit 360–430 px Breite testen:
|
||||
|
||||
1. Erster Login: Onboarding verstehen und schließen.
|
||||
2. Profiländerung einreichen.
|
||||
3. Termin zu- oder absagen.
|
||||
4. Nachricht öffnen, als gelesen markieren und Rückfrage senden.
|
||||
5. Seite bei gedrosselter Verbindung neu laden: Lade-, Fehler- und
|
||||
Wiederholen-Zustände müssen verständlich sein.
|
||||
|
||||
## Feedbackbogen
|
||||
|
||||
Für jede Person erfassen:
|
||||
|
||||
- Gerät/Browser und Verbindungsart.
|
||||
- Konnte die Person Profil, Terminrückmeldung und Nachricht ohne Hilfe finden?
|
||||
- Unklare Begriffe oder fehlende Informationen.
|
||||
- Fehlermeldung, Zeitpunkt und reproduzierbarer Ablauf.
|
||||
- Freigabe für breiteren Rollout: ja / nein / mit Nachbesserung.
|
||||
|
||||
Nach dem Pilot die Ergebnisse im Vereinsvorgang dokumentieren und nur dann die
|
||||
letzte Phase-4-Checkbox im [Plan](./simple-user-plan.md) abhaken.
|
||||
@@ -19,48 +19,55 @@ Finanzen, Rollenverwaltung, Archiv oder interne Vorgänge zu sehen.
|
||||
|
||||
### Phase 1: Zugang und persönlicher Einstieg
|
||||
|
||||
- [ ] Rollenprofil `member` und Navigation gegen unzulässige Ziele auditieren.
|
||||
- [ ] Persönliches Dashboard für Termine, Training, Rückmeldungen und Vereinsnachrichten umsetzen.
|
||||
- [ ] Leere Zustände für fehlende Termine, Nachrichten und Rückmeldungen formulieren.
|
||||
- [ ] Persönliche Startseite als Standardziel nach Login festlegen.
|
||||
- [x] Rollenprofil `member` und Navigation gegen unzulässige Ziele auditieren.
|
||||
- [x] Persönliches Dashboard für Termine, Training, Rückmeldungen und Vereinsnachrichten umsetzen.
|
||||
- [x] Leere Zustände für fehlende Termine, Nachrichten und Rückmeldungen formulieren.
|
||||
- [x] Persönliche Startseite als Standardziel nach Login festlegen.
|
||||
|
||||
Abnahme:
|
||||
|
||||
- [ ] Ein Nutzer ohne Verwaltungsrechte sieht nur seinen persönlichen Bereich.
|
||||
- [ ] Kein sichtbarer Link führt zu einer anschließenden Berechtigungsfehlermeldung.
|
||||
- [x] Ein Nutzer ohne Verwaltungsrechte sieht nur seinen persönlichen Bereich.
|
||||
- [x] Kein sichtbarer Link führt zu einer anschließenden Berechtigungsfehlermeldung.
|
||||
|
||||
### Phase 2: Eigene Daten und Rückmeldungen
|
||||
|
||||
- [ ] Ansicht „Mein Profil“ für freigegebene Stammdaten bereitstellen.
|
||||
- [ ] Änderungsanfrage für Kontaktdaten statt direkter Änderung geschützter Felder umsetzen.
|
||||
- [ ] Zu- und Absagen für relevante Trainings oder Veranstaltungen ermöglichen.
|
||||
- [ ] Mitgliedschafts- und SEPA-Hinweise verständlich, aber ohne sensible Zahlungsdaten anzeigen.
|
||||
- [ ] Rückmeldungen für berechtigte Verantwortliche sichtbar machen.
|
||||
- [x] Ansicht „Mein Profil“ für freigegebene Stammdaten bereitstellen.
|
||||
- [x] Änderungsanfrage für Kontaktdaten statt direkter Änderung geschützter Felder umsetzen.
|
||||
- [x] Zu- und Absagen für relevante Trainings oder Veranstaltungen ermöglichen.
|
||||
- [x] Mitgliedschafts- und SEPA-Hinweise verständlich, aber ohne sensible Zahlungsdaten anzeigen.
|
||||
- [x] Rückmeldungen für berechtigte Verantwortliche sichtbar machen.
|
||||
|
||||
Abnahme:
|
||||
|
||||
- [ ] Kontaktdaten können sicher geändert bzw. zur Prüfung eingereicht werden.
|
||||
- [ ] Zu- und Absagen sind an der jeweiligen Veranstaltung nachvollziehbar.
|
||||
- [x] Kontaktdaten können sicher geändert bzw. zur Prüfung eingereicht werden.
|
||||
- [x] Zu- und Absagen sind an der jeweiligen Veranstaltung nachvollziehbar.
|
||||
|
||||
### Phase 3: Kommunikation und Informationen
|
||||
|
||||
- [ ] Persönlichen Nachrichteneingang bereitstellen.
|
||||
- [ ] Vereinsankündigungen nach Datum sortiert anzeigen.
|
||||
- [ ] Gelesen-/ungelesen-Status und einen klaren Rückfrageweg ergänzen.
|
||||
- [ ] Datenschutz- und Sichtbarkeitsregeln für Nachrichten und Kontaktinformationen prüfen.
|
||||
- [x] Persönlichen Nachrichteneingang bereitstellen.
|
||||
- [x] Vereinsankündigungen nach Datum sortiert anzeigen.
|
||||
- [x] Gelesen-/ungelesen-Status und einen klaren Rückfrageweg ergänzen.
|
||||
- [x] Datenschutz- und Sichtbarkeitsregeln für Nachrichten und Kontaktinformationen prüfen.
|
||||
|
||||
Abnahme:
|
||||
|
||||
- [ ] Neue Vereinsinformationen sind ohne Verwaltungsmenüs auffindbar.
|
||||
- [ ] Persönliche Nachrichten sind ausschließlich für den Empfänger sichtbar.
|
||||
- [x] Neue Vereinsinformationen sind ohne Verwaltungsmenüs auffindbar.
|
||||
- [x] Persönliche Nachrichten sind ausschließlich für den Empfänger sichtbar.
|
||||
|
||||
### Phase 4: Mobil, Qualität und Einführung
|
||||
|
||||
- [ ] Mobilansicht und langsame Verbindungen prüfen.
|
||||
- [ ] Berechtigungstest mit Mitglied, Trainer und Vorstand durchführen.
|
||||
- [ ] Onboarding für ersten Login, Profilprüfung und Rückmeldungen erstellen.
|
||||
- [x] Onboarding für ersten Login, Profilprüfung und Rückmeldungen erstellen.
|
||||
- [ ] Pilot mit wenigen echten Mitgliedern durchführen und Feedback auswerten.
|
||||
|
||||
Vorbereitet:
|
||||
|
||||
- Die persönliche Startseite stapelt ihre Inhalte auf kleinen Bildschirmen;
|
||||
Lade-, Fehler- und Wiederholen-Zustände sind vorhanden.
|
||||
- Eine konkrete [Pilot- und Rollen-Checkliste](./member-pilot-checklist.md)
|
||||
dokumentiert die noch mit echten Konten und Geräten auszuführenden Prüfungen.
|
||||
|
||||
Abnahme:
|
||||
|
||||
- [ ] Die Testrollen sehen jeweils nur die vorgesehenen Daten und Aktionen.
|
||||
@@ -74,10 +81,10 @@ Abnahme:
|
||||
|
||||
## Erste umsetzbare Tickets
|
||||
|
||||
1. [ ] Bestehende `member`-Berechtigungen und Navigation auditieren.
|
||||
2. [ ] Datenvertrag und Wireframe für das persönliche Dashboard festlegen.
|
||||
3. [ ] Dashboard mit Terminen und Nachrichten implementieren.
|
||||
4. [ ] Profil- und Kontaktdaten-Änderungsanfrage implementieren.
|
||||
5. [ ] Trainings- und Veranstaltungsrückmeldungen ergänzen.
|
||||
6. [ ] Nachrichteneingang und Benachrichtigungsstatus ergänzen.
|
||||
1. [x] Bestehende `member`-Berechtigungen und Navigation auditieren.
|
||||
2. [x] Datenvertrag und Wireframe für das persönliche Dashboard festlegen.
|
||||
3. [x] Dashboard mit Terminen und Nachrichten implementieren.
|
||||
4. [x] Profil- und Kontaktdaten-Änderungsanfrage implementieren.
|
||||
5. [x] Trainings- und Veranstaltungsrückmeldungen ergänzen.
|
||||
6. [x] Nachrichteneingang und Benachrichtigungsstatus ergänzen.
|
||||
7. [ ] Rollen- und Mobile-Smoke-Test mit Pilotgruppe durchführen.
|
||||
|
||||
@@ -34,6 +34,7 @@ const LogsView = () => import('./views/LogsView.vue');
|
||||
const ClickTtView = () => import('./views/ClickTtView.vue');
|
||||
const MemberTransferSettingsView = () => import('./views/MemberTransferSettingsView.vue');
|
||||
const PersonalSettings = () => import('./views/PersonalSettings.vue');
|
||||
const MemberHomeView = () => import('./views/MemberHomeView.vue');
|
||||
const OrdersView = () => import('./views/OrdersView.vue');
|
||||
const BillingView = () => import('./views/BillingView.vue');
|
||||
const ClubRequestsView = () => import('./views/ClubRequestsView.vue');
|
||||
@@ -192,6 +193,7 @@ const routes = [
|
||||
{ path: '/clicktt', name: 'clicktt', component: ClickTtView, meta: withMeta({ products: fullAppProducts }) },
|
||||
{ path: '/member-transfer-settings', name: 'member-transfer-settings', component: MemberTransferSettingsView, meta: withMeta({ products: fullAppProducts }) },
|
||||
{ path: '/personal-settings', name: 'personal-settings', component: PersonalSettings, meta: withMeta({ products: allProducts }) },
|
||||
{ path: '/my-club', name: 'member-home', component: MemberHomeView, meta: withMeta({ products: clubOnly }) },
|
||||
{ path: '/orders', name: 'orders', component: OrdersView, meta: withMeta({ products: allProducts }) },
|
||||
{ path: '/billing', name: 'billing', component: BillingView, meta: withMeta({ products: trainerOnly }) },
|
||||
{ path: '/club-requests', name: 'club-requests', component: ClubRequestsView, meta: withMeta({ products: clubOnly, permission: ['requests', 'read'] }) },
|
||||
|
||||
@@ -72,7 +72,7 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['defaultHomeRoute']),
|
||||
...mapGetters(['defaultHomeRoute', 'appProduct', 'userRole']),
|
||||
},
|
||||
methods: {
|
||||
// Dialog Helper Methods
|
||||
@@ -101,14 +101,19 @@ export default {
|
||||
this.confirmDialog.isOpen = false;
|
||||
},
|
||||
|
||||
...mapActions(['login']),
|
||||
...mapActions(['login', 'setCurrentClub']),
|
||||
async executeLogin() {
|
||||
try {
|
||||
const response = await apiClient.post('/auth/login', { email: this.email, password: this.password }, {
|
||||
timeout: 5000,
|
||||
});
|
||||
await this.login({ token: response.data.token, username: this.email });
|
||||
const redirectTarget = typeof this.$route.query.redirect === 'string' ? this.$route.query.redirect : this.defaultHomeRoute;
|
||||
const explicitRedirect = typeof this.$route.query.redirect === 'string' ? this.$route.query.redirect : '';
|
||||
let redirectTarget = explicitRedirect || this.defaultHomeRoute;
|
||||
if (!explicitRedirect && this.appProduct === 'club' && this.$store.state.clubs.length === 1) {
|
||||
await this.setCurrentClub(this.$store.state.clubs[0].id);
|
||||
if (this.userRole === 'member') redirectTarget = '/my-club';
|
||||
}
|
||||
this.$router.push(redirectTarget);
|
||||
} catch (error) {
|
||||
const message = safeErrorMessage(error, this.$t('auth.loginFailed'));
|
||||
|
||||
91
frontend/src/views/MemberHomeView.vue
Normal file
91
frontend/src/views/MemberHomeView.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<main class="member-home">
|
||||
<header class="card hero">
|
||||
<p class="eyebrow">Mein Verein</p>
|
||||
<h2>{{ profile ? `Hallo ${profile.firstName}` : 'Willkommen im Vereinsbereich' }}</h2>
|
||||
<p>{{ profile ? 'Hier findest du deine nächsten Vereinstermine.' : 'Dein Konto ist noch keinem Mitgliedsprofil zugeordnet.' }}</p>
|
||||
<router-link class="btn-secondary" to="/personal-settings">Persönliche Einstellungen</router-link>
|
||||
</header>
|
||||
<section v-if="showOnboarding" class="onboarding" aria-labelledby="onboarding-title">
|
||||
<div class="onboarding-copy">
|
||||
<p class="eyebrow">Erste Schritte</p>
|
||||
<h3 id="onboarding-title">Gut angekommen?</h3>
|
||||
<p>Mit diesen drei Dingen bist du im Vereinsbereich schnell auf dem Laufenden.</p>
|
||||
</div>
|
||||
<ol class="onboarding-steps">
|
||||
<li><span aria-hidden="true">1</span><div><strong>Profil prüfen</strong><small>Kontaktdaten aktuell halten</small></div></li>
|
||||
<li><span aria-hidden="true">2</span><div><strong>Termine zu- oder absagen</strong><small>Damit der Verein planen kann</small></div></li>
|
||||
<li><span aria-hidden="true">3</span><div><strong>Nachrichten lesen</strong><small>Wichtige Infos nicht verpassen</small></div></li>
|
||||
</ol>
|
||||
<button class="btn-primary onboarding-dismiss" type="button" @click="dismissOnboarding">Verstanden</button>
|
||||
</section>
|
||||
<section v-if="error" class="card state-banner state-banner-error"><p>{{ error }}</p><button class="btn-secondary" @click="load">Erneut versuchen</button></section>
|
||||
<section v-else-if="!profile && !loading" class="card state-banner"><h3>Mitgliedsprofil verbinden</h3><p>Bitte wende dich an die Vereinsverwaltung, damit dein Benutzerkonto mit deinem Mitgliedsprofil verknüpft wird.</p></section>
|
||||
<section v-if="profile" class="card inbox-card">
|
||||
<div class="section-header"><div><h3>Meine Nachrichten <span v-if="unreadCount" class="unread-badge">{{ unreadCount }}</span></h3><p>Persönliche Nachrichten und Vereinsankündigungen.</p></div><button class="btn-secondary" :disabled="inboxLoading" @click="loadInbox">{{ inboxLoading ? 'Lädt…' : 'Aktualisieren' }}</button></div>
|
||||
<p v-if="inboxError" class="state-banner state-banner-error">{{ inboxError }}</p><p v-else-if="inboxLoading" class="state-banner">Nachrichten werden geladen…</p><p v-else-if="inboxItems.length === 0" class="state-banner">Du hast aktuell keine Vereinsnachrichten.</p>
|
||||
<div v-else class="inbox-layout"><ul class="inbox-list"><li v-for="item in inboxItems" :key="item.recipientId" :class="{ selected: selectedInboxItem?.recipientId === item.recipientId, unread: !item.readAt }"><button @click="selectInboxItem(item)"><span class="inbox-subject">{{ item.subject }}</span><span class="inbox-date">{{ formatDate(item.sentAt) }}</span><span class="inbox-preview">{{ textPreview(item.preview) }}</span></button></li></ul><article v-if="selectedInboxItem" class="inbox-detail"><header><small>{{ selectedInboxItem.threadType === 'broadcast' ? 'Vereinsankündigung' : 'Nachricht' }} · {{ formatDate(selectedInboxItem.sentAt) }}</small><h4>{{ selectedInboxItem.subject }}</h4></header><div v-for="message in selectedInboxItem.messages" :key="message.id" :class="['message-bubble', message.direction]"><p>{{ message.body }}</p><small>{{ message.direction === 'inbound' ? 'Deine Rückfrage' : 'Vereinsnachricht' }} · {{ formatDate(message.createdAt) }}</small></div><form v-if="selectedInboxItem.canAskQuestion" class="question-form" @submit.prevent="sendQuestion"><label>Rückfrage an die Vereinsverwaltung<textarea v-model.trim="questionBody" rows="3" maxlength="4000" placeholder="Worum geht es?"></textarea></label><p v-if="questionError" class="form-error">{{ questionError }}</p><button class="btn-primary" :disabled="sendingQuestion">{{ sendingQuestion ? 'Wird gesendet…' : 'Rückfrage senden' }}</button></form><p v-else class="state-banner">Für diese Nachricht ist keine direkte Rückfrage vorgesehen.</p></article></div>
|
||||
</section>
|
||||
<section v-else class="card">
|
||||
<div class="section-header"><h3>Nächste Termine</h3><router-link to="/calendar">Kalender öffnen</router-link></div>
|
||||
<p v-if="loading" class="state-banner">Deine Termine werden geladen…</p>
|
||||
<p v-else-if="events.length === 0" class="state-banner">Für die nächste Zeit sind keine Vereinstermine eingetragen.</p>
|
||||
<ul v-else class="event-list"><li v-for="event in events" :key="event.id"><div><strong>{{ event.title }}</strong><span>{{ formatDate(event.startDate) }} · {{ event.location || 'Verein' }}</span></div><div class="response-actions"><small v-if="event.responseStatus" :class="['status-chip', event.responseStatus]">{{ event.responseStatus === 'attending' ? 'Zusage' : 'Absage' }}</small><button class="btn-secondary" :disabled="savingEventId === event.id" @click="respondToEvent(event, 'attending')">Ich komme</button><button class="btn-secondary" :disabled="savingEventId === event.id" @click="respondToEvent(event, 'declined')">Ich kann nicht</button></div></li></ul>
|
||||
</section>
|
||||
<section v-if="profile" class="card profile-card">
|
||||
<div class="section-header"><div><h3>Mein Profil</h3><p>Deine hinterlegten Kontaktdaten.</p></div><button class="btn-secondary" @click="showProfileForm = !showProfileForm">{{ showProfileForm ? 'Abbrechen' : 'Änderung einreichen' }}</button></div>
|
||||
<dl class="profile-grid"><div><dt>E-Mail</dt><dd>{{ profile.email || 'Nicht hinterlegt' }}</dd></div><div><dt>Telefon</dt><dd>{{ profile.phone || 'Nicht hinterlegt' }}</dd></div><div class="address"><dt>Adresse</dt><dd>{{ addressLabel }}</dd></div></dl>
|
||||
<form v-if="showProfileForm" class="change-form" @submit.prevent="submitProfileChange"><p>Änderungen werden erst nach Prüfung durch die Vereinsverwaltung übernommen.</p><label>E-Mail<input v-model.trim="profileForm.email" type="email"></label><label>Telefon<input v-model.trim="profileForm.phone" type="tel"></label><label>Straße<input v-model.trim="profileForm.street"></label><div class="form-row"><label>PLZ<input v-model.trim="profileForm.postalCode"></label><label>Ort<input v-model.trim="profileForm.city"></label></div><p v-if="profileFormError" class="form-error">{{ profileFormError }}</p><button class="btn-primary" :disabled="savingProfile">{{ savingProfile ? 'Wird eingereicht…' : 'Zur Prüfung einreichen' }}</button></form>
|
||||
<p v-if="openProfileChangeRequests.length" class="state-banner">{{ openProfileChangeRequests.length }} Änderungsanfrage{{ openProfileChangeRequests.length === 1 ? '' : 'n' }} wartet auf Prüfung.</p>
|
||||
</section>
|
||||
<section v-if="profile" class="card two-column membership-card">
|
||||
<div><h3>Mitgliedschaft</h3><p>{{ membershipStatus }}</p><small v-if="membership.joinedOn">Mitglied seit {{ formatDate(membership.joinedOn) }}</small></div>
|
||||
<div><h3>SEPA-Mandat</h3><p v-if="membership.hasActiveSepaMandate">Ein gültiges Mandat ist hinterlegt.</p><p v-else-if="membership.needsSepaMandate">Bitte wende dich wegen des SEPA-Mandats an die Vereinsverwaltung.</p><p v-else>Für dich ist aktuell kein SEPA-Hinweis erforderlich.</p><small>Kontodaten werden hier nicht angezeigt.</small></div>
|
||||
</section>
|
||||
<section v-if="profile && trainingDates.length" class="card">
|
||||
<h3>Meine Trainingstage</h3><ul class="event-list"><li v-for="training in trainingDates" :key="training.id"><div><strong>{{ formatDate(training.date) }}</strong><span>{{ training.trainingStart ? `Beginn ${timeLabel(training.trainingStart)} Uhr` : 'Trainingszeit folgt' }}</span></div><div class="response-actions"><small :class="['status-chip', training.attendanceStatus]">{{ trainingStatusLabel(training.attendanceStatus) }}</small><button class="btn-secondary" :disabled="savingTrainingId === training.id" @click="respondToTraining(training, 'present')">Ich komme</button><button class="btn-secondary" :disabled="savingTrainingId === training.id" @click="respondToTraining(training, 'excused')">Absagen</button></div></li></ul>
|
||||
</section>
|
||||
<section v-if="profile" class="card two-column">
|
||||
<div><h3>Meine Trainingsgruppen</h3><p v-if="trainingGroups.length === 0" class="state-banner">Du bist aktuell keiner Trainingsgruppe zugeordnet.</p><ul v-else class="simple-list"><li v-for="group in trainingGroups" :key="group.id"><strong>{{ group.name }}</strong><span>{{ trainingLabel(group) }}</span></li></ul></div>
|
||||
<div><h3>Aktuelle Nachrichten</h3><p v-if="messages.length === 0" class="state-banner">Es liegen keine neuen Vereinsnachrichten vor.</p><ul v-else class="simple-list"><li v-for="message in messages" :key="message.id"><strong>{{ message.subject }}</strong><span>{{ formatDate(message.sentAt) }}</span></li></ul></div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import apiClient from '../apiClient.js';
|
||||
import { safeLocalStorage } from '../utils/storage.js';
|
||||
export default {
|
||||
name: 'MemberHomeView',
|
||||
data: () => ({ loading: false, error: '', profile: null, events: [], trainingGroups: [], messages: [], trainingDates: [], membership: {}, openProfileChangeRequests: [], showProfileForm: false, profileForm: {}, profileFormError: '', savingProfile: false, savingEventId: null, savingTrainingId: null, inboxItems: [], inboxLoading: false, inboxError: '', unreadCount: 0, selectedInboxItem: null, questionBody: '', questionError: '', sendingQuestion: false, onboardingDismissed: true }),
|
||||
watch: { currentClub: { immediate: true, handler() { this.load(); } } },
|
||||
methods: {
|
||||
async load() {
|
||||
if (!this.currentClub) return;
|
||||
this.loading = true; this.error = '';
|
||||
try { const { data } = await apiClient.get(`/clubmembers/dashboard/${this.currentClub}`); this.profile = data.profile; this.events = data.upcomingEvents || []; this.trainingGroups = data.trainingGroups || []; this.messages = data.messages || []; this.trainingDates = data.upcomingTrainingDates || []; this.membership = data.membership || {}; this.openProfileChangeRequests = data.openProfileChangeRequests || []; this.profileForm = this.profile ? { email: this.profile.email || '', phone: this.profile.phone || '', street: this.profile.street || '', postalCode: this.profile.postalCode || '', city: this.profile.city || '' } : {}; this.onboardingDismissed = !this.profile || safeLocalStorage.getItem(this.onboardingStorageKey) === 'done'; if (this.profile) await this.loadInbox(); }
|
||||
catch (error) { this.error = error?.response?.data?.error || 'Dein Vereinsbereich konnte nicht geladen werden.'; }
|
||||
finally { this.loading = false; }
|
||||
},
|
||||
formatDate(value) { return value ? new Intl.DateTimeFormat('de-DE', { dateStyle: 'full' }).format(new Date(value)) : 'Termin folgt'; },
|
||||
trainingLabel(group) { const first = group.trainingTimes?.[0]; return first ? `Training am ${['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag'][first.weekday]} um ${String(first.startTime).slice(0,5)} Uhr` : 'Trainingszeit folgt'; },
|
||||
timeLabel(value) { return String(value).slice(0, 5); },
|
||||
trainingStatusLabel(status) { return ({ present: 'Zusage', excused: 'Abgesagt', cancelled: 'Abgesagt' })[status] || 'Zusage'; },
|
||||
async respondToEvent(event, status) { this.savingEventId = event.id; try { await apiClient.put(`/clubmembers/dashboard/${this.currentClub}/events/${event.id}/response`, { status }); event.responseStatus = status; } catch (error) { this.error = error?.response?.data?.error || 'Die Rückmeldung konnte nicht gespeichert werden.'; } finally { this.savingEventId = null; } },
|
||||
async respondToTraining(training, attendanceStatus) { this.savingTrainingId = training.id; try { await apiClient.put(`/clubmembers/dashboard/${this.currentClub}/training/${training.id}/attendance`, { attendanceStatus }); training.attendanceStatus = attendanceStatus; } catch (error) { this.error = error?.response?.data?.error || 'Die Trainingsrückmeldung konnte nicht gespeichert werden.'; } finally { this.savingTrainingId = null; } },
|
||||
async submitProfileChange() { this.profileFormError = ''; this.savingProfile = true; try { await apiClient.post(`/clubmembers/profile/${this.currentClub}/change-requests`, this.profileForm); this.showProfileForm = false; await this.load(); } catch (error) { this.profileFormError = error?.response?.data?.error || 'Die Änderungsanfrage konnte nicht eingereicht werden.'; } finally { this.savingProfile = false; } },
|
||||
async loadInbox() { if (!this.currentClub) return; this.inboxLoading = true; this.inboxError = ''; try { const { data } = await apiClient.get(`/clubmembers/inbox/${this.currentClub}`); this.inboxItems = data.items || []; this.unreadCount = data.unreadCount || 0; if (this.selectedInboxItem) this.selectedInboxItem = this.inboxItems.find((item) => item.recipientId === this.selectedInboxItem.recipientId) || null; } catch (error) { this.inboxError = error?.response?.data?.error || 'Nachrichten konnten nicht geladen werden.'; } finally { this.inboxLoading = false; } },
|
||||
async selectInboxItem(item) { this.selectedInboxItem = item; this.questionBody = ''; this.questionError = ''; if (!item.readAt) { try { const { data } = await apiClient.patch(`/clubmembers/inbox/${this.currentClub}/${item.recipientId}/read`); item.readAt = data.readAt; this.unreadCount = Math.max(0, this.unreadCount - 1); } catch (error) { this.inboxError = error?.response?.data?.error || 'Lesestatus konnte nicht gespeichert werden.'; } } },
|
||||
async sendQuestion() { this.questionError = ''; if (!this.questionBody) { this.questionError = 'Bitte formuliere deine Rückfrage.'; return; } this.sendingQuestion = true; try { const { data } = await apiClient.post(`/clubmembers/inbox/${this.currentClub}/${this.selectedInboxItem.recipientId}/questions`, { body: this.questionBody }); this.selectedInboxItem.messages.push(data); this.questionBody = ''; } catch (error) { this.questionError = error?.response?.data?.error || 'Rückfrage konnte nicht gesendet werden.'; } finally { this.sendingQuestion = false; } },
|
||||
textPreview(value) { return String(value || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120) || 'Nachricht öffnen'; },
|
||||
dismissOnboarding() { safeLocalStorage.setItem(this.onboardingStorageKey, 'done'); this.onboardingDismissed = true; },
|
||||
},
|
||||
computed: { ...mapGetters(['currentClub']), onboardingStorageKey() { return `memberHomeOnboarding:${this.currentClub || 'unknown'}`; }, showOnboarding() { return Boolean(this.profile) && !this.onboardingDismissed; }, addressLabel() { return [this.profile?.street, [this.profile?.postalCode, this.profile?.city].filter(Boolean).join(' ')].filter(Boolean).join(', ') || 'Nicht hinterlegt'; }, membershipStatus() { return this.membership.status === 'active' ? 'Aktive Mitgliedschaft' : (this.membership.status || 'Mitgliedschaft wird geprüft'); } },
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.member-home{max-width:900px;margin:0 auto;display:grid;gap:1rem}.hero{padding:1.5rem;background:linear-gradient(135deg,var(--primary-strong),var(--primary));color:#fff}.eyebrow{margin:0;font-weight:700;text-transform:uppercase;letter-spacing:.08em;font-size:.8rem}.hero h2{margin:.45rem 0}.hero p{max-width:58ch}.hero .btn-secondary{display:inline-block;text-decoration:none;background:#fff;color:var(--primary-strong);margin-top:.5rem}.card{padding:1.25rem}.onboarding{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,1.25fr) auto;gap:1rem;align-items:center;padding:1.25rem;border:1px solid color-mix(in srgb,var(--primary) 25%,var(--border-color));border-left:4px solid var(--primary);border-radius:.6rem;background:linear-gradient(115deg,color-mix(in srgb,var(--primary) 7%,#fff),#fff)}.onboarding h3,.onboarding-copy p{margin:.25rem 0}.onboarding-steps{list-style:none;display:grid;gap:.5rem;margin:0;padding:0}.onboarding-steps li{display:flex;align-items:center;gap:.55rem}.onboarding-steps li>span{display:grid;place-items:center;width:1.55rem;height:1.55rem;border-radius:50%;background:var(--primary);color:#fff;font-size:.8rem;font-weight:700}.onboarding-steps small{display:block;color:var(--text-secondary)}.section-header{display:flex;justify-content:space-between;gap:1rem;align-items:center}.section-header h3,.section-header p{margin:.1rem 0}.unread-badge{display:inline-grid;place-items:center;min-width:1.4rem;height:1.4rem;border-radius:1rem;background:var(--primary);color:#fff;font-size:.8rem;vertical-align:middle}.inbox-layout{display:grid;grid-template-columns:minmax(210px,.75fr) minmax(0,1.25fr);border-top:1px solid var(--border-color);margin-top:1rem}.inbox-list{list-style:none;margin:0;padding:0;border-right:1px solid var(--border-color)}.inbox-list li{border-bottom:1px solid var(--border-color)}.inbox-list button{width:100%;border:0;background:transparent;text-align:left;padding:.8rem;display:grid;gap:.2rem;cursor:pointer}.inbox-list .selected button{background:var(--surface-100)}.inbox-list .unread .inbox-subject{font-weight:800}.inbox-date,.inbox-preview,.inbox-detail small{color:var(--text-secondary);font-size:.82rem}.inbox-preview{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.inbox-detail{padding:1rem}.inbox-detail h4{margin:.25rem 0 1rem}.message-bubble{padding:.75rem;border-radius:.5rem;background:var(--surface-100);margin:.65rem 0}.message-bubble.inbound{background:#e8f2ff;margin-left:1.5rem}.message-bubble p{white-space:pre-wrap;margin:0 0 .4rem}.question-form{display:grid;gap:.55rem;margin-top:1rem;padding-top:1rem;border-top:1px solid var(--border-color)}.question-form label{display:grid;gap:.3rem;font-weight:600}.question-form textarea{width:100%;padding:.55rem;border:1px solid var(--border-color);border-radius:.35rem;resize:vertical}.event-list{list-style:none;margin:0;padding:0}.event-list li{display:flex;justify-content:space-between;gap:1rem;padding:1rem 0;border-top:1px solid var(--border-color)}.event-list span{display:block;color:var(--text-secondary)}.response-actions{display:flex;flex-wrap:wrap;gap:.45rem;align-items:center}.response-actions .btn-secondary{font-size:.85rem}.status-chip{padding:.2rem .5rem;border-radius:999px;background:var(--surface-100);font-weight:600}.status-chip.attending,.status-chip.present{background:#dcfce7;color:#166534}.status-chip.declined,.status-chip.excused,.status-chip.cancelled{background:#fee2e2;color:#991b1b}.profile-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem;margin:1rem 0}.profile-grid .address{grid-column:1/-1}.profile-grid dt{font-size:.8rem;color:var(--text-secondary)}.profile-grid dd{margin:.2rem 0 0}.change-form{display:grid;gap:.8rem;border-top:1px solid var(--border-color);padding-top:1rem}.change-form label{display:grid;gap:.3rem;font-weight:600}.change-form input{width:100%;padding:.55rem;border:1px solid var(--border-color);border-radius:.35rem}.form-row{display:grid;grid-template-columns:1fr 2fr;gap:.75rem}.form-error{color:#b91c1c;margin:0}.membership-card p{margin:.4rem 0}.membership-card small{color:var(--text-secondary)}@media(max-width:700px){.onboarding{grid-template-columns:1fr;align-items:start}.onboarding-dismiss{justify-self:start}}@media(max-width:600px){.event-list li,.section-header{flex-direction:column;align-items:flex-start}.two-column,.profile-grid,.form-row,.inbox-layout{grid-template-columns:1fr}.inbox-list{border-right:0;border-bottom:1px solid var(--border-color)}}
|
||||
.two-column{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem}.two-column h3{margin-top:0}.simple-list{list-style:none;margin:0;padding:0}.simple-list li{display:grid;gap:.25rem;padding:.75rem 0;border-top:1px solid var(--border-color)}.simple-list span{color:var(--text-secondary);font-size:.9rem}
|
||||
</style>
|
||||
Reference in New Issue
Block a user