feat: Implement member inbox with read status and question functionality
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 53s

This commit is contained in:
Torsten Schulz (local)
2026-07-24 09:17:57 +02:00
parent 63a5db1196
commit e2e8ba0d54
9 changed files with 211 additions and 17 deletions

View File

@@ -6,8 +6,26 @@
<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>
@@ -37,15 +55,16 @@
<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 }),
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 || '' } : {}; }
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; }
},
@@ -56,12 +75,17 @@ export default {
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']), 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'); } },
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}.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}}
.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>