feat: Implement member self-service features for profile change requests and event responses

- Added MemberProfileChangeRequest and MemberEventResponse models with associations.
- Created API endpoints for member dashboard, profile change requests, event responses, and training attendance updates.
- Developed MemberHomeView component to display member-specific information and actions.
- Updated routing to include new member dashboard and profile change request functionalities.
- Added SQL migration scripts for new database tables related to member self-service.
- Enhanced login functionality to redirect members based on their roles and club associations.
This commit is contained in:
Torsten Schulz (local)
2026-07-24 07:53:05 +02:00
parent 3ac4039097
commit 63a5db1196
12 changed files with 398 additions and 21 deletions

View File

@@ -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'] }) },

View File

@@ -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'));

View File

@@ -0,0 +1,67 @@
<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="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-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';
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 }),
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 || '' } : {}; }
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; } },
},
computed: { ...mapGetters(['currentClub']), 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}.section-header{display:flex;justify-content:space-between;gap:1rem;align-items:center}.section-header h3,.section-header p{margin:.1rem 0}.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:600px){.event-list li,.section-header{flex-direction:column;align-items:flex-start}.two-column,.profile-grid,.form-row{grid-template-columns:1fr}}
.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>