Tournament search and filters
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 17m19s

This commit is contained in:
Torsten Schulz (local)
2026-09-04 07:09:11 +02:00
parent a7b395163b
commit 5ac126bedb
3 changed files with 102 additions and 16 deletions

View File

@@ -20,7 +20,7 @@
</div>
</div>
<label v-if="!standaloneTest" class="test-mode-control" :class="{ active: isTestMode }">
<label v-if="!standaloneTest && !viewOnly" class="test-mode-control" :class="{ active: isTestMode }">
<input v-model="isTestMode" type="checkbox" @change="onTestModeChange" />
<span>
<strong>Testmodus</strong>
@@ -28,7 +28,13 @@
</span>
</label>
<div v-else class="standalone-test-banner">
<div v-if="viewOnly" class="view-only-banner">
<span>👁 Auswärtsspiel nur Ansicht. Der Spielbericht wird live aktualisiert.</span>
<span v-if="lastLiveUpdatedAt">Zuletzt aktualisiert: {{ formatLiveUpdatedAt() }}</span>
<button type="button" class="btn-secondary" :disabled="loading" @click="refreshViewOnlyReport"> Aktualisieren</button>
</div>
<div v-if="standaloneTest" class="standalone-test-banner">
<span>🧪 Eigenständiger Simulator keine Verbindung zu nuScore</span>
<label>
Spielsystem
@@ -110,7 +116,7 @@
</ul>
</div>
<div class="nuscore-sections">
<div class="nuscore-sections">
<div class="section-grid">
<button class="section-btn" :class="{ active: activeSection === 'general' }" @click="setActiveSection('general')">
<div class="section-icon"></div>
@@ -178,8 +184,9 @@
:value="getFormattedTime(match.startDate)"
@change="setStartTime($event.target.value)"
class="time-input"
:disabled="viewOnly"
/>
<button @click="setCurrentStartTime" class="time-btn" :title="$t('matchReportApi.setCurrentTime')">🕐</button>
<button @click="setCurrentStartTime" class="time-btn" :title="$t('matchReportApi.setCurrentTime')" :disabled="viewOnly">🕐</button>
</div>
</div>
@@ -191,8 +198,9 @@
:value="getFormattedTime(match.endDate)"
@change="setEndTime($event.target.value)"
class="time-input"
:disabled="viewOnly"
/>
<button @click="setCurrentEndTime" class="time-btn" :title="$t('matchReportApi.setCurrentTime')">🕐</button>
<button @click="setCurrentEndTime" class="time-btn" :title="$t('matchReportApi.setCurrentTime')" :disabled="viewOnly">🕐</button>
</div>
</div>
@@ -209,6 +217,7 @@
v-model="teamNotAppeared"
class="not-appeared-select"
:class="{ 'not-appeared': teamNotAppeared !== null }"
:disabled="viewOnly"
>
<option :value="null">{{ $t('matchReportApi.bothTeamsAppeared') }}</option>
<option value="home">{{ $t('matchReportApi.homeTeamNotAppeared', { team: meetingData.homeTeamname || meetingData.homeClub }) }}</option>
@@ -462,7 +471,7 @@
<div v-else-if="activeSection === 'greeting'" class="greeting-content">
<div class="greeting-header">
<h3>Begrüßung</h3>
<button
<button v-if="!viewOnly"
@click="toggleGreetingCompleted()"
class="completion-toggle-btn"
:class="{ 'completed': isGreetingCompleted }"
@@ -479,7 +488,7 @@
<div class="time-row">
<strong>Startzeit:</strong>
<span>{{ formatTime(match.startDate) }}</span>
<button @click="setCurrentStartTime()" class="time-btn-small" :disabled="isMatchCompleted">🕐 jetzt setzen</button>
<button v-if="!viewOnly" @click="setCurrentStartTime()" class="time-btn-small" :disabled="isMatchCompleted">🕐 jetzt setzen</button>
</div>
</div>
</div>
@@ -537,8 +546,8 @@
</td>
<td class="state-cell">
<div class="state-content">
<button v-if="!m.completed" class="state-btn" @click="completeMatch(idx)" title="Match abschließen"></button>
<button v-else class="state-btn" @click="reopenMatch(idx)" title="Match wieder öffnen"></button>
<button v-if="!viewOnly && !m.completed" class="state-btn" @click="completeMatch(idx)" title="Match abschließen"></button>
<button v-else-if="!viewOnly" class="state-btn" @click="reopenMatch(idx)" title="Match wieder öffnen"></button>
<div class="set-ratio">{{ getSetRatio(m) }}</div>
</div>
</td>
@@ -557,7 +566,7 @@
</table>
<!-- Auto-Fill Button -->
<div class="autofill-section">
<div v-if="!viewOnly" class="autofill-section">
<button @click="autoFillEmptyMatches()" class="autofill-btn" :disabled="isMatchCompleted">
📝 Auto-ausfüllen: Leere Positionen mit 3:0 automatisch ausfüllen
</button>
@@ -577,7 +586,7 @@
<div class="time-row">
<strong>Endzeit:</strong>
<span>{{ formatTime(match.endDate) }}</span>
<button @click="setEndTimeAndNavigate()" class="time-btn-small" :disabled="isMatchCompleted">🕐 jetzt setzen</button>
<button v-if="!viewOnly" @click="setEndTimeAndNavigate()" class="time-btn-small" :disabled="isMatchCompleted">🕐 jetzt setzen</button>
</div>
</div>
</div>
@@ -853,6 +862,10 @@ export default {
standaloneTest: {
type: Boolean,
default: false
},
viewOnly: {
type: Boolean,
default: false
}
},
data() {
@@ -862,6 +875,7 @@ export default {
isTestMode: this.standaloneTest,
testVerification: null,
testSubmissionResult: null,
lastLiveUpdatedAt: null,
originalTestInput: null,
// Unveränderliche Referenz auf den offiziellen Bericht, bevor der
// Testmodus seinen vollständig leeren lokalen Zustand erzeugt.
@@ -1007,6 +1021,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
return sets[this.editingSetCell.setIndex] || '';
},
canOpenNextStages() {
if (this.viewOnly) return true;
// Wenn das Match bereits abgeschlossen ist, dürfen keine Änderungen mehr gemacht werden
if (this.isMatchCompleted) {
return false;
@@ -1020,12 +1035,14 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
return this.isHomeLineupCertified && this.isGuestLineupCertified;
},
canOpenResultStage() {
if (this.viewOnly) return true;
if (this.teamNotAppeared !== null) {
return false;
}
return this.areBothLineupsCertified();
},
canOpenCompletionStage() {
if (this.viewOnly) return true;
if (this.isMatchCompleted) {
return false;
}
@@ -1242,6 +1259,16 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
this.meetingDetailsPollInterval = null;
}
},
async refreshViewOnlyReport() {
if (!this.viewOnly || this.loading) return;
await this.loadData();
},
formatLiveUpdatedAt() {
if (!this.lastLiveUpdatedAt) return '';
return new Intl.DateTimeFormat('de-DE', {
hour: '2-digit', minute: '2-digit', second: '2-digit'
}).format(this.lastLiveUpdatedAt);
},
buildMeetingDetailsSignature(details) {
if (!details || typeof details !== 'object') {
return '';
@@ -1319,6 +1346,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
this.lastMeetingDetailsSignature = latestSignature;
this.meetingDetails = latestMeetingDetails;
this.lastLiveUpdatedAt = new Date();
if (latestMeetingDetails.startDate != null && this.meetingData) {
this.meetingData.startDate = latestMeetingDetails.startDate;
@@ -1459,6 +1487,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
// Toggle Begrüßung abgeschlossen/nicht abgeschlossen
toggleGreetingCompleted() {
if (this.viewOnly) return;
this.isGreetingCompleted = !this.isGreetingCompleted;
if (this.isGreetingCompleted) {
@@ -1482,6 +1511,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
// Setze aktuelle Zeit für Startzeit
setCurrentStartTime() {
if (this.viewOnly) return;
const now = new Date();
const timeString = now.toTimeString().substring(0, 8); // HH:MM:SS Format
this.match.startDate = now;
@@ -1501,11 +1531,13 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
// Setze aktuelle Zeit für Startzeit (Legacy für Kompatibilität)
setCurrentEndTime() {
if (this.viewOnly) return;
this.setEndTimeAndNavigate();
},
// Auto-Fill für leere Matches
autoFillEmptyMatches() {
if (this.viewOnly) return;
console.log('🔧 Starte Auto-Fill für leere Matches...');
this.results.forEach((match, index) => {
@@ -1990,6 +2022,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
async submitMatchReport() {
try {
if (this.viewOnly) return;
console.log('🚀 Starte Spielbericht-Absendung...');
if (this.isTestMode) {
@@ -2852,6 +2885,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
if (detailsResponse.ok) {
this.meetingDetails = await detailsResponse.json();
this.lastMeetingDetailsSignature = this.buildMeetingDetailsSignature(this.meetingDetails);
this.lastLiveUpdatedAt = new Date();
console.log('[nuscore] /meetingdetails response:', this.meetingDetails);
this.logBraunschweigerDebugSnapshot('Server /meetingdetails');
}
@@ -3611,6 +3645,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
}
},
completeMatch(idx) {
if (this.viewOnly) return;
const m = this.results[idx];
// Validiere ob Match korrekt abgeschlossen werden kann
@@ -3687,6 +3722,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
this.closeSetKeyboard();
},
reopenMatch(idx) {
if (this.viewOnly) return;
const m = this.results[idx];
m.completed = false;
m.result = '1:0';
@@ -3822,6 +3858,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
// Prüfe ob ein Satz-Input-Feld deaktiviert werden sollte
isSetInputDisabled(matchIndex, setIndex) {
if (this.viewOnly) return true;
const match = this.results[matchIndex];
// Wenn Match bereits abgeschlossen, alle Felder deaktiviert
@@ -4312,6 +4349,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
},
togglePlayerSelection(player, team) {
if (this.viewOnly) return;
if (this.isLineupLocked(team)) return;
if (!this.meetingDetails) return;
@@ -4352,6 +4390,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
},
toggleDoublePosition(player, team, doublePosition) {
if (this.viewOnly) return;
if (this.isLineupLocked(team)) return;
if (!this.meetingDetails) return;
@@ -4386,6 +4425,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
},
canSelectDoublePosition(player, team, doublePosition) {
if (this.viewOnly) return false;
if (!this.meetingDetails) return false;
const teamKey = team === 'home' ? 'teamLineupHomePlayers' : 'teamLineupGuestPlayers';
@@ -4538,6 +4578,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
// Aufstellung signieren
async signLineup(team) {
if (this.viewOnly) return;
if (this.isLineupLocked(team)) {
return;
}
@@ -5204,6 +5245,21 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
cursor: pointer;
}
.view-only-banner {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.75rem;
margin: 0 0 16px;
padding: 10px 14px;
border: 1px solid #7b95a8;
border-radius: 10px;
background: #eef5fa;
color: #254d68;
font-weight: 600;
}
.test-mode-control.active {
border-color: #d78a00;
background: #fff3cd;

View File

@@ -13,12 +13,28 @@
<section v-if="workspaceTab === 'suggestions'" class="workspace-panel tournament-suggestions-panel" aria-label="Turniervorschläge">
<div class="panel-header"><h3>Turniervorschläge</h3><p>Turniere aus dem myTischtennis-Kalender prüfen und danach manuell einpflegen.</p></div>
<div class="panel-toolbar"><button class="btn-primary" :disabled="fetchingSuggestions" @click="fetchSuggestions">{{ fetchingSuggestions ? 'Abruf läuft ' : 'Jetzt abrufen' }}</button><span class="toolbar-meta">{{ newSuggestions.length }} neu</span></div>
<div class="panel-toolbar suggestion-toolbar">
<button class="btn-primary" :disabled="fetchingSuggestions" @click="fetchSuggestions">{{ fetchingSuggestions ? 'Abruf läuft ' : 'Jetzt abrufen' }}</button>
<input
v-model.trim="suggestionSearch"
type="search"
class="title-input"
placeholder="Turnier suchen"
aria-label="Turniervorschläge suchen"
/>
<select v-model="suggestionStatusFilter" aria-label="Status der Turniervorschläge filtern">
<option value="new">Neue</option>
<option value="reviewed">Geprüfte</option>
<option value="dismissed">Ausgeblendete</option>
<option value="all">Alle</option>
</select>
<span class="toolbar-meta">{{ filteredSuggestions.length }} angezeigt · {{ newSuggestions.length }} neu</span>
</div>
<p v-if="suggestionError" class="suggestion-error">{{ suggestionError }}</p>
<ul v-if="newSuggestions.length" class="event-list suggestion-list">
<li v-for="suggestion in newSuggestions" :key="suggestion.id" class="event-item"><div class="suggestion-main"><strong>{{ suggestion.title }}</strong><span>{{ suggestion.eventDate || 'Termin offen' }}<template v-if="suggestion.location"> · {{ suggestion.location }}</template></span></div><a class="btn-secondary" :href="suggestion.sourceUrl" target="_blank" rel="noopener">Öffnen</a><button class="btn-secondary" @click="updateSuggestionStatus(suggestion, 'reviewed')">Geprüft</button><button class="btn-secondary" @click="updateSuggestionStatus(suggestion, 'dismissed')">Ausblenden</button></li>
<ul v-if="filteredSuggestions.length" class="event-list suggestion-list">
<li v-for="suggestion in filteredSuggestions" :key="suggestion.id" class="event-item"><div class="suggestion-main"><strong>{{ suggestion.title }}</strong><span>{{ suggestion.eventDate || 'Termin offen' }}<template v-if="suggestion.location"> · {{ suggestion.location }}</template></span></div><a class="btn-secondary" :href="suggestion.sourceUrl" target="_blank" rel="noopener">Öffnen</a><template v-if="suggestion.status === 'dismissed'"><button class="btn-secondary" @click="updateSuggestionStatus(suggestion, 'new')">Einblenden</button></template><template v-else><button class="btn-secondary" @click="updateSuggestionStatus(suggestion, suggestion.status === 'reviewed' ? 'new' : 'reviewed')">{{ suggestion.status === 'reviewed' ? 'Als neu markieren' : 'Geprüft' }}</button><button class="btn-secondary" @click="updateSuggestionStatus(suggestion, 'dismissed')">Ausblenden</button></template></li>
</ul>
<p v-else class="empty-state compact">Keine neuen Vorschläge. Jetzt abrufen eignet sich zum Testen.</p>
<p v-else class="empty-state compact">{{ suggestionSearch ? 'Keine passenden Turniervorschläge gefunden.' : 'Keine Turniervorschläge in dieser Ansicht. „Jetzt abrufen“ aktualisiert den Kalender.' }}</p>
</section>
<section v-else-if="workspaceTab === 'import'" class="workspace-panel" aria-label="Turnier importieren">
<div class="panel-header">
@@ -652,6 +668,7 @@ export default {
editingTitle: '',
autoRegistering: false,
suggestions: [], fetchingSuggestions: false, suggestionError: '',
suggestionSearch: '', suggestionStatusFilter: 'new',
tacticPlans: [], tacticPlan: null, tacticPlansSnapshot: '', tacticsLoading: false, tacticSaving: false, tacticsError: '', tacticTournamentId: null, tacticLoadToken: 0, reloadToken: 0,
tournamentObservationDialog: { isOpen: false, member: null, notes: [] },
};
@@ -731,6 +748,18 @@ export default {
});
},
newSuggestions() { return (this.suggestions || []).filter((suggestion) => suggestion.status === 'new'); },
filteredSuggestions() {
const search = String(this.suggestionSearch || '').trim().toLocaleLowerCase('de');
return (this.suggestions || []).filter((suggestion) => {
if (this.suggestionStatusFilter !== 'all' && suggestion.status !== this.suggestionStatusFilter) return false;
if (!search) return true;
return [suggestion.title, suggestion.eventDate, suggestion.location, suggestion.organizer, suggestion.federation, suggestion.sourceUrl]
.filter(Boolean)
.join(' ')
.toLocaleLowerCase('de')
.includes(search);
});
},
historySummaryText() {
const rows = this.clubParticipationRows();
if (this.loadingClubParticipations) return 'Turnierbeteiligungen werden geladen';

View File

@@ -2860,7 +2860,8 @@ export default {
title,
component: 'MatchReportApiDialog',
props: {
match
match,
viewOnly: !this.isOurClubPlayingHome(match)
}
});
},