Implement cross-club friendly match concept with invitations and shared matches
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 49s

- Added controllers for handling friendly match invitations and shared matches.
- Created migration scripts for `friendly_match_invitation` and `friendly_match_shared` tables.
- Developed models for `FriendlyMatchInvitation` and `FriendlyMatchShared`.
- Established routes for managing invitations and shared matches.
- Implemented services for business logic related to invitations and shared matches.
- Documented the concept plan for the new feature including API endpoints and data models.
This commit is contained in:
Torsten Schulz (local)
2026-05-30 17:50:35 +02:00
parent 359527eb5b
commit 0ff67dae80
21 changed files with 1795 additions and 17 deletions

View File

@@ -320,6 +320,36 @@ export const onMatchReportSubmitted = (callback) => {
}
};
export const onFriendlyInvitationCreated = (callback) => {
if (socket) {
socket.on('friendly:invitation:created', callback);
}
};
export const onFriendlyInvitationAccepted = (callback) => {
if (socket) {
socket.on('friendly:invitation:accepted', callback);
}
};
export const onFriendlyInvitationDeclined = (callback) => {
if (socket) {
socket.on('friendly:invitation:declined', callback);
}
};
export const onFriendlySharedMatchUpdated = (callback) => {
if (socket) {
socket.on('friendly:shared:match:updated', callback);
}
};
export const onFriendlySharedMatchDeleted = (callback) => {
if (socket) {
socket.on('friendly:shared:match:deleted', callback);
}
};
// Event-Listener entfernen
export const offParticipantAdded = (callback) => {
if (socket) {
@@ -423,3 +453,33 @@ export const offMatchReportSubmitted = (callback) => {
}
};
export const offFriendlyInvitationCreated = (callback) => {
if (socket) {
socket.off('friendly:invitation:created', callback);
}
};
export const offFriendlyInvitationAccepted = (callback) => {
if (socket) {
socket.off('friendly:invitation:accepted', callback);
}
};
export const offFriendlyInvitationDeclined = (callback) => {
if (socket) {
socket.off('friendly:invitation:declined', callback);
}
};
export const offFriendlySharedMatchUpdated = (callback) => {
if (socket) {
socket.off('friendly:shared:match:updated', callback);
}
};
export const offFriendlySharedMatchDeleted = (callback) => {
if (socket) {
socket.off('friendly:shared:match:deleted', callback);
}
};

View File

@@ -43,6 +43,43 @@
@update:active-tab="activeTab = $event"
>
<template #schedule-panel>
<div v-if="friendlyOnly" class="friendly-invitations-card">
<div class="friendly-invitations-header">
<strong>Vereinsuebergreifende Einladungen</strong>
<button type="button" class="btn-secondary" @click="openFriendlyInvitationDialog">Verein einladen</button>
</div>
<div class="friendly-invitations-grid">
<div>
<h4>Eingehend ({{ incomingFriendlyInvitations.length }})</h4>
<ul v-if="incomingFriendlyInvitations.length" class="friendly-invitation-list">
<li v-for="invitation in incomingFriendlyInvitations" :key="`incoming-${invitation.id}`">
<div class="friendly-invitation-main">
<span><strong>{{ getClubNameById(invitation.fromClubId) }}</strong> · {{ invitation.proposedDate }} {{ invitation.proposedStartTime || '' }}</span>
<small>{{ invitation.proposedMatchName }}</small>
</div>
<div class="friendly-invitation-actions">
<button type="button" class="btn-save" @click="acceptFriendlyInvitation(invitation.id)">Annehmen</button>
<button type="button" class="btn-cancel" @click="declineFriendlyInvitation(invitation.id)">Ablehnen</button>
</div>
</li>
</ul>
<p v-else class="friendly-invitation-empty">Keine eingehenden Einladungen.</p>
</div>
<div>
<h4>Ausgehend ({{ outgoingFriendlyInvitations.length }})</h4>
<ul v-if="outgoingFriendlyInvitations.length" class="friendly-invitation-list">
<li v-for="invitation in outgoingFriendlyInvitations" :key="`outgoing-${invitation.id}`">
<div class="friendly-invitation-main">
<span><strong>{{ getClubNameById(invitation.toClubId) }}</strong> · {{ invitation.proposedDate }} {{ invitation.proposedStartTime || '' }}</span>
<small>{{ invitation.proposedMatchName }}</small>
</div>
<span class="friendly-invitation-status">{{ invitation.status || 'pending' }}</span>
</li>
</ul>
<p v-else class="friendly-invitation-empty">Keine ausgehenden Einladungen.</p>
</div>
</div>
</div>
<div v-if="selectedTeam" class="league-match-scope-card">
<div class="league-match-scope-header">
<strong>{{ $t('schedule.matchOverviewTitle') }}</strong>
@@ -460,6 +497,46 @@
</div>
</BaseDialog>
<BaseDialog
v-model="friendlyInvitationDialog.isOpen"
title="Verein einladen"
:max-width="560"
@close="closeFriendlyInvitationDialog"
>
<div class="friendly-invitation-form">
<div class="friendly-form-grid">
<label>Zielverein
<select v-model="friendlyInvitationDialog.form.toClubId">
<option value="">Bitte Verein wählen</option>
<option
v-for="club in friendlyInvitationTargetClubs"
:key="club.id"
:value="club.id"
>
{{ club.name }}
</option>
</select>
</label>
<label>Datum
<input v-model="friendlyInvitationDialog.form.date" type="date" />
</label>
<label>Startzeit
<input v-model="friendlyInvitationDialog.form.startTime" type="time" />
</label>
<label>Matchname
<input v-model="friendlyInvitationDialog.form.matchName" type="text" placeholder="z. B. Freundschaftsspiel Herren 1" />
</label>
</div>
<label class="friendly-invitation-message">Nachricht (optional)
<textarea v-model="friendlyInvitationDialog.form.message" rows="3" placeholder="Kurze Nachricht an den Zielverein"></textarea>
</label>
<div class="dialog-actions">
<button type="button" class="btn-save" @click="saveFriendlyInvitation">Einladung senden</button>
<button type="button" class="btn-cancel" @click="closeFriendlyInvitationDialog">{{ $t('schedule.cancel') }}</button>
</div>
</div>
</BaseDialog>
<BaseDialog
v-model="friendlyMatchDialog.isOpen"
:title="friendlyMatchDialog.editingId ? 'Freundschaftsspiel bearbeiten' : 'Freundschaftsspiel anlegen'"
@@ -531,7 +608,17 @@ import {
onScheduleMatchUpdated,
offScheduleMatchUpdated,
onMatchReportSubmitted,
offMatchReportSubmitted
offMatchReportSubmitted,
onFriendlyInvitationCreated,
offFriendlyInvitationCreated,
onFriendlyInvitationAccepted,
offFriendlyInvitationAccepted,
onFriendlyInvitationDeclined,
offFriendlyInvitationDeclined,
onFriendlySharedMatchUpdated,
offFriendlySharedMatchUpdated,
onFriendlySharedMatchDeleted,
offFriendlySharedMatchDeleted
} from '../services/socketService.js';
export default {
name: 'ScheduleView',
@@ -606,6 +693,9 @@ export default {
friendlyMatchesLabel() {
return 'Freundschaftsspiele';
},
friendlyInvitationTargetClubs() {
return (this.clubs || []).filter((club) => Number(club.id) !== Number(this.currentClub));
},
},
watch: {
currentClub: {
@@ -613,13 +703,24 @@ export default {
handler(newVal) {
offScheduleMatchUpdated(this.handleScheduleMatchUpdated);
offMatchReportSubmitted(this.handleMatchReportSubmitted);
offFriendlyInvitationCreated(this.handleFriendlyInvitationRealtime);
offFriendlyInvitationAccepted(this.handleFriendlyInvitationRealtime);
offFriendlyInvitationDeclined(this.handleFriendlyInvitationRealtime);
offFriendlySharedMatchUpdated(this.handleFriendlySharedMatchUpdatedRealtime);
offFriendlySharedMatchDeleted(this.handleFriendlySharedMatchDeletedRealtime);
disconnectSocket();
if (newVal) {
connectSocket(newVal);
onScheduleMatchUpdated(this.handleScheduleMatchUpdated);
onMatchReportSubmitted(this.handleMatchReportSubmitted);
onFriendlyInvitationCreated(this.handleFriendlyInvitationRealtime);
onFriendlyInvitationAccepted(this.handleFriendlyInvitationRealtime);
onFriendlyInvitationDeclined(this.handleFriendlyInvitationRealtime);
onFriendlySharedMatchUpdated(this.handleFriendlySharedMatchUpdatedRealtime);
onFriendlySharedMatchDeleted(this.handleFriendlySharedMatchDeletedRealtime);
if (this.friendlyOnly) {
this.loadFriendlyMatches();
this.loadFriendlyInvitations();
}
}
}
@@ -711,9 +812,110 @@ export default {
saving: false,
saveAgain: false
},
incomingFriendlyInvitations: [],
outgoingFriendlyInvitations: [],
friendlyInvitationDialog: {
isOpen: false,
form: {
toClubId: '',
date: new Date().toISOString().slice(0, 10),
startTime: '',
matchName: `Freundschaftsspiel ${new Date().toISOString().slice(0, 10)}`,
message: '',
}
},
};
},
methods: {
getClubNameById(clubId) {
const club = (this.clubs || []).find((item) => Number(item.id) === Number(clubId));
return club?.name || `Verein ${clubId}`;
},
resetFriendlyInvitationDialogForm() {
this.friendlyInvitationDialog.form = {
toClubId: '',
date: new Date().toISOString().slice(0, 10),
startTime: '',
matchName: `Freundschaftsspiel ${new Date().toISOString().slice(0, 10)}`,
message: '',
};
},
async openFriendlyInvitationDialog() {
if (!this.friendlyInvitationTargetClubs.length) {
await this.showInfo('Hinweis', 'Keine weiteren Vereine vorhanden.', '', 'info');
return;
}
this.resetFriendlyInvitationDialogForm();
this.friendlyInvitationDialog.isOpen = true;
},
closeFriendlyInvitationDialog() {
this.friendlyInvitationDialog.isOpen = false;
this.resetFriendlyInvitationDialogForm();
},
async saveFriendlyInvitation() {
const form = this.friendlyInvitationDialog.form;
const toClubId = Number.parseInt(form.toClubId, 10);
if (!Number.isInteger(toClubId)) {
await this.showInfo('Hinweis', 'Bitte einen Zielverein auswählen.', '', 'info');
return;
}
if (!String(form.date || '').trim()) {
await this.showInfo('Hinweis', 'Bitte ein Datum angeben.', '', 'info');
return;
}
if (!String(form.matchName || '').trim()) {
await this.showInfo('Hinweis', 'Bitte einen Matchnamen angeben.', '', 'info');
return;
}
try {
await apiClient.post(`/friendly-match-invitations/${this.currentClub}`, {
toClubId,
date: form.date,
startTime: String(form.startTime || '').trim() || null,
matchName: String(form.matchName || '').trim(),
message: String(form.message || '').trim() || null,
});
this.closeFriendlyInvitationDialog();
await this.loadFriendlyInvitations();
await this.showInfo('Erfolg', 'Einladung wurde versendet.', '', 'success');
} catch (error) {
await this.showInfo('Fehler', getSafeErrorMessage(error, 'Einladung konnte nicht gespeichert werden.'), '', 'error');
}
},
async loadFriendlyInvitations() {
try {
const [incomingResponse, outgoingResponse] = await Promise.all([
apiClient.get(`/friendly-match-invitations/${this.currentClub}/incoming`),
apiClient.get(`/friendly-match-invitations/${this.currentClub}/outgoing`),
]);
const incoming = Array.isArray(incomingResponse.data) ? incomingResponse.data : [];
const outgoing = Array.isArray(outgoingResponse.data) ? outgoingResponse.data : [];
this.incomingFriendlyInvitations = incoming.filter((invitation) => String(invitation?.status || 'pending') === 'pending');
this.outgoingFriendlyInvitations = outgoing.filter((invitation) => String(invitation?.status || 'pending') === 'pending');
} catch (error) {
console.error('Error loading friendly invitations:', error);
}
},
async acceptFriendlyInvitation(invitationId) {
try {
await apiClient.post(`/friendly-match-invitations/${this.currentClub}/${invitationId}/accept`);
this.incomingFriendlyInvitations = this.incomingFriendlyInvitations.filter((invitation) => invitation.id !== invitationId);
this.outgoingFriendlyInvitations = this.outgoingFriendlyInvitations.filter((invitation) => invitation.id !== invitationId);
await Promise.all([this.loadFriendlyInvitations(), this.loadFriendlyMatches()]);
} catch (error) {
await this.showInfo('Fehler', getSafeErrorMessage(error, 'Einladung konnte nicht angenommen werden.'), '', 'error');
}
},
async declineFriendlyInvitation(invitationId) {
try {
await apiClient.post(`/friendly-match-invitations/${this.currentClub}/${invitationId}/decline`);
this.incomingFriendlyInvitations = this.incomingFriendlyInvitations.filter((invitation) => invitation.id !== invitationId);
this.outgoingFriendlyInvitations = this.outgoingFriendlyInvitations.filter((invitation) => invitation.id !== invitationId);
await this.loadFriendlyInvitations();
} catch (error) {
await this.showInfo('Fehler', getSafeErrorMessage(error, 'Einladung konnte nicht abgelehnt werden.'), '', 'error');
}
},
emptyFriendlyMatchForm() {
const today = new Date().toISOString().slice(0, 10);
return {
@@ -1025,7 +1227,7 @@ export default {
try {
const response = match.isFriendly
? await apiClient.patch(`/friendly-matches/${this.currentClub}/${match.id}/players`, {
? await apiClient.patch(`${match.isSharedFriendly ? `/friendly-matches/shared/${this.currentClub}/${match.id}/players` : `/friendly-matches/${this.currentClub}/${match.id}/players`}`, {
playersReady,
playersPlanned,
playersPlayed
@@ -1438,7 +1640,7 @@ export default {
const score = this.calculateFriendlyResultScore(this.friendlyResultDialog.rows);
try {
this.friendlyResultDialog.saving = true;
await apiClient.put(`/friendly-matches/${this.currentClub}/${match.id}`, {
await apiClient.put(`${match.isSharedFriendly ? `/friendly-matches/shared/${this.currentClub}/${match.id}` : `/friendly-matches/${this.currentClub}/${match.id}`}`, {
homeMatchPoints: score.home,
guestMatchPoints: score.guest,
isCompleted,
@@ -1472,7 +1674,12 @@ export default {
};
const id = this.friendlyMatchDialog.editingId;
if (id) {
await apiClient.put(`/friendly-matches/${this.currentClub}/${id}`, payload);
const existingMatch = this.matches.find((item) => Number(item.id) === Number(id));
if (existingMatch?.isSharedFriendly) {
await apiClient.put(`/friendly-matches/shared/${this.currentClub}/${id}`, payload);
} else {
await apiClient.put(`/friendly-matches/${this.currentClub}/${id}`, payload);
}
} else {
await apiClient.post(`/friendly-matches/${this.currentClub}`, payload);
}
@@ -1504,7 +1711,7 @@ export default {
homeTeamName: match.homeTeam?.name || '',
guestTeamName: match.guestTeam?.name || ''
};
await apiClient.put(`/friendly-matches/${this.currentClub}/${match.id}`, payload);
await apiClient.put(`${match.isSharedFriendly ? `/friendly-matches/shared/${this.currentClub}/${match.id}` : `/friendly-matches/${this.currentClub}/${match.id}`}`, payload);
} catch (error) {
console.error('toggleHomeAway error:', error);
// Revert optimistic change
@@ -1521,7 +1728,12 @@ export default {
const confirmed = await this.showConfirm('Freundschaftsspiel löschen', 'Soll dieses Freundschaftsspiel gelöscht werden?', '', 'warning');
if (!confirmed) return;
try {
await apiClient.delete(`/friendly-matches/${this.currentClub}/${this.friendlyMatchDialog.editingId}`);
const target = this.matches.find((item) => Number(item.id) === Number(this.friendlyMatchDialog.editingId));
if (target?.isSharedFriendly) {
await apiClient.delete(`/friendly-matches/shared/${this.currentClub}/${this.friendlyMatchDialog.editingId}`);
} else {
await apiClient.delete(`/friendly-matches/${this.currentClub}/${this.friendlyMatchDialog.editingId}`);
}
this.closeFriendlyMatchDialog();
await this.loadFriendlyMatches();
} catch (error) {
@@ -1786,8 +1998,11 @@ export default {
this.activeTab = 'schedule';
this.leagueTable = [];
try {
const response = await apiClient.get(`/friendly-matches/${this.currentClub}`);
this.friendlyMatches = response.data || [];
const [localResponse, sharedResponse] = await Promise.all([
apiClient.get(`/friendly-matches/${this.currentClub}`),
apiClient.get(`/friendly-matches/shared/${this.currentClub}`),
]);
this.friendlyMatches = [...(localResponse.data || []), ...(sharedResponse.data || [])];
this.matches = this.sortMatchesByDateTime(this.friendlyMatches);
} catch (error) {
this.showInfo(this.$t('messages.error'), getSafeErrorMessage(error, 'Freundschaftsspiele konnten nicht geladen werden.'), '', 'error');
@@ -2096,10 +2311,30 @@ export default {
}
this.refreshScheduleData();
},
handleFriendlyInvitationRealtime() {
if (!this.friendlyOnly) return;
this.loadFriendlyInvitations();
},
handleFriendlySharedMatchUpdatedRealtime(payload) {
if (!this.friendlyOnly || !payload?.match) return;
const idx = this.matches.findIndex((match) => match.id === payload.match.id);
if (idx !== -1) {
this.matches.splice(idx, 1, payload.match);
} else {
this.matches.push(payload.match);
}
this.friendlyMatches = [...this.matches];
this.matches = this.sortMatchesByDateTime(this.matches);
},
handleFriendlySharedMatchDeletedRealtime(payload) {
if (!this.friendlyOnly || payload?.matchId == null) return;
this.matches = this.matches.filter((match) => match.id !== payload.matchId);
this.friendlyMatches = [...this.matches];
},
},
async created() {
if (this.friendlyOnly) {
await this.loadFriendlyMatches();
await Promise.all([this.loadFriendlyMatches(), this.loadFriendlyInvitations()]);
return;
}
this.loadTeams();
@@ -2107,6 +2342,11 @@ export default {
beforeUnmount() {
offScheduleMatchUpdated(this.handleScheduleMatchUpdated);
offMatchReportSubmitted(this.handleMatchReportSubmitted);
offFriendlyInvitationCreated(this.handleFriendlyInvitationRealtime);
offFriendlyInvitationAccepted(this.handleFriendlyInvitationRealtime);
offFriendlyInvitationDeclined(this.handleFriendlyInvitationRealtime);
offFriendlySharedMatchUpdated(this.handleFriendlySharedMatchUpdatedRealtime);
offFriendlySharedMatchDeleted(this.handleFriendlySharedMatchDeletedRealtime);
disconnectSocket();
},
};
@@ -2307,6 +2547,89 @@ td {
font-weight: 600;
}
.friendly-invitations-card {
border: 1px solid #dbe3ea;
border-radius: 10px;
background: #f9fcff;
padding: 0.85rem;
margin-bottom: 1rem;
}
.friendly-invitations-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
}
.friendly-invitations-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
margin-top: 0.75rem;
}
.friendly-invitation-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.friendly-invitation-list li {
display: flex;
justify-content: space-between;
gap: 0.75rem;
padding: 0.55rem 0.6rem;
border: 1px solid #e5ecf2;
border-radius: 8px;
background: #fff;
}
.friendly-invitation-main {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.friendly-invitation-actions {
display: flex;
gap: 0.4rem;
}
.friendly-invitation-empty {
color: #64748b;
margin: 0.4rem 0 0;
}
.friendly-invitation-status {
align-self: center;
color: #475569;
}
.friendly-invitation-form {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.friendly-invitation-message {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.friendly-invitation-message textarea {
width: 100%;
box-sizing: border-box;
padding: 0.45rem 0.55rem;
border: 1px solid var(--border-color, #ddd);
border-radius: 6px;
resize: vertical;
}
.modal {
display: flex;
justify-content: center;