Freundschaftsspiele korrigiert
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 52s

This commit is contained in:
Torsten Schulz (local)
2026-06-06 12:42:17 +02:00
parent 5727404f88
commit 5194d4582f
22 changed files with 1527 additions and 177 deletions

View File

@@ -1,23 +1,24 @@
<template>
<section class="friendly-participant-column">
<h4>{{ title }}</h4>
<div class="friendly-add-row">
<div v-if="!readonly && allowMembers" 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">
<option v-for="member in availableMembers" :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">
<p v-else-if="!readonly && memberHint" class="friendly-participant-hint">{{ memberHint }}</p>
<div v-if="!readonly && allowManual" 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>
<button v-if="!readonly" type="button" @click="$emit('remove', index)">x</button>
</li>
</ul>
</section>
@@ -29,9 +30,21 @@ export default {
props: {
title: { type: String, required: true },
members: { type: Array, required: true },
participants: { type: Array, required: true }
participants: { type: Array, required: true },
allowMembers: { type: Boolean, default: true },
allowManual: { type: Boolean, default: true },
readonly: { type: Boolean, default: false },
memberHint: { type: String, default: '' }
},
emits: ['add-member', 'add-manual', 'remove'],
computed: {
availableMembers() {
const selected = new Set((this.participants || [])
.filter((participant) => participant?.type === 'member')
.map((participant) => Number(participant.memberId)));
return (this.members || []).filter((member) => !selected.has(Number(member.id)));
}
},
data() {
return {
selectedMemberId: '',
@@ -104,4 +117,10 @@ export default {
background: var(--background-soft, #f7f7f7);
border-radius: 6px;
}
.friendly-participant-hint {
margin: 0;
color: var(--text-muted, #666);
font-size: 0.9rem;
}
</style>

View File

@@ -4,24 +4,30 @@
<!-- Tab Navigation -->
<div class="tab-navigation">
<button
<button
:class="['tab-button', { active: activeTab === 'settings' }]"
@click="activeTab = 'settings'"
>
{{ $t('clubSettings.settings') }}
</button>
<button
<button
:class="['tab-button', { active: activeTab === 'training-groups' }]"
@click="activeTab = 'training-groups'"
>
👨👩👧👦 {{ $t('clubSettings.trainingGroups') }}
</button>
<button
<button
:class="['tab-button', { active: activeTab === 'training-times' }]"
@click="activeTab = 'training-times'"
>
🕐 {{ $t('clubSettings.trainingTimes') }}
</button>
<button
:class="['tab-button', { active: activeTab === 'venues' }]"
@click="activeTab = 'venues'"
>
🏟 Spiellokale
</button>
</div>
<!-- Settings Tab -->
@@ -138,6 +144,57 @@
<TrainingTimesTab />
</div>
<!-- End Training Times Tab -->
<!-- Venues Tab -->
<div v-if="activeTab === 'venues'" class="venues-tab">
<p v-if="!currentClub" class="hint hint-warning">Kein Verein ausgewählt.</p>
<p v-else-if="venuesLoading" class="hint">{{ $t('common.loading') }}</p>
<p v-else-if="venuesError" class="hint hint-error">{{ venuesError }}</p>
<section v-if="currentClub" class="card venue-form-card">
<h2>{{ venueForm.id ? 'Spiellokal bearbeiten' : 'Spiellokal anlegen' }}</h2>
<div class="field-grid">
<div class="field-group">
<label>Name</label>
<input v-model="venueForm.name" class="text-input" placeholder="z. B. Sporthalle Harheim" />
</div>
<div class="field-group">
<label>Straße / Adresse</label>
<input v-model="venueForm.address" class="text-input" placeholder="Straße und Hausnummer" />
</div>
<div class="field-group">
<label>PLZ</label>
<input v-model="venueForm.zip" class="text-input" placeholder="60437" />
</div>
<div class="field-group">
<label>Ort</label>
<input v-model="venueForm.city" class="text-input" placeholder="Frankfurt am Main" />
</div>
</div>
<div class="actions">
<button class="btn btn-primary" @click="saveVenue">{{ venueForm.id ? 'Speichern' : 'Hinzufügen' }}</button>
<button v-if="venueForm.id" class="btn btn-secondary" @click="resetVenueForm">Abbrechen</button>
</div>
</section>
<section v-if="currentClub" class="card venues-list-card">
<h2>Spiellokale</h2>
<p v-if="!venues.length" class="hint">Noch keine Spiellokale angelegt.</p>
<div v-else class="venue-list">
<div v-for="venue in venues" :key="venue.id" class="venue-row">
<div>
<strong>{{ venue.name }}</strong>
<div class="venue-address">{{ formatVenueAddress(venue) || 'Keine Adresse hinterlegt' }}</div>
</div>
<div class="venue-actions">
<button class="btn btn-secondary" @click="editVenue(venue)">Bearbeiten</button>
<button class="btn btn-danger" @click="deleteVenue(venue)">Löschen</button>
</div>
</div>
</div>
</section>
</div>
<!-- End Venues Tab -->
</div>
</template>
@@ -193,6 +250,10 @@ export default {
saved: false,
loading: false,
loadError: null,
venues: [],
venuesLoading: false,
venuesError: null,
venueForm: this.emptyVenueForm(),
};
},
computed: {
@@ -204,7 +265,13 @@ export default {
watch: {
currentClub: {
handler(clubId) {
if (clubId) this.loadClubSettings();
if (clubId) {
this.loadClubSettings();
this.loadVenues();
} else {
this.venues = [];
this.resetVenueForm();
}
},
immediate: true,
},
@@ -267,6 +334,73 @@ export default {
return null;
}
},
emptyVenueForm() {
return { id: null, name: '', address: '', zip: '', city: '' };
},
resetVenueForm() {
this.venueForm = this.emptyVenueForm();
},
formatVenueAddress(venue) {
return [venue?.address, [venue?.zip, venue?.city].filter(Boolean).join(' ')].filter(Boolean).join(', ');
},
async loadVenues() {
if (!this.currentClub) return;
this.venuesLoading = true;
this.venuesError = null;
try {
const response = await apiClient.get(`/club-venues/${this.currentClub}`);
this.venues = response.data || [];
} catch (e) {
this.venuesError = 'Spiellokale konnten nicht geladen werden.';
this.venues = [];
} finally {
this.venuesLoading = false;
}
},
editVenue(venue) {
this.venueForm = {
id: venue.id,
name: venue.name || '',
address: venue.address || '',
zip: venue.zip || '',
city: venue.city || '',
};
},
async saveVenue() {
if (!this.currentClub) return;
const payload = {
name: this.venueForm.name,
address: this.venueForm.address,
zip: this.venueForm.zip,
city: this.venueForm.city,
};
if (!payload.name.trim()) {
alert('Bitte einen Namen für das Spiellokal angeben.');
return;
}
try {
if (this.venueForm.id) {
await apiClient.put(`/club-venues/${this.currentClub}/${this.venueForm.id}`, payload);
} else {
await apiClient.post(`/club-venues/${this.currentClub}`, payload);
}
this.resetVenueForm();
await this.loadVenues();
} catch (e) {
alert('Spiellokal konnte nicht gespeichert werden.');
}
},
async deleteVenue(venue) {
if (!this.currentClub || !venue?.id) return;
if (!confirm(`Spiellokal "${venue.name}" wirklich löschen?`)) return;
try {
await apiClient.delete(`/club-venues/${this.currentClub}/${venue.id}`);
if (this.venueForm.id === venue.id) this.resetVenueForm();
await this.loadVenues();
} catch (e) {
alert('Spiellokal konnte nicht gelöscht werden.');
}
},
async save() {
if (!this.currentClub) {
alert(this.$t('clubSettings.noClubSelected'));
@@ -326,10 +460,17 @@ export default {
.actions { display: flex; align-items: center; gap: 10px; margin-top: 10px; }
.btn.btn-primary { background: var(--primary-color); color: #fff; border: none; padding: 8px 12px; border-radius: 6px; cursor: pointer; }
.btn.btn-primary:hover { background: var(--primary-hover); }
.btn.btn-secondary { background: #f8fafc; color: #1f2937; border: 1px solid #cbd5e1; padding: 8px 12px; border-radius: 6px; cursor: pointer; }
.btn.btn-danger { background: #fff5f5; color: #b91c1c; border: 1px solid #fecaca; padding: 8px 12px; border-radius: 6px; cursor: pointer; }
.saved-hint { color: #28a745; font-weight: 600; }
.hint { color: #666; font-size: 12px; margin-top: 8px; }
.hint-warning { color: #856404; background: #fff3cd; padding: 12px; border-radius: 6px; }
.hint-error { color: #721c24; background: #f8d7da; padding: 12px; border-radius: 6px; }
.venue-form-card, .venues-list-card { margin-bottom: 16px; }
.venue-list { display: grid; gap: 10px; }
.venue-row { display: flex; justify-content: space-between; gap: 16px; align-items: center; padding: 12px; border: 1px solid #e5e7eb; border-radius: 6px; background: #f9fafb; }
.venue-address { margin-top: 4px; color: #666; font-size: 13px; }
.venue-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
.tab-navigation {
display: flex;
@@ -363,5 +504,7 @@ export default {
@media (max-width: 720px) {
.field-grid { grid-template-columns: 1fr; }
.venue-row { align-items: stretch; flex-direction: column; }
.venue-actions { justify-content: flex-start; }
}
</style>

View File

@@ -204,7 +204,7 @@
<span class="status-label">
{{ isOurClubPlayingHome(match) ? $t('schedule.homeLabel') || $t('schedule.homeGame') : $t('schedule.awayLabel') || $t('schedule.away') }}
</span>
<button type="button" class="btn-small" @click.stop="toggleHomeAway(match)">
<button type="button" class="btn-small" :disabled="isFriendlyMatchReadOnly(match)" @click.stop="toggleHomeAway(match)">
{{ $t('schedule.swapButton') }}
</button>
</div>
@@ -212,8 +212,8 @@
<td class="pin-cell">
<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>
<button type="button" class="btn-secondary" @click.stop="openFriendlyResultDialog(match)">{{ isFriendlyMatchReadOnly(match) ? 'Ansehen' : 'Ergebnis' }}</button>
<button type="button" class="btn-secondary" @click.stop="openFriendlyMatchDialog(match)">{{ isFriendlyMatchReadOnly(match) ? 'Details' : 'Bearbeiten' }}</button>
</div>
<span v-else-if="match.homePin" class="pin-value clickable"
@click.stop="copyToClipboard(match.homePin, $t('schedule.homePin'), $event)"
@@ -369,6 +369,7 @@
<input
type="checkbox"
:checked="member.isReady"
:disabled="playerSelectionDialog.readonly"
@change="togglePlayerReady(member)"
/>
</td>
@@ -376,6 +377,7 @@
<input
type="checkbox"
:checked="member.isPlanned"
:disabled="playerSelectionDialog.readonly"
@change="togglePlayerPlanned(member)"
/>
</td>
@@ -383,6 +385,7 @@
<input
type="checkbox"
:checked="member.hasPlayed"
:disabled="playerSelectionDialog.readonly"
@change="togglePlayerPlayed(member)"
/>
</td>
@@ -396,7 +399,7 @@
</div>
<div class="dialog-actions">
<button @click="savePlayerSelection" class="btn-save">{{ $t('schedule.save') }}</button>
<button v-if="!playerSelectionDialog.readonly" @click="savePlayerSelection" class="btn-save">{{ $t('schedule.save') }}</button>
<button @click="closePlayerSelectionDialog" class="btn-cancel">{{ $t('schedule.cancel') }}</button>
</div>
</div>
@@ -413,7 +416,7 @@
<div class="score-summary">
<div class="score-display">
<span class="score-label">Spielstand:</span>
<span class="score-value">{{ friendlyResultScore.home }}:{{ friendlyResultScore.guest }}</span>
<span :class="['score-value', friendlyScorePerspectiveClass(friendlyResultScore)]">{{ friendlyResultScore.home }}:{{ friendlyResultScore.guest }}</span>
</div>
</div>
<table class="friendly-result-table">
@@ -428,6 +431,8 @@
<th>Satz 3</th>
<th>Satz 4</th>
<th>Satz 5</th>
<th>Sätze</th>
<th>Punkte</th>
<th>Status</th>
</tr>
</thead>
@@ -435,29 +440,39 @@
<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 class="friendly-result-player">{{ row.homeName || '-' }}</td>
<td class="friendly-result-player">{{ row.guestName || '-' }}</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)"
:disabled="friendlyResultReadonly || isFriendlySetClosed(row, setIndex - 1)"
@blur="normalizeFriendlySet(row, setIndex - 1)"
/>
</td>
<td :class="['friendly-result-score-cell', friendlyScorePerspectiveClass(calculateFriendlyRowSets(row))]">{{ friendlyRowSetScore(row) }}</td>
<td :class="['friendly-result-score-cell', friendlyScorePerspectiveClass(friendlyRowPointScoreObject(row))]">{{ friendlyRowPointScore(row) }}</td>
<td>
<button type="button" class="btn-secondary" @click="row.completed = !row.completed">
<button type="button" class="btn-secondary" :disabled="friendlyResultReadonly" @click="row.completed = !row.completed">
{{ row.completed ? 'Abgeschlossen' : 'Offen' }}
</button>
</td>
</tr>
</tbody>
<tfoot>
<tr class="friendly-result-total-row">
<td colspan="9">Gesamt</td>
<td :class="['friendly-result-score-cell', friendlyScorePerspectiveClass(friendlyResultSetScore)]">{{ friendlyResultSetScore.home }}:{{ friendlyResultSetScore.guest }}</td>
<td :class="['friendly-result-score-cell', friendlyScorePerspectiveClass(friendlyResultScore)]">{{ friendlyResultScore.home }}:{{ friendlyResultScore.guest }}</td>
<td></td>
</tr>
</tfoot>
</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 v-if="!friendlyResultReadonly" @click="saveFriendlyResults(false)" class="btn-save">Speichern</button>
<button v-if="!friendlyResultReadonly" @click="completeFriendlyResults" class="btn-save">Abschließen</button>
<button @click="closeFriendlyResultDialog" class="btn-cancel">{{ $t('schedule.cancel') }}</button>
</div>
</div>
@@ -545,44 +560,94 @@
>
<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>Datum <input v-model="friendlyMatchDialog.form.date" type="date" :disabled="friendlyMatchDialog.readonly" /></label>
<label>Uhrzeit <input v-model="friendlyMatchDialog.form.time" type="time" :disabled="friendlyMatchDialog.readonly" /></label>
<label>Heimteam <input v-model="friendlyMatchDialog.form.homeTeamName" type="text" :disabled="friendlyMatchDialog.readonly" /></label>
<label>Gastteam <input v-model="friendlyMatchDialog.form.guestTeamName" type="text" :disabled="friendlyMatchDialog.readonly" /></label>
<label>Spielsystem
<select v-model="friendlyMatchDialog.form.matchSystem">
<select v-model="friendlyMatchDialog.form.matchSystem" :disabled="friendlyMatchDialog.readonly">
<option>Braunschweiger System</option>
<option>Bundessystem</option>
<option>Werner-Scheffler-System</option>
<option>Sechser-Paarkreuz-System</option>
<option>Europaliga-System</option>
<option>Swaythling-Cup-System</option>
<option>Corbillon-Cup-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>
<label>Gewinnsätze <input v-model.number="friendlyMatchDialog.form.winningSets" type="number" min="1" :disabled="friendlyMatchDialog.readonly" /></label>
<label v-if="canSetFriendlyVenue" class="friendly-venue-field">Spiellokal
<select v-model="friendlyMatchDialog.selectedVenueId" :disabled="friendlyMatchDialog.readonly" @change="applyFriendlyVenue">
<option value="">Kein Spiellokal ausgewählt</option>
<option v-for="venue in friendlyMatchDialog.venues" :key="venue.id" :value="String(venue.id)">{{ venue.name }}</option>
</select>
</label>
<div v-else-if="friendlyVenueSummary" class="friendly-venue-summary">
<strong>Spiellokal</strong>
<span>{{ friendlyVenueSummary }}</span>
</div>
</div>
<div class="friendly-participants">
<FriendlyParticipantsColumn
title="Heim-Aufstellung"
:members="friendlyMatchDialog.members"
:members="friendlyParticipantMemberOptions('homeParticipants')"
:participants="friendlyMatchDialog.form.homeParticipants"
:allow-members="!friendlyMatchDialog.readonly"
:allow-manual="false"
:readonly="friendlyMatchDialog.readonly"
@add-member="addFriendlyParticipant('homeParticipants', $event)"
@add-manual="addManualFriendlyParticipant('homeParticipants', $event)"
@remove="removeFriendlyParticipant('homeParticipants', $event)"
/>
<FriendlyParticipantsColumn
title="Gast-Aufstellung"
:members="friendlyMatchDialog.members"
:members="friendlyParticipantMemberOptions('guestParticipants')"
:participants="friendlyMatchDialog.form.guestParticipants"
:allow-members="friendlyParticipantMemberOptions('guestParticipants').length > 0"
:allow-manual="friendlyParticipantMemberOptions('guestParticipants').length === 0"
:readonly="friendlyMatchDialog.readonly"
:member-hint="friendlyGuestMemberHint"
@add-member="addFriendlyParticipant('guestParticipants', $event)"
@add-manual="addManualFriendlyParticipant('guestParticipants', $event)"
@remove="removeFriendlyParticipant('guestParticipants', $event)"
/>
</div>
<section v-if="friendlyEditDoubleRows().length" class="friendly-doubles-section">
<h4>Doppel</h4>
<div v-for="(row, index) in friendlyEditDoubleRows()" :key="row.id" class="friendly-double-row">
<strong>Doppel {{ index + 1 }}</strong>
<div class="friendly-double-side">
<span>Heim</span>
<select :value="friendlyDoublePart(row.homeName, 0)" :disabled="friendlyMatchDialog.readonly" @change="setFriendlyEditDoublePart(row, 'homeName', 0, $event.target.value)">
<option value="">Spieler 1</option>
<option v-for="name in friendlyEditDoubleOptions('homeParticipants', row, 'homeName', 0)" :key="`ed-h1-${row.id}-${name}`" :value="name">{{ name }}</option>
</select>
<select :value="friendlyDoublePart(row.homeName, 1)" :disabled="friendlyMatchDialog.readonly" @change="setFriendlyEditDoublePart(row, 'homeName', 1, $event.target.value)">
<option value="">Spieler 2</option>
<option v-for="name in friendlyEditDoubleOptions('homeParticipants', row, 'homeName', 1)" :key="`ed-h2-${row.id}-${name}`" :value="name">{{ name }}</option>
</select>
</div>
<div class="friendly-double-side">
<span>Gast</span>
<select :value="friendlyDoublePart(row.guestName, 0)" :disabled="friendlyMatchDialog.readonly" @change="setFriendlyEditDoublePart(row, 'guestName', 0, $event.target.value)">
<option value="">Spieler 1</option>
<option v-for="name in friendlyEditDoubleOptions('guestParticipants', row, 'guestName', 0)" :key="`ed-g1-${row.id}-${name}`" :value="name">{{ name }}</option>
</select>
<select :value="friendlyDoublePart(row.guestName, 1)" :disabled="friendlyMatchDialog.readonly" @change="setFriendlyEditDoublePart(row, 'guestName', 1, $event.target.value)">
<option value="">Spieler 2</option>
<option v-for="name in friendlyEditDoubleOptions('guestParticipants', row, 'guestName', 1)" :key="`ed-g2-${row.id}-${name}`" :value="name">{{ name }}</option>
</select>
</div>
</div>
</section>
<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 v-if="!friendlyMatchDialog.readonly" @click="saveFriendlyMatch" class="btn-save">Speichern</button>
<button v-if="friendlyMatchDialog.editingId && !friendlyMatchDialog.readonly" @click="deleteFriendlyMatch" class="btn-cancel">Löschen</button>
<button @click="closeFriendlyMatchDialog" class="btn-cancel">{{ $t('schedule.cancel') }}</button>
</div>
</div>
@@ -659,6 +724,30 @@ export default {
friendlyResultScore() {
return this.calculateFriendlyResultScore(this.friendlyResultDialog.rows);
},
friendlyResultSetScore() {
return this.calculateFriendlyResultSetScore(this.friendlyResultDialog.rows);
},
friendlyResultReadonly() {
return this.isFriendlyMatchReadOnly(this.friendlyResultDialog.match);
},
friendlyGuestMemberHint() {
const match = this.friendlyMatchDialog.match;
if (!match?.isSharedFriendly) return '';
if (this.isFriendlyMatchReadOnly(match)) return 'Der Termin ist verstrichen. Die Gastmannschaft ist nur sichtbar.';
return 'Gastmitglieder sind erst nach Annahme sichtbar.';
},
canSetFriendlyVenue() {
const match = this.friendlyMatchDialog.match;
if (this.friendlyMatchDialog.readonly) return false;
if (!match?.isSharedFriendly) return true;
return Number(match.homeClubId) === Number(this.currentClub);
},
friendlyVenueSummary() {
const form = this.friendlyMatchDialog.form;
return [form.locationName, [form.locationAddress, [form.locationZip, form.locationCity].filter(Boolean).join(' ')].filter(Boolean).join(', ')]
.filter(Boolean)
.join(' - ');
},
nextScheduledMatchLabel() {
const today = new Date();
today.setHours(0, 0, 0, 0);
@@ -768,7 +857,8 @@ export default {
isOpen: false,
match: null,
members: [],
loading: false
loading: false,
readonly: false
},
locationDialog: {
isOpen: false,
@@ -783,7 +873,13 @@ export default {
friendlyMatchDialog: {
isOpen: false,
editingId: null,
match: null,
members: [],
homeMembers: [],
guestMembers: [],
venues: [],
selectedVenueId: '',
readonly: false,
form: {
date: new Date().toISOString().slice(0, 10),
time: '',
@@ -938,7 +1034,8 @@ export default {
guestMatchPoints: 0,
isCompleted: false,
homeParticipants: [],
guestParticipants: []
guestParticipants: [],
resultDetails: []
};
},
parseFriendlyArray(value) {
@@ -953,6 +1050,54 @@ export default {
}
return [];
},
friendlyMatchEndsAt(match) {
if (!match?.date) return null;
const date = String(match.date).slice(0, 10);
const value = new Date(`${date}T23:59:59`);
return Number.isNaN(value.getTime()) ? null : value;
},
isFriendlyMatchReadOnly(match) {
if (!match?.isFriendly) return false;
if (match.isLocked === true) return true;
const endsAt = this.friendlyMatchEndsAt(match);
return endsAt ? endsAt.getTime() <= Date.now() : false;
},
sortFriendlyMembers(members) {
return (members || []).slice().sort((a, b) => {
const fa = (a.firstName || '').toString().toLowerCase();
const fb = (b.firstName || '').toString().toLowerCase();
if (fa < fb) return -1;
if (fa > fb) return 1;
return (a.lastName || '').toString().localeCompare((b.lastName || '').toString());
});
},
friendlyMemberIdList(value) {
if (Array.isArray(value)) return value.map((id) => Number(id)).filter((id) => Number.isFinite(id));
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed.map((id) => Number(id)).filter((id) => Number.isFinite(id)) : [];
} catch (error) {
return [];
}
}
return [];
},
friendlyEligibleMemberIds(match = this.friendlyMatchDialog.match) {
const ready = new Set(this.friendlyMemberIdList(match?.playersReady));
const planned = new Set(this.friendlyMemberIdList(match?.playersPlanned));
if (!ready.size || !planned.size) return null;
return new Set([...ready].filter((id) => planned.has(id)));
},
friendlyParticipantMemberOptions(field) {
const source = field === 'guestParticipants' ? this.friendlyMatchDialog.guestMembers : this.friendlyMatchDialog.homeMembers;
const eligible = this.friendlyEligibleMemberIds();
if (!eligible) return source;
const selected = new Set((this.friendlyMatchDialog.form[field] || [])
.filter((participant) => participant?.type === 'member')
.map((participant) => Number(participant.memberId)));
return source.filter((member) => eligible.has(Number(member.id)) || selected.has(Number(member.id)));
},
sortMatchesByDateTime(matches) {
if (!Array.isArray(matches)) {
return [];
@@ -1077,6 +1222,7 @@ export default {
async openPlayerSelectionDialog(match) {
this.playerSelectionDialog.match = match;
this.playerSelectionDialog.readonly = this.isFriendlyMatchReadOnly(match);
this.playerSelectionDialog.isOpen = true;
this.playerSelectionDialog.loading = true;
@@ -1098,11 +1244,17 @@ export default {
const playedIds = normalizePlayersList(match.playersPlayed);
const preselectedIds = Array.from(new Set([...readyIds, ...plannedIds, ...playedIds]));
// Fetch members for the current club
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;
let allMembers = [];
if (match.isFriendly && match.isSharedFriendly) {
const ownSide = Number(match.homeClubId) === Number(this.currentClub) ? 'home' : 'guest';
const response = await apiClient.get(`/friendly-matches/shared/${this.currentClub}/${match.id}/members/${ownSide}`);
allMembers = response.data || [];
} else {
const response = match.isFriendly
? await apiClient.get(`/friendly-matches/${this.currentClub}/members/list`)
: await apiClient.get(`/clubmembers/get/${this.currentClub}/true`);
allMembers = response.data || [];
}
const lineupHalf = this.getLineupHalfForMatch(match);
const eligibleMemberIds = match.isFriendly ? [] : await this.getEligibleMemberIdsForSelectedTeam(lineupHalf);
@@ -1214,17 +1366,28 @@ export default {
async savePlayerSelection(closeDialog = true) {
const match = this.playerSelectionDialog.match;
if (!match) return;
if (!match || this.isFriendlyMatchReadOnly(match)) return;
const playersReady = this.playerSelectionDialog.members
.filter(m => m.isReady)
.map(m => m.id);
const playersPlanned = this.playerSelectionDialog.members
.filter(m => m.isPlanned)
.map(m => m.id);
const playersPlayed = this.playerSelectionDialog.members
.filter(m => m.hasPlayed)
.map(m => m.id);
const normalizePlayersList = (value) => {
if (Array.isArray(value)) return value.map((id) => Number(id)).filter((id) => Number.isFinite(id));
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed.map((id) => Number(id)).filter((id) => Number.isFinite(id)) : [];
} catch (error) {
return [];
}
}
return [];
};
const visibleIds = new Set(this.playerSelectionDialog.members.map((m) => Number(m.id)));
const mergeVisibleSelection = (existing, predicate) => [
...normalizePlayersList(existing).filter((id) => !visibleIds.has(Number(id))),
...this.playerSelectionDialog.members.filter(predicate).map((m) => Number(m.id)),
].filter((id, index, arr) => Number.isFinite(id) && arr.indexOf(id) === index);
const playersReady = mergeVisibleSelection(match.playersReady, (m) => m.isReady);
const playersPlanned = mergeVisibleSelection(match.playersPlanned, (m) => m.isPlanned);
const playersPlayed = mergeVisibleSelection(match.playersPlayed, (m) => m.hasPlayed);
console.log('[savePlayerSelection] Saving players:', { playersReady, playersPlanned, playersPlayed, matchId: match.id });
@@ -1386,23 +1549,67 @@ export default {
this.selectedFile = file;
this.importCSV();
},
async loadFriendlyMembers() {
const response = await apiClient.get(`/friendly-matches/${this.currentClub}/members/list`);
const members = response.data || [];
// Sort members alphabetically by firstName then lastName
this.friendlyMatchDialog.members = members.slice().sort((a, b) => {
const fa = (a.firstName || '').toString().toLowerCase();
const fb = (b.firstName || '').toString().toLowerCase();
if (fa < fb) return -1;
if (fa > fb) return 1;
const la = (a.lastName || '').toString().toLowerCase();
const lb = (b.lastName || '').toString().toLowerCase();
return la.localeCompare(lb);
});
async loadFriendlyVenues(match = null) {
this.friendlyMatchDialog.venues = [];
this.friendlyMatchDialog.selectedVenueId = '';
if (!this.currentClub) return;
const canLoad = !match?.isSharedFriendly || Number(match.homeClubId) === Number(this.currentClub);
if (!canLoad) return;
try {
const response = await apiClient.get(`/club-venues/${this.currentClub}`);
this.friendlyMatchDialog.venues = response.data || [];
} catch (error) {
this.friendlyMatchDialog.venues = [];
}
},
findFriendlyVenueForForm() {
const form = this.friendlyMatchDialog.form;
return this.friendlyMatchDialog.venues.find((venue) => (
String(venue.name || '') === String(form.locationName || '')
&& String(venue.address || '') === String(form.locationAddress || '')
&& String(venue.zip || '') === String(form.locationZip || '')
&& String(venue.city || '') === String(form.locationCity || '')
));
},
applyFriendlyVenue() {
const venue = this.friendlyMatchDialog.venues.find((item) => String(item.id) === String(this.friendlyMatchDialog.selectedVenueId));
if (!venue) {
this.friendlyMatchDialog.form.locationName = '';
this.friendlyMatchDialog.form.locationAddress = '';
this.friendlyMatchDialog.form.locationZip = '';
this.friendlyMatchDialog.form.locationCity = '';
return;
}
this.friendlyMatchDialog.form.locationName = venue.name || '';
this.friendlyMatchDialog.form.locationAddress = venue.address || '';
this.friendlyMatchDialog.form.locationZip = venue.zip || '';
this.friendlyMatchDialog.form.locationCity = venue.city || '';
},
async loadFriendlyMembers(match = null) {
const localMembers = async () => {
const response = await apiClient.get(`/friendly-matches/${this.currentClub}/members/list`);
return this.sortFriendlyMembers(response.data || []);
};
if (match?.isSharedFriendly) {
const [homeResponse, guestResponse] = await Promise.all([
apiClient.get(`/friendly-matches/shared/${this.currentClub}/${match.id}/members/home`),
apiClient.get(`/friendly-matches/shared/${this.currentClub}/${match.id}/members/guest`),
]);
this.friendlyMatchDialog.homeMembers = this.sortFriendlyMembers(homeResponse.data || []);
this.friendlyMatchDialog.guestMembers = this.sortFriendlyMembers(guestResponse.data || []);
this.friendlyMatchDialog.members = [...this.friendlyMatchDialog.homeMembers, ...this.friendlyMatchDialog.guestMembers];
return;
}
const members = await localMembers();
this.friendlyMatchDialog.members = members;
this.friendlyMatchDialog.homeMembers = members;
this.friendlyMatchDialog.guestMembers = [];
},
async openFriendlyMatchDialog(match = null) {
await this.loadFriendlyMembers();
await this.loadFriendlyMembers(match);
this.friendlyMatchDialog.match = match?.isFriendly ? match : null;
this.friendlyMatchDialog.editingId = match?.isFriendly ? match.id : null;
this.friendlyMatchDialog.readonly = this.isFriendlyMatchReadOnly(match);
this.friendlyMatchDialog.form = match?.isFriendly
? {
date: match.date ? String(match.date).slice(0, 10) : new Date().toISOString().slice(0, 10),
@@ -1421,14 +1628,22 @@ export default {
guestMatchPoints: match.guestMatchPoints ?? 0,
isCompleted: Boolean(match.isCompleted),
homeParticipants: [...this.parseFriendlyArray(match.homeParticipants)],
guestParticipants: [...this.parseFriendlyArray(match.guestParticipants)]
guestParticipants: [...this.parseFriendlyArray(match.guestParticipants)],
resultDetails: [...this.parseFriendlyArray(match.resultDetails)]
}
: this.emptyFriendlyMatchForm();
await this.loadFriendlyVenues(match);
const selectedVenue = this.findFriendlyVenueForForm();
this.friendlyMatchDialog.selectedVenueId = selectedVenue ? String(selectedVenue.id) : '';
this.friendlyMatchDialog.isOpen = true;
},
closeFriendlyMatchDialog() {
this.friendlyMatchDialog.isOpen = false;
this.friendlyMatchDialog.editingId = null;
this.friendlyMatchDialog.match = null;
this.friendlyMatchDialog.readonly = false;
this.friendlyMatchDialog.venues = [];
this.friendlyMatchDialog.selectedVenueId = '';
this.friendlyMatchDialog.form = this.emptyFriendlyMatchForm();
},
addFriendlyParticipant(field, memberId) {
@@ -1448,7 +1663,7 @@ export default {
friendlyParticipantLabel(participant, fallback = '') {
if (!participant) return fallback;
if (participant.type === 'member') {
const member = this.friendlyMatchDialog.members.find(m => Number(m.id) === Number(participant.memberId));
const member = [...this.friendlyMatchDialog.homeMembers, ...this.friendlyMatchDialog.guestMembers, ...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;
@@ -1473,49 +1688,231 @@ export default {
const second = labels[secondIndex % labels.length];
return first === second ? first : `${first} / ${second}`;
},
friendlyDoublePart(value, index) {
return String(value || '').split('/').map((part) => part.trim())[index] || '';
},
setFriendlyDoublePart(row, field, index, value) {
const parts = [this.friendlyDoublePart(row[field], 0), this.friendlyDoublePart(row[field], 1)];
parts[index] = value;
row[field] = parts.filter(Boolean).join(' / ');
this.autoSaveFriendlyResults();
},
friendlyResultSideLabels(side) {
const participants = side === 'guest'
? this.friendlyResultDialog.match?.guestParticipants
: this.friendlyResultDialog.match?.homeParticipants;
return this.friendlyParticipantLabels(participants);
},
friendlyResultNameOptions(side, row, field, doubleIndex = null) {
const labels = this.friendlyResultSideLabels(side);
const used = new Set();
for (const candidate of this.friendlyResultDialog.rows || []) {
if (candidate === row) continue;
if (candidate.type === 'double') {
this.friendlyDoublePart(candidate[field], 0) && used.add(this.friendlyDoublePart(candidate[field], 0));
this.friendlyDoublePart(candidate[field], 1) && used.add(this.friendlyDoublePart(candidate[field], 1));
} else if (candidate[field]) {
used.add(candidate[field]);
}
}
if (doubleIndex != null) {
const other = this.friendlyDoublePart(row[field], doubleIndex === 0 ? 1 : 0);
if (other) used.add(other);
}
const current = doubleIndex == null ? row[field] : this.friendlyDoublePart(row[field], doubleIndex);
return labels.filter((label) => label === current || !used.has(label));
},
friendlyEditDoubleRows() {
const form = this.friendlyMatchDialog.form;
const template = this.friendlyResultTemplate({
...form,
homeParticipants: form.homeParticipants,
guestParticipants: form.guestParticipants,
});
const doubleIds = template.filter((row) => row.type === 'double').map((row) => row.id);
if (!Array.isArray(form.resultDetails)) form.resultDetails = [];
const existingById = new Map(form.resultDetails
.filter((row) => row?.id)
.map((row) => [String(row.id), row]));
const singles = form.resultDetails.filter((row) => row?.type !== 'double');
const doubles = doubleIds.map((id) => {
const row = existingById.get(id) || { id, type: 'double', homeName: '', guestName: '', sets: ['', '', '', '', ''], completed: false };
row.id = id;
row.type = 'double';
row.sets = Array.from({ length: 5 }, (_, i) => row.sets?.[i] || '');
return row;
});
form.resultDetails = [...doubles, ...singles];
return doubles;
},
friendlyEditDoubleLabels(field) {
return this.friendlyParticipantLabels(this.friendlyMatchDialog.form[field]);
},
setFriendlyEditDoublePart(row, field, index, value) {
const parts = [this.friendlyDoublePart(row[field], 0), this.friendlyDoublePart(row[field], 1)];
parts[index] = value;
row[field] = parts.filter(Boolean).join(' / ');
},
friendlyEditDoubleOptions(participantField, row, nameField, doubleIndex) {
const labels = this.friendlyEditDoubleLabels(participantField);
const rows = this.friendlyEditDoubleRows();
const used = new Set();
for (const candidate of rows) {
if (candidate === row) continue;
this.friendlyDoublePart(candidate[nameField], 0) && used.add(this.friendlyDoublePart(candidate[nameField], 0));
this.friendlyDoublePart(candidate[nameField], 1) && used.add(this.friendlyDoublePart(candidate[nameField], 1));
}
const other = this.friendlyDoublePart(row[nameField], doubleIndex === 0 ? 1 : 0);
if (other) used.add(other);
const current = this.friendlyDoublePart(row[nameField], doubleIndex);
return labels.filter((label) => label === current || !used.has(label));
},
friendlySystemKey(system) {
return String(system || '').trim().toLowerCase();
},
friendlyResultTemplate(match) {
const system = this.friendlySystemKey(match.matchSystem);
const homeCount = this.friendlyParticipantLabels(match.homeParticipants).length;
const guestCount = this.friendlyParticipantLabels(match.guestParticipants).length;
const rows = (entries) => entries.map((entry, index) => ({
id: entry.id || `${entry.type === 'double' ? 'd' : 's'}-${index + 1}`,
type: entry.type,
home: entry.home,
guest: entry.guest,
}));
const d = (id, home, guest) => ({ id, type: 'double', home, guest });
const s = (id, home, guest) => ({ id, type: 'single', home, guest });
if (system.includes('bundessystem')) {
return rows([
d('d-1', 'D1', 'D1'), d('d-2', 'D2', 'D2'),
s('s-1', 'A1', 'B1'), s('s-2', 'A2', 'B2'), s('s-3', 'A3', 'B3'), s('s-4', 'A4', 'B4'),
s('s-5', 'A1', 'B2'), s('s-6', 'A2', 'B1'), s('s-7', 'A3', 'B4'), s('s-8', 'A4', 'B3'),
]);
}
if (system.includes('werner')) {
return rows([
d('d-1', 'D1', 'D1'), d('d-2', 'D2', 'D2'),
s('s-1', 'A1', 'B2'), s('s-2', 'A2', 'B1'), s('s-3', 'A3', 'B4'), s('s-4', 'A4', 'B3'),
s('s-5', 'A1', 'B1'), s('s-6', 'A2', 'B2'), s('s-7', 'A3', 'B3'), s('s-8', 'A4', 'B4'),
]);
}
if (system.includes('sechser')) {
return rows([
d('d-1', 'D1', 'D1'), d('d-2', 'D2', 'D2'), d('d-3', 'D3', 'D3'),
s('s-1', 'A1', 'B2'), s('s-2', 'A2', 'B1'), s('s-3', 'A1', 'B1'), s('s-4', 'A2', 'B2'),
s('s-5', 'A3', 'B4'), s('s-6', 'A4', 'B3'), s('s-7', 'A3', 'B3'), s('s-8', 'A4', 'B4'),
s('s-9', 'A5', 'B6'), s('s-10', 'A6', 'B5'), s('s-11', 'A5', 'B5'), s('s-12', 'A6', 'B6'),
d('d-4', 'D1', 'D1'),
]);
}
if (system.includes('europaliga')) {
return rows([
d('d-1', 'D1', 'D1'), d('d-2', 'D2', 'D2'), d('d-3', 'D3', 'D3'),
s('s-1', 'A1', 'B1'), s('s-2', 'A2', 'B2'), s('s-3', 'A1', 'B2'), s('s-4', 'A2', 'B1'),
s('s-5', 'A3', 'B3'), s('s-6', 'A4', 'B4'), s('s-7', 'A3', 'B4'), s('s-8', 'A4', 'B3'),
s('s-9', 'A5', 'B5'), s('s-10', 'A6', 'B6'), s('s-11', 'A5', 'B6'), s('s-12', 'A6', 'B5'),
]);
}
if (system.includes('corbillon')) {
return rows([
s('s-1', 'A1', 'B1'), s('s-2', 'A2', 'B2'), d('d-1', 'D1', 'D1'), s('s-3', 'A1', 'B2'), s('s-4', 'A2', 'B1'),
]);
}
if (system.includes('modifiziertes swaythling')) {
return rows([
s('s-1', 'A1', 'B2'), s('s-2', 'A2', 'B1'), s('s-3', 'A3', 'B3'), d('d-1', 'D1', 'D1'), s('s-4', 'A1', 'B1'), s('s-5', 'A3', 'B2'), s('s-6', 'A2', 'B3'),
]);
}
if (system.includes('swaythling')) {
return rows([
s('s-1', 'A1', 'B1'), s('s-2', 'A2', 'B2'), s('s-3', 'A3', 'B3'), s('s-4', 'A1', 'B2'), s('s-5', 'A2', 'B1'),
]);
}
if (system.includes('braunschweiger')) {
if (homeCount >= 4 && guestCount >= 4) {
return rows([
d('d-1', 'D1', 'D1'), d('d-2', 'D2', 'D2'),
s('s-1', 'A1', 'B1'), s('s-2', 'A2', 'B2'), s('s-3', 'A3', 'B3'), s('s-4', 'A4', 'B4'),
s('s-5', 'A1', 'B2'), s('s-6', 'A2', 'B1'), s('s-7', 'A3', 'B4'), s('s-8', 'A4', 'B3'),
]);
}
if (homeCount >= 4 && guestCount <= 3) {
return rows([
d('d-1', 'D1', 'D1'),
s('s-1', 'A3', 'B3'), s('s-2', 'A1', 'B2'), s('s-3', 'A2', 'B1'), s('s-4', 'A4', 'B2'),
s('s-5', 'A1', 'B1'), s('s-6', 'A4', 'B3'), s('s-7', 'A2', 'B2'), s('s-8', 'A1', 'B3'), s('s-9', 'A3', 'B1'),
]);
}
if (homeCount <= 3 && guestCount >= 4) {
return rows([
d('d-1', 'D1', 'D1'),
s('s-1', 'A3', 'B3'), s('s-2', 'A2', 'B1'), s('s-3', 'A1', 'B2'), s('s-4', 'A2', 'B4'),
s('s-5', 'A1', 'B1'), s('s-6', 'A3', 'B4'), s('s-7', 'A2', 'B2'), s('s-8', 'A3', 'B1'), s('s-9', 'A1', 'B3'),
]);
}
return rows([
d('d-1', 'D1', 'D1'),
s('s-1', 'A1', 'B2'), s('s-2', 'A2', 'B1'), s('s-3', 'A3', 'B2'), s('s-4', 'A2', 'B3'),
s('s-5', 'A1', 'B1'), s('s-6', 'A3', 'B3'), s('s-7', 'A2', 'B2'), s('s-8', 'A3', 'B1'), s('s-9', 'A1', 'B3'),
]);
}
const doublesCount = Number.parseInt(match.doublesCount, 10) || 0;
const singlesCount = Number.parseInt(match.singlesCount, 10) || 0;
return rows([
...Array.from({ length: doublesCount }, (_, i) => d(`d-${i + 1}`, `D${i + 1}`, `D${i + 1}`)),
...Array.from({ length: singlesCount }, (_, i) => s(`s-${i + 1}`, `A${(i % Math.max(homeCount, 1)) + 1}`, `B${(i % Math.max(guestCount, 1)) + 1}`)),
]);
},
friendlyPlayerForCode(labels, code) {
const match = String(code || '').match(/[AB](\d+)/i);
if (!match) return '';
return labels[Number(match[1]) - 1] || '';
},
friendlyDoubleForCode(match, labels, side, code) {
const number = Number(String(code || '').match(/D[A-Z]?(\d+)/i)?.[1] || 1);
const row = this.parseFriendlyArray(match.resultDetails).find((candidate) => String(candidate?.id) === `d-${number}`);
const value = row?.[side === 'guest' ? 'guestName' : 'homeName'];
return value || this.friendlyDoubleLabel(labels, number - 1);
},
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;
return this.friendlyResultTemplate(match).map((templateRow) => ({
id: templateRow.id,
type: templateRow.type,
homeName: templateRow.type === 'double'
? this.friendlyDoubleForCode(match, homeLabels, 'home', templateRow.home)
: this.friendlyPlayerForCode(homeLabels, templateRow.home),
guestName: templateRow.type === 'double'
? this.friendlyDoubleForCode(match, guestLabels, 'guest', templateRow.guest)
: this.friendlyPlayerForCode(guestLabels, templateRow.guest),
sets: ['', '', '', '', ''],
completed: false
}));
},
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;
if (!existing.length) return generated;
const existingById = new Map(existing
.filter((row) => row?.id)
.map((row) => [String(row.id), row]));
return generated.map((generatedRow, index) => {
const existingRow = existingById.get(String(generatedRow.id)) || existing[index] || null;
if (!existingRow) return generatedRow;
return {
...generatedRow,
homeName: generatedRow.homeName || '',
guestName: generatedRow.guestName || '',
sets: Array.from({ length: 5 }, (_, i) => existingRow.sets?.[i] || generatedRow.sets?.[i] || ''),
completed: Boolean(existingRow.completed),
};
});
},
async openFriendlyResultDialog(match) {
await this.loadFriendlyMembers();
await this.loadFriendlyMembers(match);
this.friendlyResultDialog.match = match;
this.friendlyResultDialog.rows = this.buildFriendlyResultRows(match);
this.friendlyResultDialog.error = '';
@@ -1555,7 +1952,7 @@ export default {
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;
if (Math.max(a, b) < 11 || Math.abs(a - b) < 2) return null;
return `${a}:${b}`;
}
const losing = Math.abs(Number(raw));
@@ -1587,6 +1984,50 @@ export default {
}
return { winner: null, decisiveIndex: null };
},
calculateFriendlyRowSets(row) {
const requiredSets = this.getFriendlyWinningSets();
let homeSets = 0;
let guestSets = 0;
for (const set of row.sets || []) {
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) break;
}
return { home: homeSets, guest: guestSets };
},
friendlyRowSetScore(row) {
const sets = this.calculateFriendlyRowSets(row);
return `${sets.home}:${sets.guest}`;
},
friendlyRowPointScore(row) {
const score = this.friendlyRowPointScoreObject(row);
return `${score.home}:${score.guest}`;
},
friendlyRowPointScoreObject(row) {
const winner = this.calculateFriendlyRowWinner(row);
if (winner === 'home') return { home: 1, guest: 0 };
if (winner === 'guest') return { home: 0, guest: 1 };
return { home: 0, guest: 0 };
},
friendlyResultViewerSide() {
const match = this.friendlyResultDialog.match;
if (match?.isSharedFriendly) {
if (Number(match.homeClubId) === Number(this.currentClub)) return 'home';
if (Number(match.guestClubId) === Number(this.currentClub)) return 'guest';
}
return this.isOurClubPlayingHome(match) ? 'home' : 'guest';
},
friendlyScorePerspectiveClass(score) {
const home = Number(score?.home || 0);
const guest = Number(score?.guest || 0);
if (home === guest) return 'score-even';
const viewerSide = this.friendlyResultViewerSide();
const viewerLeading = viewerSide === 'guest' ? guest > home : home > guest;
return viewerLeading ? 'score-leading' : 'score-trailing';
},
calculateFriendlyRowWinner(row) {
return this.calculateFriendlyRowState(row).winner;
},
@@ -1609,6 +2050,17 @@ export default {
return score;
}, { home: 0, guest: 0 });
},
calculateFriendlyResultSetScore(rows) {
return (rows || []).reduce((score, row) => {
const sets = this.calculateFriendlyRowSets(row);
score.home += sets.home;
score.guest += sets.guest;
return score;
}, { home: 0, guest: 0 });
},
isFriendlyResultComplete(rows = this.friendlyResultDialog.rows) {
return Array.isArray(rows) && rows.length > 0 && rows.every((row) => Boolean(this.calculateFriendlyRowWinner(row)));
},
async autoSaveFriendlyResults() {
if (this.friendlyResultDialog.saving) {
this.friendlyResultDialog.saveAgain = true;
@@ -1622,7 +2074,7 @@ export default {
async saveFriendlyResults(isCompleted = false, options = {}) {
const { closeDialog = true, reloadMatches = true } = options;
const match = this.friendlyResultDialog.match;
if (!match) return;
if (!match || this.isFriendlyMatchReadOnly(match)) return;
for (const row of this.friendlyResultDialog.rows) {
const normalizedSets = [];
for (const set of row.sets) {
@@ -1641,17 +2093,18 @@ export default {
this.applyFriendlyRowCompletion(row);
}
const score = this.calculateFriendlyResultScore(this.friendlyResultDialog.rows);
const completed = Boolean(isCompleted || this.isFriendlyResultComplete(this.friendlyResultDialog.rows));
try {
this.friendlyResultDialog.saving = true;
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,
isCompleted: completed,
resultDetails: this.friendlyResultDialog.rows
});
match.homeMatchPoints = score.home;
match.guestMatchPoints = score.guest;
match.isCompleted = isCompleted;
match.isCompleted = completed;
match.resultDetails = this.friendlyResultDialog.rows.map((row) => ({ ...row, sets: [...row.sets] }));
if (closeDialog) {
this.closeFriendlyResultDialog();
@@ -1669,6 +2122,8 @@ export default {
await this.saveFriendlyResults(true);
},
async saveFriendlyMatch() {
if (this.friendlyMatchDialog.readonly) return;
this.friendlyEditDoubleRows();
try {
const payload = {
...this.friendlyMatchDialog.form,
@@ -1693,7 +2148,7 @@ export default {
}
},
async toggleHomeAway(match) {
if (!match || !match.id) return;
if (!match || !match.id || this.isFriendlyMatchReadOnly(match)) return;
const originalHome = match.homeTeam ? { ...match.homeTeam } : { name: '' };
const originalGuest = match.guestTeam ? { ...match.guestTeam } : { name: '' };
// Optimistic UI update: swap locally
@@ -1727,7 +2182,7 @@ export default {
}
},
async deleteFriendlyMatch() {
if (!this.friendlyMatchDialog.editingId) return;
if (!this.friendlyMatchDialog.editingId || this.friendlyMatchDialog.readonly) return;
const confirmed = await this.showConfirm('Freundschaftsspiel löschen', 'Soll dieses Freundschaftsspiel gelöscht werden?', '', 'warning');
if (!confirmed) return;
try {
@@ -2474,6 +2929,14 @@ td {
border-radius: 6px;
}
.friendly-venue-summary {
display: flex;
flex-direction: column;
gap: 0.35rem;
color: #374151;
font-size: 0.9rem;
}
.friendly-checkbox {
flex-direction: row !important;
align-items: center;
@@ -2515,6 +2978,34 @@ td {
border-radius: 6px;
}
.friendly-doubles-section {
border: 1px solid var(--border-color, #ddd);
border-radius: 8px;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.friendly-double-row {
display: grid;
gap: 0.5rem;
background: var(--background-soft, #f7f7f7);
border-radius: 6px;
padding: 0.65rem;
}
.friendly-double-side {
display: grid;
grid-template-columns: 4rem 1fr 1fr;
gap: 0.5rem;
align-items: center;
}
.friendly-double-side select {
min-width: 0;
}
.friendly-actions-cell {
display: flex;
gap: 0.5rem;
@@ -2532,7 +3023,8 @@ td {
border-collapse: collapse;
}
.friendly-result-table input {
.friendly-result-table input,
.friendly-result-table select {
width: 100%;
box-sizing: border-box;
padding: 0.35rem 0.45rem;
@@ -2544,11 +3036,37 @@ td {
min-width: 12rem;
}
.friendly-double-select {
display: grid;
gap: 0.25rem;
}
.friendly-result-table .set-input {
width: 4.5rem;
text-align: center;
}
.friendly-result-score-cell,
.score-value {
border-radius: 4px;
padding: 0.2rem 0.45rem;
}
.score-leading {
color: #0f7a3b;
background: #e8f7ee;
}
.score-trailing {
color: #b42318;
background: #fff0ee;
}
.score-even {
color: #175cd3;
background: #eef4ff;
}
.friendly-result-error {
color: #b00020;
font-weight: 600;