feat: Implement friendly match management features
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s

- Added backend support for managing friendly matches including listing, creating, updating, and deleting matches.
- Introduced a new database table `friendly_match` with relevant fields for match details.
- Created a service layer to handle business logic related to friendly matches.
- Developed API routes for friendly match operations with appropriate authentication and authorization.
- Added a Vue component for managing participants in friendly matches, allowing selection of members and manual entry of names.
- Updated existing tournament editor screens to integrate friendly match functionalities.
This commit is contained in:
Torsten Schulz (local)
2026-05-18 00:43:42 +02:00
parent 040e758044
commit 5dfdcb63bc
16 changed files with 1551 additions and 87 deletions

View File

@@ -113,6 +113,10 @@
<span class="nav-icon">📅</span>
{{ $t('navigation.schedule') }}
</router-link>
<router-link v-if="hasPermission('schedule', 'read')" to="/friendly-matches" class="nav-link" title="Freundschaftsspiele">
<span class="nav-icon">🤝</span>
Freundschaftsspiele
</router-link>
</div>
<div class="nav-section">
@@ -292,7 +296,7 @@ export default {
return this.isClubOwner || this.userRole === 'admin' || this.hasPermission('permissions', 'read');
},
isFullHeightRoute() {
return this.$route?.name === 'schedule';
return this.$route?.name === 'schedule' || this.$route?.name === 'friendly-matches';
},
viewReloadKey() {
return `${this.$route.fullPath}|${this.currentClub || 'no-club'}`;

View File

@@ -0,0 +1,107 @@
<template>
<section class="friendly-participant-column">
<h4>{{ title }}</h4>
<div class="friendly-add-row">
<select v-model="selectedMemberId">
<option value="">Mitglied auswählen</option>
<option v-for="member in members" :key="member.id" :value="member.id">
{{ member.firstName }} {{ member.lastName }}
</option>
</select>
<button type="button" @click="addSelectedMember">Hinzufügen</button>
</div>
<div class="friendly-add-row">
<input v-model="manualName" type="text" placeholder="Manueller Name" @keyup.enter="addManual" />
<button type="button" @click="addManual">Hinzufügen</button>
</div>
<ul class="friendly-participant-list">
<li v-for="(participant, index) in participants" :key="index">
<span>{{ participantLabel(participant) }}</span>
<button type="button" @click="$emit('remove', index)">x</button>
</li>
</ul>
</section>
</template>
<script>
export default {
name: 'FriendlyParticipantsColumn',
props: {
title: { type: String, required: true },
members: { type: Array, required: true },
participants: { type: Array, required: true }
},
emits: ['add-member', 'add-manual', 'remove'],
data() {
return {
selectedMemberId: '',
manualName: ''
};
},
methods: {
memberLabel(id) {
const member = this.members.find(m => Number(m.id) === Number(id));
return member ? `${member.firstName} ${member.lastName}`.trim() : `Mitglied #${id}`;
},
participantLabel(participant) {
if (participant.type === 'member') return this.memberLabel(participant.memberId);
return `${participant.firstName || ''} ${participant.lastName || ''}`.trim();
},
addSelectedMember() {
if (!this.selectedMemberId) return;
this.$emit('add-member', Number(this.selectedMemberId));
this.selectedMemberId = '';
},
addManual() {
const value = this.manualName.trim();
if (!value) return;
this.$emit('add-manual', value);
this.manualName = '';
}
}
};
</script>
<style scoped>
.friendly-participant-column {
display: flex;
flex-direction: column;
gap: 0.35rem;
border: 1px solid var(--border-color, #ddd);
border-radius: 8px;
padding: 0.75rem;
}
.friendly-add-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.5rem;
}
.friendly-add-row input,
.friendly-add-row select {
width: 100%;
box-sizing: border-box;
padding: 0.45rem 0.55rem;
border: 1px solid var(--border-color, #ddd);
border-radius: 6px;
}
.friendly-participant-list {
list-style: none;
padding: 0;
margin: 0.5rem 0 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.friendly-participant-list li {
display: flex;
justify-content: space-between;
gap: 0.5rem;
padding: 0.35rem 0.5rem;
background: var(--background-soft, #f7f7f7);
border-radius: 6px;
}
</style>

View File

@@ -3,17 +3,19 @@
<div class="schedule-static-chrome">
<div class="schedule-page-header">
<div class="schedule-page-title">
<h2>{{ $t('schedule.title') }}</h2>
<p>{{ $t('schedule.subtitle') }}</p>
<h2>{{ resolvedTitle }}</h2>
<p>{{ resolvedSubtitle }}</p>
</div>
<div class="schedule-page-actions">
<SeasonSelector
v-if="showScheduleActions"
:model-value="selectedSeasonId"
:show-current-season="true"
@update:model-value="$emit('update:selected-season-id', $event)"
@season-change="$emit('season-change', $event)"
/>
<button @click="$emit('open-import-modal')">{{ $t('schedule.importSchedule') }}</button>
<button v-if="showScheduleActions" @click="$emit('open-import-modal')">{{ $t('schedule.importSchedule') }}</button>
<button v-if="showFriendlyActions" @click="$emit('open-friendly-match-modal')" class="btn-secondary">Freundschaftsspiel</button>
<button
v-if="showGalleryButton"
@click="$emit('open-gallery-dialog')"
@@ -50,7 +52,7 @@
</div>
<div class="output schedule-layout">
<aside class="schedule-sidebar">
<aside v-if="showSidebar" class="schedule-sidebar">
<div class="schedule-sidebar-card">
<div class="schedule-sidebar-header">
<h3>{{ $t('schedule.selection') }}</h3>
@@ -63,7 +65,7 @@
:placeholder="$t('schedule.searchTeams')"
@input="$emit('update:team-search-query', $event.target.value.trim())"
/>
<div class="schedule-quick-links">
<div v-if="showScheduleActions" class="schedule-quick-links">
<button
type="button"
class="schedule-quick-link"
@@ -131,7 +133,7 @@
</div>
</div>
<div v-if="selectedLeague && selectedLeague !== ''" class="tab-navigation">
<div v-if="selectedLeague && selectedLeague !== '' && showTableTab" class="tab-navigation">
<button :class="['tab-button', { active: activeTab === 'schedule' }]" @click="$emit('update:active-tab', 'schedule')">
📅 {{ $t('schedule.scheduleTab') }} <span class="tab-count">{{ matchesCount }}</span>
</button>
@@ -169,6 +171,8 @@ export default {
components: { SeasonSelector },
props: {
selectedSeasonId: { type: [Number, String, null], default: null },
title: { type: String, default: '' },
subtitle: { type: String, default: '' },
showGalleryButton: { type: Boolean, required: true },
galleryLoading: { type: Boolean, required: true },
filteredScheduleTeamsCount: { type: Number, required: true },
@@ -187,12 +191,17 @@ export default {
activeTab: { type: String, required: true },
tableCount: { type: Number, required: true },
fetchingTeamData: { type: Boolean, required: true },
fetchingTable: { type: Boolean, required: true }
fetchingTable: { type: Boolean, required: true },
showFriendlyActions: { type: Boolean, default: false },
showScheduleActions: { type: Boolean, default: true },
showSidebar: { type: Boolean, default: true },
showTableTab: { type: Boolean, default: true }
},
emits: [
'update:selected-season-id',
'season-change',
'open-import-modal',
'open-friendly-match-modal',
'open-gallery-dialog',
'update:team-search-query',
'load-all-matches',
@@ -202,7 +211,15 @@ export default {
'generate-pdf',
'fetch-table',
'update:active-tab'
]
],
computed: {
resolvedTitle() {
return this.title || this.$t('schedule.title');
},
resolvedSubtitle() {
return this.subtitle || this.$t('schedule.subtitle');
}
}
};
</script>

View File

@@ -55,6 +55,7 @@ const routes = [
{ path: '/calendar', name: 'calendar', component: CalendarView },
{ path: '/pending-approvals', name: 'pending-approvals', component: PendingApprovalsView},
{ path: '/schedule', name: 'schedule', component: ScheduleView},
{ path: '/friendly-matches', name: 'friendly-matches', component: ScheduleView, props: { friendlyOnly: true } },
{ path: '/tournaments', name: 'tournaments', component: TournamentsView },
{ path: '/tournament-participations', name: 'tournament-participations', component: OfficialTournaments },
{ path: '/training-stats', name: 'training-stats', component: TrainingStatsView },

View File

@@ -3,6 +3,8 @@
<ScheduleLayoutShell
ref="scheduleShell"
:selected-season-id="selectedSeasonId"
:title="friendlyOnly ? 'Freundschaftsspiele' : $t('schedule.title')"
:subtitle="friendlyOnly ? 'Interne Spiele ohne Click-TT-Verbindung verwalten.' : $t('schedule.subtitle')"
:show-gallery-button="Boolean(playerSelectionDialog.match)"
:gallery-loading="galleryLoading"
:filtered-schedule-teams-count="filteredScheduleTeams.length"
@@ -22,9 +24,14 @@
:table-count="leagueTable.length"
:fetching-team-data="fetchingTeamData"
:fetching-table="fetchingTable"
:show-friendly-actions="friendlyOnly"
:show-schedule-actions="!friendlyOnly"
:show-sidebar="!friendlyOnly"
:show-table-tab="!friendlyOnly"
@update:selected-season-id="selectedSeasonId = $event"
@season-change="onSeasonChange"
@open-import-modal="openImportModal"
@open-friendly-match-modal="openFriendlyMatchDialog"
@open-gallery-dialog="openGalleryDialog"
@update:team-search-query="teamSearchQuery = $event"
@load-all-matches="loadAllMatches"
@@ -89,13 +96,14 @@
<th>{{ $t('schedule.time') }}</th>
<th>{{ $t('schedule.homeTeam') }}</th>
<th>{{ $t('schedule.guestTeam') }}</th>
<th>{{ $t('schedule.result') }}</th>
<th v-if="!friendlyOnly">{{ $t('schedule.result') }}</th>
<th
v-if="selectedLeague === $t('schedule.overallSchedule') || selectedLeague === $t('schedule.adultSchedule')">
{{ $t('schedule.ageClass') }}</th>
<th>{{ $t('schedule.code') }}</th>
<th>{{ $t('schedule.homePin') }}</th>
<th>{{ $t('schedule.guestPin') }}</th>
<th v-if="!friendlyOnly">{{ $t('schedule.code') }}</th>
<th>{{ friendlyOnly ? 'Aktionen' : $t('schedule.homePin') }}</th>
<th v-if="!friendlyOnly">{{ $t('schedule.guestPin') }}</th>
<th v-if="friendlyOnly"></th>
</tr>
</thead>
<tbody>
@@ -123,7 +131,7 @@
<td :class="{ 'highlighted-club': isClubHighlighted(match.guestTeam?.name) }">
{{ match.guestTeam?.name || 'N/A' }}
</td>
<td class="result-cell" :class="getResultClass(match)">
<td v-if="!friendlyOnly" class="result-cell" :class="getResultClass(match)">
<span v-if="match.isCompleted" class="result-score">
{{ match.homeMatchPoints }}:{{ match.guestMatchPoints }}
</span>
@@ -132,7 +140,7 @@
<td
v-if="selectedLeague === $t('schedule.overallSchedule') || selectedLeague === $t('schedule.adultSchedule')">
{{ match.leagueDetails?.name || 'N/A' }}</td>
<td class="code-cell match-report-cell" @click.stop="openMatchReport(match)">
<td v-if="!friendlyOnly" class="code-cell match-report-cell" @click.stop="openMatchReport(match)">
<span v-if="match.code && selectedLeague && selectedLeague !== ''">
<button @click.stop="openMatchReport(match)" class="nuscore-link"
:title="$t('schedule.openMatchReport')">📊</button>
@@ -142,20 +150,30 @@
<span v-else-if="match.code" class="code-value clickable"
@click.stop="copyToClipboard(match.code, $t('schedule.code'), $event)"
:title="$t('schedule.copyCode') + ': ' + match.code">{{ match.code }}</span>
<span v-else class="no-data">-</span>
<span v-else-if="!match.isFriendly" class="no-data">-</span>
</td>
<td class="pin-cell">
<span v-if="match.homePin" class="pin-value clickable"
<div v-if="match.isFriendly" class="friendly-actions-cell">
<button type="button" class="btn-secondary" @click.stop="openFriendlyResultDialog(match)">Ergebnis</button>
<button type="button" class="btn-secondary" @click.stop="openFriendlyMatchDialog(match)">Bearbeiten</button>
</div>
<span v-else-if="match.homePin" class="pin-value clickable"
@click.stop="copyToClipboard(match.homePin, $t('schedule.homePin'), $event)"
:title="$t('schedule.copyHomePin') + ': ' + match.homePin">{{ match.homePin }}</span>
<span v-else class="no-data">-</span>
</td>
<td class="pin-cell">
<td v-if="!friendlyOnly" class="pin-cell">
<span v-if="match.guestPin" class="pin-value clickable"
@click.stop="copyToClipboard(match.guestPin, $t('schedule.guestPin'), $event)"
:title="$t('schedule.copyGuestPin') + ': ' + match.guestPin">{{ match.guestPin }}</span>
<span v-else class="no-data">-</span>
</td>
<td v-if="friendlyOnly" class="result-cell" :class="getResultClass(match)">
<span v-if="match.isCompleted" class="result-score">
{{ match.homeMatchPoints }}:{{ match.guestMatchPoints }}
</span>
<span v-else class="result-pending"></span>
</td>
</tr>
</tbody>
</table>
@@ -326,6 +344,67 @@
</div>
</BaseDialog>
<BaseDialog
v-model="friendlyResultDialog.isOpen"
:title="`Ergebniseingabe - ${friendlyResultDialog.match?.homeTeam?.name || ''} vs ${friendlyResultDialog.match?.guestTeam?.name || ''}`"
width="96vw"
max-width="1500px"
@close="closeFriendlyResultDialog"
>
<div class="friendly-result-dialog" v-if="friendlyResultDialog.match">
<div class="score-summary">
<div class="score-display">
<span class="score-label">Spielstand:</span>
<span class="score-value">{{ friendlyResultScore.home }}:{{ friendlyResultScore.guest }}</span>
</div>
</div>
<table class="friendly-result-table">
<thead>
<tr>
<th>#</th>
<th>Typ</th>
<th>Heim</th>
<th>Gast</th>
<th>Satz 1</th>
<th>Satz 2</th>
<th>Satz 3</th>
<th>Satz 4</th>
<th>Satz 5</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in friendlyResultDialog.rows" :key="row.id">
<td>{{ index + 1 }}</td>
<td>{{ row.type === 'double' ? 'Doppel' : 'Einzel' }}</td>
<td><input v-model="row.homeName" class="player-input" type="text" /></td>
<td><input v-model="row.guestName" class="player-input" type="text" /></td>
<td v-for="setIndex in 5" :key="setIndex">
<input
v-model="row.sets[setIndex - 1]"
class="set-input"
placeholder="11:7"
:disabled="isFriendlySetClosed(row, setIndex - 1)"
@blur="normalizeFriendlySet(row, setIndex - 1)"
/>
</td>
<td>
<button type="button" class="btn-secondary" @click="row.completed = !row.completed">
{{ row.completed ? 'Abgeschlossen' : 'Offen' }}
</button>
</td>
</tr>
</tbody>
</table>
<div v-if="friendlyResultDialog.error" class="friendly-result-error">{{ friendlyResultDialog.error }}</div>
<div class="dialog-actions">
<button @click="saveFriendlyResults(false)" class="btn-save">Speichern</button>
<button @click="completeFriendlyResults" class="btn-save">Abschließen</button>
<button @click="closeFriendlyResultDialog" class="btn-cancel">{{ $t('schedule.cancel') }}</button>
</div>
</div>
</BaseDialog>
<BaseDialog
v-model="locationDialog.isOpen"
:title="$t('schedule.locationDialogTitle')"
@@ -359,6 +438,57 @@
</div>
</div>
</BaseDialog>
<BaseDialog
v-model="friendlyMatchDialog.isOpen"
:title="friendlyMatchDialog.editingId ? 'Freundschaftsspiel bearbeiten' : 'Freundschaftsspiel anlegen'"
:max-width="900"
@close="closeFriendlyMatchDialog"
>
<div class="friendly-form">
<div class="friendly-form-grid">
<label>Datum <input v-model="friendlyMatchDialog.form.date" type="date" /></label>
<label>Uhrzeit <input v-model="friendlyMatchDialog.form.time" type="time" /></label>
<label>Heimteam <input v-model="friendlyMatchDialog.form.homeTeamName" type="text" /></label>
<label>Gastteam <input v-model="friendlyMatchDialog.form.guestTeamName" type="text" /></label>
<label>Spielsystem
<select v-model="friendlyMatchDialog.form.matchSystem">
<option>Braunschweiger System</option>
<option>Bundessystem</option>
<option>Werner-Scheffler-System</option>
<option>Modifiziertes Swaythling-Cup-System</option>
<option>Freies System</option>
</select>
</label>
<label>Gewinnsätze <input v-model.number="friendlyMatchDialog.form.winningSets" type="number" min="1" /></label>
</div>
<div class="friendly-participants">
<FriendlyParticipantsColumn
title="Heim-Aufstellung"
:members="friendlyMatchDialog.members"
:participants="friendlyMatchDialog.form.homeParticipants"
@add-member="addFriendlyParticipant('homeParticipants', $event)"
@add-manual="addManualFriendlyParticipant('homeParticipants', $event)"
@remove="removeFriendlyParticipant('homeParticipants', $event)"
/>
<FriendlyParticipantsColumn
title="Gast-Aufstellung"
:members="friendlyMatchDialog.members"
:participants="friendlyMatchDialog.form.guestParticipants"
@add-member="addFriendlyParticipant('guestParticipants', $event)"
@add-manual="addManualFriendlyParticipant('guestParticipants', $event)"
@remove="removeFriendlyParticipant('guestParticipants', $event)"
/>
</div>
<div class="dialog-actions">
<button @click="saveFriendlyMatch" class="btn-save">Speichern</button>
<button v-if="friendlyMatchDialog.editingId" @click="deleteFriendlyMatch" class="btn-cancel">Löschen</button>
<button @click="closeFriendlyMatchDialog" class="btn-cancel">{{ $t('schedule.cancel') }}</button>
</div>
</div>
</BaseDialog>
</template>
<script>
@@ -373,6 +503,7 @@ import ConfirmDialog from '../components/ConfirmDialog.vue';
import BaseDialog from '../components/BaseDialog.vue';
import CsvImportDialog from '../components/CsvImportDialog.vue';
import ScheduleLayoutShell from '../components/schedule/ScheduleLayoutShell.vue';
import FriendlyParticipantsColumn from '../components/schedule/FriendlyParticipantsColumn.vue';
import {
connectSocket,
disconnectSocket,
@@ -383,13 +514,20 @@ import {
} from '../services/socketService.js';
export default {
name: 'ScheduleView',
props: {
friendlyOnly: {
type: Boolean,
default: false
}
},
components: {
SeasonSelector,
InfoDialog,
ConfirmDialog,
BaseDialog,
CsvImportDialog,
ScheduleLayoutShell
ScheduleLayoutShell,
FriendlyParticipantsColumn
},
computed: {
...mapGetters(['isAuthenticated', 'currentClub', 'clubs', 'currentClubName']),
@@ -410,6 +548,9 @@ export default {
pendingMatchesCount() {
return this.matches.length - this.completedMatchesCount;
},
friendlyResultScore() {
return this.calculateFriendlyResultScore(this.friendlyResultDialog.rows);
},
nextScheduledMatchLabel() {
const today = new Date();
today.setHours(0, 0, 0, 0);
@@ -424,6 +565,8 @@ export default {
if (!this.selectedLeague) return '';
return this.activeTab === 'table'
? this.$t('schedule.workspaceTableDescription', { count: this.leagueTable.length })
: this.friendlyOnly
? `Interne Freundschaftsspiele: ${this.matches.length}`
: this.$t('schedule.workspaceScheduleDescription', {
matches: this.matches.length,
completed: this.completedMatchesCount,
@@ -439,6 +582,9 @@ export default {
.filter(teamName => teamName !== ownTeamName)
.sort((a, b) => a.localeCompare(b));
},
friendlyMatchesLabel() {
return 'Freundschaftsspiele';
},
},
watch: {
currentClub: {
@@ -451,6 +597,9 @@ export default {
connectSocket(newVal);
onScheduleMatchUpdated(this.handleScheduleMatchUpdated);
onMatchReportSubmitted(this.handleMatchReportSubmitted);
if (this.friendlyOnly) {
this.loadFriendlyMatches();
}
}
}
},
@@ -490,6 +639,7 @@ export default {
allLeagueMatches: [],
leagueMatchScope: 'own',
selectedComparisonTeamName: '',
friendlyMatches: [],
// Player Selection Dialog
playerSelectionDialog: {
@@ -508,9 +658,75 @@ export default {
galleryMembers: [],
galleryError: '',
gallerySize: 200,
friendlyMatchDialog: {
isOpen: false,
editingId: null,
members: [],
form: {
date: new Date().toISOString().slice(0, 10),
time: '',
homeTeamName: '',
guestTeamName: '',
locationName: '',
locationAddress: '',
locationZip: '',
locationCity: '',
matchSystem: 'Braunschweiger System',
singlesCount: 12,
doublesCount: 4,
winningSets: 3,
homeMatchPoints: 0,
guestMatchPoints: 0,
isCompleted: false,
homeParticipants: [],
guestParticipants: []
}
},
friendlyResultDialog: {
isOpen: false,
match: null,
rows: [],
error: '',
saving: false,
saveAgain: false
},
};
},
methods: {
emptyFriendlyMatchForm() {
const today = new Date().toISOString().slice(0, 10);
return {
date: today,
time: '',
homeTeamName: this.currentClubName || '',
guestTeamName: '',
locationName: '',
locationAddress: '',
locationZip: '',
locationCity: '',
matchSystem: 'Braunschweiger System',
singlesCount: 12,
doublesCount: 4,
winningSets: 3,
homeMatchPoints: 0,
guestMatchPoints: 0,
isCompleted: false,
homeParticipants: [],
guestParticipants: []
};
},
parseFriendlyArray(value) {
if (Array.isArray(value)) return value;
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
return [];
}
}
return [];
},
sortMatchesByDateTime(matches) {
if (!Array.isArray(matches)) {
return [];
@@ -643,12 +859,14 @@ export default {
const preselectedIds = Array.from(new Set([...readyIds, ...plannedIds, ...playedIds]));
// Fetch members for the current club
const response = await apiClient.get(`/clubmembers/get/${this.currentClub}/true`);
const response = match.isFriendly
? await apiClient.get(`/friendly-matches/${this.currentClub}/members/list`)
: await apiClient.get(`/clubmembers/get/${this.currentClub}/true`);
const allMembers = response.data;
const lineupHalf = this.getLineupHalfForMatch(match);
const eligibleMemberIds = await this.getEligibleMemberIdsForSelectedTeam(lineupHalf);
const activeMembers = allMembers.filter(member => member.active);
const eligibleMemberIds = match.isFriendly ? [] : await this.getEligibleMemberIdsForSelectedTeam(lineupHalf);
const activeMembers = match.isFriendly ? allMembers : allMembers.filter(member => member.active);
const allowedIds = new Set(
[...eligibleMemberIds, ...preselectedIds]
.map((id) => Number(id))
@@ -760,12 +978,18 @@ export default {
console.log('[savePlayerSelection] Saving players:', { playersReady, playersPlanned, playersPlayed, matchId: match.id });
try {
const response = await apiClient.patch(`/matches/${match.id}/players`, {
clubId: this.currentClub,
playersReady,
playersPlanned,
playersPlayed
});
const response = match.isFriendly
? await apiClient.patch(`/friendly-matches/${this.currentClub}/${match.id}/players`, {
playersReady,
playersPlanned,
playersPlayed
})
: await apiClient.patch(`/matches/${match.id}/players`, {
clubId: this.currentClub,
playersReady,
playersPlanned,
playersPlayed
});
if (response.status >= 400) {
throw new Error(response?.data?.error || 'Failed to update match players');
}
@@ -911,6 +1135,309 @@ export default {
this.selectedFile = file;
this.importCSV();
},
async loadFriendlyMembers() {
const response = await apiClient.get(`/friendly-matches/${this.currentClub}/members/list`);
this.friendlyMatchDialog.members = response.data || [];
},
async openFriendlyMatchDialog(match = null) {
await this.loadFriendlyMembers();
this.friendlyMatchDialog.editingId = match?.isFriendly ? match.id : null;
this.friendlyMatchDialog.form = match?.isFriendly
? {
date: match.date ? String(match.date).slice(0, 10) : new Date().toISOString().slice(0, 10),
time: match.time ? String(match.time).slice(0, 5) : '',
homeTeamName: match.homeTeam?.name || '',
guestTeamName: match.guestTeam?.name || '',
locationName: match.location?.name === 'N/A' ? '' : (match.location?.name || ''),
locationAddress: match.location?.address || '',
locationZip: match.location?.zip || '',
locationCity: match.location?.city || '',
matchSystem: match.matchSystem || 'Braunschweiger System',
singlesCount: match.singlesCount ?? 12,
doublesCount: match.doublesCount ?? 4,
winningSets: match.winningSets ?? 3,
homeMatchPoints: match.homeMatchPoints ?? 0,
guestMatchPoints: match.guestMatchPoints ?? 0,
isCompleted: Boolean(match.isCompleted),
homeParticipants: [...this.parseFriendlyArray(match.homeParticipants)],
guestParticipants: [...this.parseFriendlyArray(match.guestParticipants)]
}
: this.emptyFriendlyMatchForm();
this.friendlyMatchDialog.isOpen = true;
},
closeFriendlyMatchDialog() {
this.friendlyMatchDialog.isOpen = false;
this.friendlyMatchDialog.editingId = null;
this.friendlyMatchDialog.form = this.emptyFriendlyMatchForm();
},
addFriendlyParticipant(field, memberId) {
const list = this.friendlyMatchDialog.form[field];
if (list.some(p => p.type === 'member' && Number(p.memberId) === Number(memberId))) return;
list.push({ type: 'member', memberId });
},
addManualFriendlyParticipant(field, fullName) {
const parts = String(fullName).trim().split(/\s+/);
const firstName = parts.shift() || '';
const lastName = parts.join(' ');
this.friendlyMatchDialog.form[field].push({ type: 'manual', firstName, lastName });
},
removeFriendlyParticipant(field, index) {
this.friendlyMatchDialog.form[field].splice(index, 1);
},
friendlyParticipantLabel(participant, fallback = '') {
if (!participant) return fallback;
if (participant.type === 'member') {
const member = this.friendlyMatchDialog.members.find(m => Number(m.id) === Number(participant.memberId));
return member ? `${member.firstName} ${member.lastName}`.trim() : fallback;
}
return `${participant.firstName || ''} ${participant.lastName || ''}`.trim() || fallback;
},
friendlyParticipantLabels(participants) {
return (Array.isArray(participants) ? participants : [])
.map((participant) => this.friendlyParticipantLabel(participant, ''))
.filter(Boolean);
},
friendlyDoubleLabel(labels, index) {
if (!labels.length) return '';
if (labels.length === 1) return labels[0];
const pairings = [
[0, 1],
[2, 3],
[0, 2],
[1, 3]
];
const fallbackStart = (index * 2) % labels.length;
const [firstIndex, secondIndex] = pairings[index] || [fallbackStart, fallbackStart + 1];
const first = labels[firstIndex % labels.length];
const second = labels[secondIndex % labels.length];
return first === second ? first : `${first} / ${second}`;
},
buildGeneratedFriendlyResultRows(match) {
const homeLabels = this.friendlyParticipantLabels(match.homeParticipants);
const guestLabels = this.friendlyParticipantLabels(match.guestParticipants);
const rows = [];
for (let i = 0; i < 4; i += 1) {
rows.push({
id: `d-${i + 1}`,
type: 'double',
homeName: this.friendlyDoubleLabel(homeLabels, i),
guestName: this.friendlyDoubleLabel(guestLabels, i),
sets: ['', '', '', '', ''],
completed: false
});
}
for (let i = 0; i < 12; i += 1) {
rows.push({
id: `s-${i + 1}`,
type: 'single',
homeName: homeLabels[i % Math.max(homeLabels.length, 1)] || '',
guestName: guestLabels[i % Math.max(guestLabels.length, 1)] || '',
sets: ['', '', '', '', ''],
completed: false
});
}
return rows;
},
buildFriendlyResultRows(match) {
const existing = this.parseFriendlyArray(match.resultDetails);
const generated = this.buildGeneratedFriendlyResultRows(match);
if (existing.length) {
return existing.map((row, index) => ({
id: row.id || `m-${index}`,
type: row.type === 'double' ? 'double' : 'single',
homeName: row.homeName || generated[index]?.homeName || '',
guestName: row.guestName || generated[index]?.guestName || '',
sets: Array.from({ length: 5 }, (_, i) => row.sets?.[i] || ''),
completed: Boolean(row.completed)
}));
}
return generated;
},
async openFriendlyResultDialog(match) {
await this.loadFriendlyMembers();
this.friendlyResultDialog.match = match;
this.friendlyResultDialog.rows = this.buildFriendlyResultRows(match);
this.friendlyResultDialog.error = '';
this.friendlyResultDialog.isOpen = true;
},
closeFriendlyResultDialog() {
this.friendlyResultDialog.isOpen = false;
this.friendlyResultDialog.match = null;
this.friendlyResultDialog.rows = [];
this.friendlyResultDialog.error = '';
this.friendlyResultDialog.saving = false;
this.friendlyResultDialog.saveAgain = false;
},
async normalizeFriendlySet(row, index) {
const value = String(row.sets[index] || '').trim();
if (!value) {
this.applyFriendlyRowCompletion(row);
await this.autoSaveFriendlyResults();
return;
}
const normalized = this.normalizeFriendlySetValue(value);
if (!normalized) {
this.friendlyResultDialog.error = 'Bitte gültige Sätze eingeben, z.B. 11:7, 7 oder -7.';
return;
}
row.sets[index] = normalized;
this.applyFriendlyRowCompletion(row);
this.friendlyResultDialog.error = '';
await this.autoSaveFriendlyResults();
},
normalizeFriendlySetValue(value) {
const raw = String(value || '').trim();
if (!raw) return '';
if (raw.includes(':')) {
const parts = raw.split(':');
if (parts.length !== 2) return null;
const a = Number(parts[0]);
const b = Number(parts[1]);
if (!Number.isInteger(a) || !Number.isInteger(b) || a < 0 || b < 0) return null;
if ((a < 11 && b < 11) || Math.abs(a - b) < 2) return null;
return `${a}:${b}`;
}
const losing = Math.abs(Number(raw));
if (!Number.isInteger(losing)) return null;
const winning = losing < 10 ? 11 : losing + 2;
return raw.startsWith('-') ? `${losing}:${winning}` : `${winning}:${losing}`;
},
getFriendlyWinningSets() {
const value = Number.parseInt(this.friendlyResultDialog.match?.winningSets, 10);
return Number.isInteger(value) && value > 0 ? value : 3;
},
calculateFriendlyRowState(row) {
const requiredSets = this.getFriendlyWinningSets();
let homeSets = 0;
let guestSets = 0;
for (let index = 0; index < (row.sets || []).length; index += 1) {
const set = row.sets[index];
const normalized = this.normalizeFriendlySetValue(set);
if (!normalized) continue;
const [home, guest] = normalized.split(':').map(Number);
if (home > guest) homeSets += 1;
if (guest > home) guestSets += 1;
if (homeSets >= requiredSets || guestSets >= requiredSets) {
return {
winner: homeSets > guestSets ? 'home' : 'guest',
decisiveIndex: index
};
}
}
return { winner: null, decisiveIndex: null };
},
calculateFriendlyRowWinner(row) {
return this.calculateFriendlyRowState(row).winner;
},
applyFriendlyRowCompletion(row) {
const state = this.calculateFriendlyRowState(row);
row.completed = Boolean(state.winner);
if (state.winner && Number.isInteger(state.decisiveIndex)) {
row.sets = row.sets.map((set, index) => index > state.decisiveIndex ? '' : set);
}
},
isFriendlySetClosed(row, index) {
const state = this.calculateFriendlyRowState(row);
return Boolean(state.winner && Number.isInteger(state.decisiveIndex) && index > state.decisiveIndex);
},
calculateFriendlyResultScore(rows) {
return (rows || []).reduce((score, row) => {
const winner = this.calculateFriendlyRowWinner(row);
if (winner === 'home') score.home += 1;
if (winner === 'guest') score.guest += 1;
return score;
}, { home: 0, guest: 0 });
},
async autoSaveFriendlyResults() {
if (this.friendlyResultDialog.saving) {
this.friendlyResultDialog.saveAgain = true;
return;
}
do {
this.friendlyResultDialog.saveAgain = false;
await this.saveFriendlyResults(false, { closeDialog: false, reloadMatches: false });
} while (this.friendlyResultDialog.saveAgain);
},
async saveFriendlyResults(isCompleted = false, options = {}) {
const { closeDialog = true, reloadMatches = true } = options;
const match = this.friendlyResultDialog.match;
if (!match) return;
for (const row of this.friendlyResultDialog.rows) {
const normalizedSets = [];
for (const set of row.sets) {
if (!String(set || '').trim()) {
normalizedSets.push('');
continue;
}
const normalized = this.normalizeFriendlySetValue(set);
if (!normalized) {
this.friendlyResultDialog.error = 'Bitte ungültige Sätze korrigieren.';
return;
}
normalizedSets.push(normalized);
}
row.sets = normalizedSets;
this.applyFriendlyRowCompletion(row);
}
const score = this.calculateFriendlyResultScore(this.friendlyResultDialog.rows);
try {
this.friendlyResultDialog.saving = true;
await apiClient.put(`/friendly-matches/${this.currentClub}/${match.id}`, {
homeMatchPoints: score.home,
guestMatchPoints: score.guest,
isCompleted,
resultDetails: this.friendlyResultDialog.rows
});
match.homeMatchPoints = score.home;
match.guestMatchPoints = score.guest;
match.isCompleted = isCompleted;
match.resultDetails = this.friendlyResultDialog.rows.map((row) => ({ ...row, sets: [...row.sets] }));
if (closeDialog) {
this.closeFriendlyResultDialog();
}
if (reloadMatches) {
await this.loadFriendlyMatches();
}
} catch (error) {
this.friendlyResultDialog.error = getSafeErrorMessage(error, 'Ergebnisse konnten nicht gespeichert werden.');
} finally {
this.friendlyResultDialog.saving = false;
}
},
async completeFriendlyResults() {
await this.saveFriendlyResults(true);
},
async saveFriendlyMatch() {
try {
const payload = {
...this.friendlyMatchDialog.form,
homeParticipants: this.parseFriendlyArray(this.friendlyMatchDialog.form.homeParticipants).map((participant) => ({ ...participant })),
guestParticipants: this.parseFriendlyArray(this.friendlyMatchDialog.form.guestParticipants).map((participant) => ({ ...participant }))
};
const id = this.friendlyMatchDialog.editingId;
if (id) {
await apiClient.put(`/friendly-matches/${this.currentClub}/${id}`, payload);
} else {
await apiClient.post(`/friendly-matches/${this.currentClub}`, payload);
}
this.closeFriendlyMatchDialog();
await this.loadFriendlyMatches();
} catch (error) {
await this.showInfo('Fehler', getSafeErrorMessage(error, 'Freundschaftsspiel konnte nicht gespeichert werden.'), '', 'error');
}
},
async deleteFriendlyMatch() {
if (!this.friendlyMatchDialog.editingId) return;
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}`);
this.closeFriendlyMatchDialog();
await this.loadFriendlyMatches();
} catch (error) {
await this.showInfo('Fehler', getSafeErrorMessage(error, 'Freundschaftsspiel konnte nicht gelöscht werden.'), '', 'error');
}
},
async importCSV() {
if (!this.selectedFile) return;
@@ -1159,6 +1686,25 @@ export default {
this.matches = [];
}
},
async loadFriendlyMatches() {
this.selectedLeague = this.friendlyMatchesLabel;
this.selectedTeam = null;
this.ownLeagueMatches = [];
this.allLeagueMatches = [];
this.leagueMatchScope = 'own';
this.selectedComparisonTeamName = '';
this.activeTab = 'schedule';
this.leagueTable = [];
try {
const response = await apiClient.get(`/friendly-matches/${this.currentClub}`);
this.friendlyMatches = response.data || [];
this.matches = this.sortMatchesByDateTime(this.friendlyMatches);
} catch (error) {
this.showInfo(this.$t('messages.error'), getSafeErrorMessage(error, 'Freundschaftsspiele konnten nicht geladen werden.'), '', 'error');
this.friendlyMatches = [];
this.matches = [];
}
},
formatDate(date) {
if (!date) return 'N/A';
const d = new Date(date);
@@ -1401,10 +1947,35 @@ export default {
this.loadAllMatches();
} else if (this.selectedLeague === this.$t('schedule.adultSchedule')) {
this.loadAdultMatches();
} else if (this.selectedLeague === this.friendlyMatchesLabel) {
this.loadFriendlyMatches();
}
},
handleScheduleMatchUpdated(payload) {
if (this.friendlyOnly) {
if (payload?.match?.isFriendly) {
const idx = this.matches.findIndex(m => m.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);
return;
}
if (payload?.matchId != null) {
const idx = this.matches.findIndex(m => m.id === payload.matchId);
if (idx !== -1) {
this.matches.splice(idx, 1);
this.friendlyMatches = [...this.matches];
return;
}
}
this.refreshScheduleData();
return;
}
if (payload?.match && payload.matchId != null) {
const idx = this.matches.findIndex(m => m.id === payload.matchId);
if (idx !== -1) {
@@ -1437,9 +2008,10 @@ export default {
},
},
async created() {
// Teams werden geladen, sobald eine Saison ausgewählt ist
// Die SeasonSelector-Komponente wählt automatisch die aktuelle Saison aus
// und ruft anschließend onSeasonChange auf, was loadTeams() ausführt
if (this.friendlyOnly) {
await this.loadFriendlyMatches();
return;
}
this.loadTeams();
},
beforeUnmount() {
@@ -1535,6 +2107,116 @@ td {
color: #856404;
}
.friendly-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.friendly-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.75rem;
}
.friendly-form-grid label,
.friendly-participant-column {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.friendly-form-grid input,
.friendly-form-grid select,
.friendly-add-row input,
.friendly-add-row select {
width: 100%;
box-sizing: border-box;
padding: 0.45rem 0.55rem;
border: 1px solid var(--border-color, #ddd);
border-radius: 6px;
}
.friendly-checkbox {
flex-direction: row !important;
align-items: center;
}
.friendly-participants {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
}
.friendly-participant-column {
border: 1px solid var(--border-color, #ddd);
border-radius: 8px;
padding: 0.75rem;
}
.friendly-add-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.5rem;
}
.friendly-participant-list {
list-style: none;
padding: 0;
margin: 0.5rem 0 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.friendly-participant-list li {
display: flex;
justify-content: space-between;
gap: 0.5rem;
padding: 0.35rem 0.5rem;
background: var(--background-soft, #f7f7f7);
border-radius: 6px;
}
.friendly-actions-cell {
display: flex;
gap: 0.5rem;
align-items: center;
}
.friendly-result-dialog {
display: flex;
flex-direction: column;
gap: 1rem;
}
.friendly-result-table {
width: 100%;
border-collapse: collapse;
}
.friendly-result-table input {
width: 100%;
box-sizing: border-box;
padding: 0.35rem 0.45rem;
border: 1px solid var(--border-color, #ddd);
border-radius: 4px;
}
.friendly-result-table .player-input {
min-width: 12rem;
}
.friendly-result-table .set-input {
width: 4.5rem;
text-align: center;
}
.friendly-result-error {
color: #b00020;
font-weight: 600;
}
.modal {
display: flex;
justify-content: center;