feat(MemberTtrHistory): implement TTR history management and UI enhancements
- Added new endpoints in the member controller for retrieving and refreshing TTR history. - Integrated TTR history functionality into the member service, allowing for seamless data retrieval and updates. - Updated the member model to include a field for TTR history player ID, enhancing data tracking. - Enhanced the MembersView to display TTR history with a dedicated dialog for better user interaction. - Improved the MyTischtennisClient to support fetching historical player IDs, enriching the data provided to users. - Refactored various components to ensure consistent styling and functionality across the application.
This commit is contained in:
@@ -78,6 +78,16 @@ export default {
|
||||
default: 'medium',
|
||||
validator: (value) => ['small', 'medium', 'large', 'fullscreen'].includes(value)
|
||||
},
|
||||
|
||||
width: {
|
||||
type: [String, Number],
|
||||
default: null
|
||||
},
|
||||
|
||||
maxWidth: {
|
||||
type: [String, Number],
|
||||
default: null
|
||||
},
|
||||
|
||||
// Position für nicht-modale Dialoge
|
||||
position: {
|
||||
@@ -136,6 +146,14 @@ export default {
|
||||
const style = {
|
||||
zIndex: this.zIndex
|
||||
};
|
||||
|
||||
if (this.width !== null) {
|
||||
style.width = typeof this.width === 'number' ? `${this.width}px` : this.width;
|
||||
}
|
||||
|
||||
if (this.maxWidth !== null) {
|
||||
style.maxWidth = typeof this.maxWidth === 'number' ? `${this.maxWidth}px` : this.maxWidth;
|
||||
}
|
||||
|
||||
if (!this.isModal) {
|
||||
style.left = `${this.localPosition.x}px`;
|
||||
@@ -311,6 +329,7 @@ export default {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
color: var(--text-on-primary);
|
||||
}
|
||||
|
||||
/* Controls */
|
||||
|
||||
@@ -731,6 +731,8 @@ export default {
|
||||
home: null,
|
||||
guest: null
|
||||
},
|
||||
initialCompletionState: false,
|
||||
submitSucceeded: false,
|
||||
isHomeLineupCertified: false,
|
||||
isGuestLineupCertified: false,
|
||||
isGreetingCompleted: false,
|
||||
@@ -885,13 +887,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
|
||||
|
||||
// Prüfe ob das Match bereits abgeschlossen ist
|
||||
isMatchCompleted() {
|
||||
if (typeof this.meetingDetails?.isCompleted === 'boolean') {
|
||||
return this.meetingDetails.isCompleted;
|
||||
}
|
||||
if (typeof this.meetingData?.isCompleted === 'boolean') {
|
||||
return this.meetingData.isCompleted;
|
||||
}
|
||||
return this.match && this.match.isCompleted === true;
|
||||
return this.submitSucceeded || this.initialCompletionState === true;
|
||||
},
|
||||
isSubmitDisabled() {
|
||||
if (this.isMatchCompleted) {
|
||||
@@ -975,18 +971,38 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
logSignatureState(label, payload) {
|
||||
const signature = payload?.signature || null;
|
||||
console.log(`[match-report] ${label}`, {
|
||||
wo: payload?.wo ?? null,
|
||||
isCompleted: payload?.isCompleted ?? null,
|
||||
homePin: payload?.homePin ?? null,
|
||||
guestPin: payload?.guestPin ?? null,
|
||||
releaseSignatureHome: signature?.releaseSignatureHome ?? null,
|
||||
releaseSignatureGuest: signature?.releaseSignatureGuest ?? null,
|
||||
lineupSignatureHome: signature?.lineupSignatureHome ?? null,
|
||||
lineupSignatureGuest: signature?.lineupSignatureGuest ?? null
|
||||
});
|
||||
getFriendlySubmitErrorMessage(message) {
|
||||
const normalized = (message || '').toString();
|
||||
|
||||
if (normalized.includes('Meeting wurde bereits freigegeben')) {
|
||||
return 'Der Spielbericht ist in nuScore bereits freigegeben.\n\nDie dort gespeicherten Daten sind offenbar nicht vollständig und können aus dieser Ansicht nicht mehr zuverlässig überschrieben werden.\n\nBitte den Spielbericht in nuScore bzw. durch eine Person mit den nötigen Rechten prüfen und dort korrigieren lassen.';
|
||||
}
|
||||
|
||||
return normalized || 'Fehler beim Absenden des Spielberichts.';
|
||||
},
|
||||
|
||||
hasReleaseSignatureForCompletion(source) {
|
||||
if (!source) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const signature = source.signature || {};
|
||||
const fallbackWo = this.teamNotAppeared === 'home'
|
||||
? 'A'
|
||||
: (this.teamNotAppeared === 'guest' ? 'B' : null);
|
||||
const wo = source.wo ?? fallbackWo;
|
||||
|
||||
const hasHomeRelease = typeof signature.releaseSignatureHome === 'string' && signature.releaseSignatureHome.trim() !== '';
|
||||
const hasGuestRelease = typeof signature.releaseSignatureGuest === 'string' && signature.releaseSignatureGuest.trim() !== '';
|
||||
|
||||
if (wo === 'A') {
|
||||
return hasGuestRelease;
|
||||
}
|
||||
if (wo === 'B') {
|
||||
return hasHomeRelease;
|
||||
}
|
||||
|
||||
return hasHomeRelease && hasGuestRelease;
|
||||
},
|
||||
|
||||
// Effektive Spieleranzahl (berücksichtigt Braunschweiger-Regel)
|
||||
@@ -1580,10 +1596,8 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
|
||||
|
||||
// Aktualisiere die Match-Daten mit unseren Eingaben
|
||||
console.log('🔄 Aktualisiere Match-Daten...');
|
||||
this.updateMatchData(matchData);
|
||||
this.updateMatchData(matchData, { finalizeReport: true });
|
||||
console.log('✅ Match-Daten aktualisiert');
|
||||
this.logSignatureState('payload before validate', matchData);
|
||||
|
||||
// Für WebSocket-Broadcast: clubId und gameCode mitsenden
|
||||
const clubId = this.$store?.getters?.currentClub;
|
||||
if (clubId) matchData.clubId = String(clubId);
|
||||
@@ -1597,10 +1611,13 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
|
||||
throw new Error('nuLigaMeetingUuid nicht gefunden');
|
||||
}
|
||||
|
||||
const validationResult = await this.validateReport(matchData);
|
||||
if (validationResult?.resultState === 'VALIDATION_ERROR') {
|
||||
const validationMessage = Array.isArray(validationResult.validationErrors) && validationResult.validationErrors.length > 0
|
||||
? validationResult.validationErrors.join('\n')
|
||||
const validationPayload = JSON.parse(JSON.stringify(matchData));
|
||||
validationPayload.isCompleted = false;
|
||||
const validationResult = await this.validateReport(validationPayload);
|
||||
const validationErrors = Array.isArray(validationResult?.validationErrors) ? validationResult.validationErrors : [];
|
||||
if (validationResult?.resultState === 'VALIDATION_ERROR' || validationErrors.length > 0) {
|
||||
const validationMessage = validationErrors.length > 0
|
||||
? validationErrors.join('\n')
|
||||
: 'Validierung fehlgeschlagen';
|
||||
throw new Error(validationMessage);
|
||||
}
|
||||
@@ -1608,8 +1625,16 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
|
||||
const submitPayload = validationResult?.object
|
||||
? JSON.parse(JSON.stringify(validationResult.object))
|
||||
: matchData;
|
||||
this.logSignatureState('payload before submit', submitPayload);
|
||||
|
||||
submitPayload.isCompleted = true;
|
||||
if (!submitPayload.signature || typeof submitPayload.signature !== 'object') {
|
||||
submitPayload.signature = {};
|
||||
}
|
||||
if (!isHomeNotAppeared && this.finalHomePin && this.finalHomePin.trim() !== '') {
|
||||
submitPayload.homePin = this.finalHomePin.trim();
|
||||
}
|
||||
if (!isGuestNotAppeared && this.finalGuestPin && this.finalGuestPin.trim() !== '') {
|
||||
submitPayload.guestPin = this.finalGuestPin.trim();
|
||||
}
|
||||
const response = await fetch(`${backendBaseUrl}/api/nuscore/submit/${uuid}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
@@ -1619,7 +1644,6 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[match-report] submit response', result);
|
||||
|
||||
if (!response.ok) {
|
||||
const validationMessage = Array.isArray(result?.details?.validationErrors) && result.details.validationErrors.length > 0
|
||||
@@ -1629,6 +1653,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
|
||||
}
|
||||
|
||||
console.log('✅ Spielbericht erfolgreich abgesendet:', result);
|
||||
this.submitSucceeded = true;
|
||||
alert('✅ Spielbericht erfolgreich abgesendet!');
|
||||
|
||||
// Dialog schließen
|
||||
@@ -1637,7 +1662,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
|
||||
} catch (error) {
|
||||
console.error('❌ Fehler beim Absenden:', error);
|
||||
console.error('❌ Fehler-Stack:', error.stack);
|
||||
alert(`Fehler beim Absenden des Spielberichts: ${error.message}`);
|
||||
alert(this.getFriendlySubmitErrorMessage(error.message));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1653,7 +1678,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
|
||||
: JSON.parse(JSON.stringify(this.match));
|
||||
|
||||
if (!matchDataOverride) {
|
||||
this.updateMatchData(matchData);
|
||||
this.updateMatchData(matchData, { finalizeReport: false });
|
||||
}
|
||||
|
||||
const uuid = this.meetingData.nuLigaMeetingUuid;
|
||||
@@ -1669,7 +1694,6 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[match-report] validate response', result);
|
||||
|
||||
if (result?.object) {
|
||||
this.meetingDetails = JSON.parse(JSON.stringify(result.object));
|
||||
@@ -1705,9 +1729,10 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
|
||||
}
|
||||
},
|
||||
|
||||
updateMatchData(matchData) {
|
||||
updateMatchData(matchData, options = {}) {
|
||||
try {
|
||||
console.log('🔄 updateMatchData: Verwende kompletten Original-Meeting-Daten...');
|
||||
const { finalizeReport = false } = options;
|
||||
|
||||
// Verwende ausschließlich die Meeting-Details vom /meetingdetails/:uuid Endpoint
|
||||
if (!this.meetingDetails) {
|
||||
@@ -1748,6 +1773,9 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
|
||||
matchData.guestPin = this.finalGuestPin.trim();
|
||||
}
|
||||
|
||||
// Zwischenstände dürfen nie als bereits freigegeben an nuscore zurückgeschickt werden.
|
||||
matchData.isCompleted = finalizeReport;
|
||||
|
||||
// Wenn eine Mannschaft nicht angetreten ist, nur wo-Flag und PIN der angetretenen Mannschaft setzen
|
||||
if (this.teamNotAppeared !== null) {
|
||||
console.log('⚠️ Mannschaft nicht angetreten - nur wo-Flag und PIN der angetretenen Mannschaft werden gesetzt');
|
||||
@@ -1782,8 +1810,8 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
|
||||
// 5. PINs - Original-Hashes beibehalten (werden vom Backend beim Senden aktualisiert)
|
||||
// matchData.homePin und matchData.guestPin bleiben die ursprünglichen Hashes aus baseData
|
||||
|
||||
// 6. Match-Status auf abgeschlossen setzen
|
||||
matchData.isCompleted = true;
|
||||
// 6. Match-Status nur beim finalen Submit auf abgeschlossen setzen
|
||||
matchData.isCompleted = finalizeReport;
|
||||
|
||||
// 7. Gesamtstatistik berechnen und eintragen
|
||||
const overallScore = this.getOverallMatchScore();
|
||||
@@ -2061,6 +2089,11 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
|
||||
}
|
||||
}
|
||||
|
||||
this.initialCompletionState = Boolean(
|
||||
((typeof this.meetingDetails?.isCompleted === 'boolean' && this.meetingDetails.isCompleted) && this.hasReleaseSignatureForCompletion(this.meetingDetails)) ||
|
||||
((typeof this.meetingData?.isCompleted === 'boolean' && this.meetingData.isCompleted) && this.hasReleaseSignatureForCompletion(this.meetingData))
|
||||
);
|
||||
|
||||
// PINs automatisch laden
|
||||
this.loadPinsAutomatically();
|
||||
|
||||
|
||||
669
frontend/src/components/MemberTtrHistoryDialog.vue
Normal file
669
frontend/src/components/MemberTtrHistoryDialog.vue
Normal file
@@ -0,0 +1,669 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
title="TTR-Historie"
|
||||
size="large"
|
||||
:width="1170"
|
||||
max-width="95vw"
|
||||
@close="closeDialog"
|
||||
>
|
||||
<div class="ttr-history-content">
|
||||
<div class="dialog-intro">
|
||||
<div>
|
||||
<div class="member-name">{{ memberName }}</div>
|
||||
</div>
|
||||
<div class="meta-block">
|
||||
<span class="meta-label">Letzter Abruf</span>
|
||||
<span class="meta-value">{{ formatDateTime(lastFetchedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-row">
|
||||
<label class="filter-label" for="ttr-history-range">Zeitraum</label>
|
||||
<select id="ttr-history-range" v-model="selectedRange" class="filter-select">
|
||||
<option v-for="option in rangeOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="state-panel">Historie wird geladen...</div>
|
||||
<div v-else-if="errorMessage && entries.length === 0" class="state-panel state-panel-error">{{ errorMessage }}</div>
|
||||
<div v-else class="content-grid">
|
||||
<div v-if="refreshing" class="state-panel state-panel-info">
|
||||
Historie wird im Hintergrund aktualisiert...
|
||||
</div>
|
||||
<div v-if="errorMessage" class="state-panel state-panel-error state-panel-full">{{ errorMessage }}</div>
|
||||
<div v-if="infoMessage" class="state-panel state-panel-info">{{ infoMessage }}</div>
|
||||
|
||||
<div class="chart-panel">
|
||||
<div class="panel-title">Verlauf</div>
|
||||
<div v-if="chartPoints.length < 2" class="state-panel state-panel-muted">
|
||||
Noch nicht genug Datenpunkte für einen Verlauf.
|
||||
</div>
|
||||
<svg
|
||||
v-else
|
||||
class="history-chart"
|
||||
:viewBox="`0 0 ${chartMeta.width} ${chartMeta.height}`"
|
||||
preserveAspectRatio="none"
|
||||
aria-label="TTR-Verlauf"
|
||||
>
|
||||
<line
|
||||
class="chart-axis"
|
||||
:x1="chartMeta.paddingLeft"
|
||||
:y1="chartMeta.height - chartMeta.paddingBottom"
|
||||
:x2="chartMeta.width - chartMeta.paddingRight"
|
||||
:y2="chartMeta.height - chartMeta.paddingBottom"
|
||||
/>
|
||||
<line
|
||||
class="chart-axis"
|
||||
:x1="chartMeta.paddingLeft"
|
||||
:y1="chartMeta.paddingTop"
|
||||
:x2="chartMeta.paddingLeft"
|
||||
:y2="chartMeta.height - chartMeta.paddingBottom"
|
||||
/>
|
||||
<g v-for="tick in chartYTicks" :key="`y-${tick.value}`">
|
||||
<line
|
||||
class="chart-grid-line"
|
||||
:x1="chartMeta.paddingLeft"
|
||||
:y1="tick.y"
|
||||
:x2="chartMeta.width - chartMeta.paddingRight"
|
||||
:y2="tick.y"
|
||||
/>
|
||||
<text class="chart-y-label" :x="chartMeta.paddingLeft - 10" :y="tick.y + 4">
|
||||
{{ tick.label }}
|
||||
</text>
|
||||
</g>
|
||||
<polyline class="chart-line" :points="chartPolyline" />
|
||||
<circle
|
||||
v-for="point in chartPoints"
|
||||
:key="point.id"
|
||||
class="chart-point"
|
||||
:cx="point.x"
|
||||
:cy="point.y"
|
||||
r="4"
|
||||
/>
|
||||
<g v-for="tick in chartXTicks" :key="`x-${tick.id}`">
|
||||
<line
|
||||
class="chart-tick"
|
||||
:x1="tick.x"
|
||||
:y1="chartMeta.height - chartMeta.paddingBottom"
|
||||
:x2="tick.x"
|
||||
:y2="chartMeta.height - chartMeta.paddingBottom + 6"
|
||||
/>
|
||||
<text
|
||||
class="chart-x-label"
|
||||
:transform="`translate(${tick.x + 6}, ${chartMeta.height - chartMeta.paddingBottom + 12}) rotate(90)`"
|
||||
>
|
||||
{{ tick.label }}
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="table-panel">
|
||||
<div class="panel-title">Historie</div>
|
||||
<div v-if="tableRows.length === 0" class="state-panel state-panel-muted">
|
||||
Noch keine gespeicherte TTR-Historie vorhanden.
|
||||
</div>
|
||||
<table v-else class="history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>TTR</th>
|
||||
<th>Veränderung</th>
|
||||
<th>Quelle</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in tableRows" :key="row.id">
|
||||
<td>{{ formatDate(row.sourceDate) }}</td>
|
||||
<td class="ttr-transition-cell">{{ formatTtrTransition(row.beforeTtr, row.afterTtr) }}</td>
|
||||
<td :class="changeClass(row.change)">
|
||||
{{ formatChange(row.change) }}
|
||||
</td>
|
||||
<td>{{ row.label || row.sourceType || '–' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<button type="button" class="btn-secondary" @click="closeDialog">Schließen</button>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BaseDialog from './BaseDialog.vue';
|
||||
import apiClient from '../apiClient.js';
|
||||
import { getSafeErrorMessage } from '../utils/errorMessages.js';
|
||||
|
||||
export default {
|
||||
name: 'MemberTtrHistoryDialog',
|
||||
components: {
|
||||
BaseDialog
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
member: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
clubId: {
|
||||
type: [String, Number],
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
refreshing: false,
|
||||
errorMessage: '',
|
||||
infoMessage: '',
|
||||
entries: [],
|
||||
lastFetchedAt: null,
|
||||
selectedRange: 'all',
|
||||
playerId: this.member?.myTischtennisHistoryPlayerId || this.member?.myTischtennisPlayerId || null
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
memberName() {
|
||||
return `${this.member?.firstName || ''} ${this.member?.lastName || ''}`.trim() || 'Mitglied';
|
||||
},
|
||||
chartMeta() {
|
||||
return {
|
||||
width: 760,
|
||||
height: 320,
|
||||
paddingTop: 24,
|
||||
paddingRight: 24,
|
||||
paddingBottom: 88,
|
||||
paddingLeft: 62
|
||||
};
|
||||
},
|
||||
rangeOptions() {
|
||||
return [
|
||||
{ value: 'all', label: 'Alle' },
|
||||
{ value: '36m', label: '3 Jahre' },
|
||||
{ value: '24m', label: '2 Jahre' },
|
||||
{ value: '12m', label: '1 Jahr' },
|
||||
{ value: '9m', label: '9 Monate' },
|
||||
{ value: '6m', label: '6 Monate' },
|
||||
{ value: '3m', label: '3 Monate' },
|
||||
{ value: '2m', label: '2 Monate' },
|
||||
{ value: '1m', label: '1 Monat' }
|
||||
];
|
||||
},
|
||||
filteredEntries() {
|
||||
if (this.selectedRange === 'all') {
|
||||
return this.entries;
|
||||
}
|
||||
|
||||
const monthCount = Number.parseInt(this.selectedRange.replace('m', ''), 10);
|
||||
if (!Number.isFinite(monthCount)) {
|
||||
return this.entries;
|
||||
}
|
||||
|
||||
const cutoff = new Date();
|
||||
cutoff.setHours(0, 0, 0, 0);
|
||||
cutoff.setMonth(cutoff.getMonth() - monthCount);
|
||||
|
||||
return this.entries.filter((entry) => {
|
||||
if (!entry?.sourceDate) {
|
||||
return false;
|
||||
}
|
||||
const date = new Date(entry.sourceDate);
|
||||
return !Number.isNaN(date.getTime()) && date >= cutoff;
|
||||
});
|
||||
},
|
||||
tableRows() {
|
||||
return this.filteredEntries.map((entry, index) => {
|
||||
const previousEntry = this.filteredEntries[index + 1] || null;
|
||||
const beforeTtr = Number.isFinite(previousEntry?.ttr) ? previousEntry.ttr : null;
|
||||
const afterTtr = Number.isFinite(entry?.ttr) ? entry.ttr : null;
|
||||
const change = Number.isFinite(beforeTtr) && Number.isFinite(afterTtr)
|
||||
? afterTtr - beforeTtr
|
||||
: null;
|
||||
|
||||
return {
|
||||
...entry,
|
||||
beforeTtr,
|
||||
afterTtr,
|
||||
change
|
||||
};
|
||||
});
|
||||
},
|
||||
chartPoints() {
|
||||
if (this.filteredEntries.length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const chronological = [...this.filteredEntries]
|
||||
.reverse()
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
value: entry.ttr
|
||||
}))
|
||||
.filter((entry) => Number.isFinite(entry.value));
|
||||
|
||||
if (chronological.length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { width, height, paddingTop, paddingRight, paddingBottom, paddingLeft } = this.chartMeta;
|
||||
const values = chronological.map((entry) => entry.value);
|
||||
const minValue = Math.min(...values);
|
||||
const maxValue = Math.max(...values);
|
||||
const valueRange = Math.max(maxValue - minValue, 1);
|
||||
|
||||
return chronological.map((entry, index) => {
|
||||
const ratioX = chronological.length === 1 ? 0.5 : index / (chronological.length - 1);
|
||||
const ratioY = (entry.value - minValue) / valueRange;
|
||||
return {
|
||||
id: entry.id,
|
||||
label: this.formatDate(entry.sourceDate),
|
||||
value: entry.value,
|
||||
x: paddingLeft + ratioX * (width - paddingLeft - paddingRight),
|
||||
y: height - paddingBottom - ratioY * (height - paddingTop - paddingBottom)
|
||||
};
|
||||
});
|
||||
},
|
||||
chartPolyline() {
|
||||
return this.chartPoints.map((point) => `${point.x},${point.y}`).join(' ');
|
||||
},
|
||||
chartXTicks() {
|
||||
if (this.chartPoints.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (this.chartPoints.length <= 12) {
|
||||
return this.chartPoints;
|
||||
}
|
||||
|
||||
const usableWidth = this.chartMeta.width - this.chartMeta.paddingLeft - this.chartMeta.paddingRight;
|
||||
const estimatedLabelSpace = 70;
|
||||
const maxTicks = Math.min(
|
||||
this.chartPoints.length,
|
||||
Math.max(2, Math.floor(usableWidth / estimatedLabelSpace))
|
||||
);
|
||||
|
||||
if (this.chartPoints.length <= maxTicks) {
|
||||
return this.chartPoints;
|
||||
}
|
||||
|
||||
const step = Math.ceil((this.chartPoints.length - 1) / (maxTicks - 1));
|
||||
const ticks = this.chartPoints.filter((_, index) => index % step === 0);
|
||||
const lastPoint = this.chartPoints[this.chartPoints.length - 1];
|
||||
|
||||
if (!ticks.some((tick) => tick.id === lastPoint.id)) {
|
||||
ticks.push(lastPoint);
|
||||
}
|
||||
|
||||
return ticks;
|
||||
},
|
||||
chartYTicks() {
|
||||
if (this.chartPoints.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const values = this.chartPoints.map((point) => point.value);
|
||||
const minValue = Math.min(...values);
|
||||
const maxValue = Math.max(...values);
|
||||
const steps = 4;
|
||||
const range = Math.max(maxValue - minValue, 1);
|
||||
const { height, paddingTop, paddingBottom } = this.chartMeta;
|
||||
const usableHeight = height - paddingTop - paddingBottom;
|
||||
|
||||
return Array.from({ length: steps + 1 }, (_, index) => {
|
||||
const value = minValue + (range * index) / steps;
|
||||
const ratioY = (value - minValue) / range;
|
||||
return {
|
||||
value: `${index}-${Math.round(value)}`,
|
||||
label: Math.round(value),
|
||||
y: height - paddingBottom - ratioY * usableHeight
|
||||
};
|
||||
}).reverse();
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.modelValue) {
|
||||
this.openDialog();
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
modelValue(newValue) {
|
||||
if (newValue) {
|
||||
this.openDialog();
|
||||
}
|
||||
},
|
||||
member: {
|
||||
deep: true,
|
||||
handler(newMember) {
|
||||
this.playerId = newMember?.myTischtennisHistoryPlayerId || newMember?.myTischtennisPlayerId || null;
|
||||
this.selectedRange = 'all';
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
closeDialog() {
|
||||
this.$emit('update:modelValue', false);
|
||||
},
|
||||
async openDialog() {
|
||||
await this.loadHistory();
|
||||
await this.refreshHistory({ silentStart: false });
|
||||
},
|
||||
async loadHistory(options = {}) {
|
||||
const { preserveMessages = false } = options;
|
||||
this.loading = true;
|
||||
if (!preserveMessages) {
|
||||
this.errorMessage = '';
|
||||
this.infoMessage = '';
|
||||
}
|
||||
try {
|
||||
const response = await apiClient.get(`/clubmembers/ttr-history/${this.clubId}/${this.member.id}`);
|
||||
this.entries = Array.isArray(response.data?.history) ? response.data.history : [];
|
||||
this.lastFetchedAt = response.data?.meta?.lastFetchedAt || null;
|
||||
this.playerId = response.data?.member?.myTischtennisHistoryPlayerId || response.data?.member?.myTischtennisPlayerId || this.playerId;
|
||||
} catch (error) {
|
||||
this.errorMessage = getSafeErrorMessage(error, 'TTR-Historie konnte nicht geladen werden.');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
async refreshHistory(options = {}) {
|
||||
const { silentStart = false } = options;
|
||||
this.refreshing = true;
|
||||
if (!silentStart) {
|
||||
this.errorMessage = '';
|
||||
this.infoMessage = '';
|
||||
}
|
||||
try {
|
||||
const response = await apiClient.post(`/clubmembers/ttr-history/${this.clubId}/${this.member.id}/refresh`);
|
||||
if (response.data?.error) {
|
||||
this.errorMessage = response.data.error;
|
||||
return;
|
||||
}
|
||||
if (response.data?.skipped && response.data?.message) {
|
||||
this.infoMessage = response.data.message;
|
||||
this.lastFetchedAt = response.data?.meta?.lastFetchedAt || this.lastFetchedAt;
|
||||
return;
|
||||
}
|
||||
await this.loadHistory({ preserveMessages: true });
|
||||
if (response.data?.message) {
|
||||
this.infoMessage = response.data.message;
|
||||
}
|
||||
} catch (error) {
|
||||
this.errorMessage = getSafeErrorMessage(error, 'TTR-Historie konnte nicht aktualisiert werden.');
|
||||
} finally {
|
||||
this.refreshing = false;
|
||||
}
|
||||
},
|
||||
formatDate(value) {
|
||||
if (!value) {
|
||||
return '–';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return date.toLocaleDateString('de-DE');
|
||||
},
|
||||
formatDateTime(value) {
|
||||
if (!value) {
|
||||
return 'Noch nie';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return date.toLocaleString('de-DE');
|
||||
},
|
||||
formatTtrTransition(before, after) {
|
||||
if (!Number.isFinite(after)) {
|
||||
return '–';
|
||||
}
|
||||
if (!Number.isFinite(before)) {
|
||||
return `– -> ${after}`;
|
||||
}
|
||||
return `${before} -> ${after}`;
|
||||
},
|
||||
formatChange(value) {
|
||||
if (!Number.isFinite(value) || value === 0) {
|
||||
return '±0';
|
||||
}
|
||||
return value > 0 ? `+${value}` : `${value}`;
|
||||
},
|
||||
changeClass(value) {
|
||||
if (!Number.isFinite(value) || value === 0) {
|
||||
return 'change-neutral';
|
||||
}
|
||||
return value > 0 ? 'change-positive' : 'change-negative';
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ttr-history-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.dialog-intro {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #203040;
|
||||
}
|
||||
|
||||
.meta-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #7b8794;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
color: #203040;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
min-width: 150px;
|
||||
padding: 0.55rem 0.85rem;
|
||||
border: 1px solid #d6dde6;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #203040;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1fr;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.chart-panel,
|
||||
.table-panel {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-weight: 700;
|
||||
color: #1f2d3d;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.state-panel {
|
||||
padding: 18px;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
color: #415164;
|
||||
}
|
||||
|
||||
.state-panel-error {
|
||||
background: #fff1f0;
|
||||
color: #8b1e1e;
|
||||
}
|
||||
|
||||
.state-panel-info {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.state-panel-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.state-panel-muted {
|
||||
background: #f4f7fb;
|
||||
color: #66788a;
|
||||
}
|
||||
|
||||
.history-chart {
|
||||
width: 100%;
|
||||
height: 320px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f7fafc 100%);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.chart-axis {
|
||||
stroke: #94a3b8;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.chart-grid-line {
|
||||
stroke: #e2e8f0;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.chart-line {
|
||||
fill: none;
|
||||
stroke: #1f6feb;
|
||||
stroke-width: 3;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.chart-point {
|
||||
fill: #0d4f9a;
|
||||
}
|
||||
|
||||
.chart-tick {
|
||||
stroke: #94a3b8;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.chart-x-label,
|
||||
.chart-y-label {
|
||||
fill: #475569;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.chart-y-label {
|
||||
text-anchor: end;
|
||||
}
|
||||
|
||||
.history-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.history-table th,
|
||||
.history-table td {
|
||||
text-align: left;
|
||||
padding: 10px 8px;
|
||||
border-bottom: 1px solid #e5ebf3;
|
||||
}
|
||||
|
||||
.history-table thead th {
|
||||
color: #fff;
|
||||
border-bottom: 2px solid #ddd;
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.ttr-transition-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.change-positive {
|
||||
color: #1f7a1f;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.change-negative {
|
||||
color: #c0392b;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.change-neutral {
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
padding: 10px 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #eef2f7;
|
||||
color: #203040;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.dialog-intro {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -326,12 +326,20 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="rating-cell">
|
||||
<span v-if="member.ttr || member.qttr">
|
||||
<span v-if="member.ttr" class="ttr-value">{{ member.ttr }}</span>
|
||||
<span v-if="member.ttr && member.qttr" class="rating-separator">/</span>
|
||||
<span v-if="member.qttr" class="qttr-value">{{ member.qttr }}</span>
|
||||
</span>
|
||||
<span v-else class="no-rating">-</span>
|
||||
<button
|
||||
type="button"
|
||||
class="rating-button"
|
||||
:disabled="!member.myTischtennisPlayerId && !member.ttr && !member.qttr"
|
||||
:title="member.myTischtennisHistoryPlayerId || member.myTischtennisPlayerId || member.ttr || member.qttr ? 'TTR-Historie anzeigen' : 'Keine myTischtennis-ID hinterlegt'"
|
||||
@click.stop="openTtrHistoryDialog(member)"
|
||||
>
|
||||
<span v-if="member.ttr || member.qttr">
|
||||
<span v-if="member.ttr" class="ttr-value">{{ member.ttr }}</span>
|
||||
<span v-if="member.ttr && member.qttr" class="rating-separator">/</span>
|
||||
<span v-if="member.qttr" class="qttr-value">{{ member.qttr }}</span>
|
||||
</span>
|
||||
<span v-else class="no-rating">-</span>
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<div class="member-contact-cell">
|
||||
@@ -467,6 +475,14 @@
|
||||
@success="handleTransferSuccess"
|
||||
@error="handleTransferError"
|
||||
/>
|
||||
|
||||
<MemberTtrHistoryDialog
|
||||
v-if="showMemberTtrHistoryDialog && selectedMemberForTtrHistory"
|
||||
v-model="showMemberTtrHistoryDialog"
|
||||
:member="selectedMemberForTtrHistory"
|
||||
:club-id="currentClub"
|
||||
@close="closeTtrHistoryDialog"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -483,6 +499,7 @@ import BaseDialog from '../components/BaseDialog.vue';
|
||||
import MemberNotesDialog from '../components/MemberNotesDialog.vue';
|
||||
import MemberActivitiesDialog from '../components/MemberActivitiesDialog.vue';
|
||||
import MemberTransferDialog from '../components/MemberTransferDialog.vue';
|
||||
import MemberTtrHistoryDialog from '../components/MemberTtrHistoryDialog.vue';
|
||||
import MembersOverviewSection from '../components/members/MembersOverviewSection.vue';
|
||||
import { debounce } from '../utils/debounce.js';
|
||||
import { buildInfoConfig, buildConfirmConfig, safeErrorMessage, sanitizeText } from '../utils/dialogUtils.js';
|
||||
@@ -496,6 +513,7 @@ export default {
|
||||
MemberNotesDialog,
|
||||
MemberActivitiesDialog,
|
||||
MemberTransferDialog,
|
||||
MemberTtrHistoryDialog,
|
||||
MembersOverviewSection
|
||||
},
|
||||
computed: {
|
||||
@@ -750,7 +768,9 @@ export default {
|
||||
isUpdatingRatings: false,
|
||||
showMemberInfo: false,
|
||||
showActivitiesModal: false,
|
||||
showMemberTtrHistoryDialog: false,
|
||||
selectedMemberForActivities: null,
|
||||
selectedMemberForTtrHistory: null,
|
||||
memberTrainingGroups: [],
|
||||
trainingGroups: [],
|
||||
selectedGroupToAdd: '',
|
||||
@@ -2072,6 +2092,17 @@ export default {
|
||||
this.editMember(member);
|
||||
}
|
||||
},
|
||||
openTtrHistoryDialog(member) {
|
||||
if (!member) {
|
||||
return;
|
||||
}
|
||||
this.selectedMemberForTtrHistory = member;
|
||||
this.showMemberTtrHistoryDialog = true;
|
||||
},
|
||||
closeTtrHistoryDialog() {
|
||||
this.showMemberTtrHistoryDialog = false;
|
||||
this.selectedMemberForTtrHistory = null;
|
||||
},
|
||||
async updateRatingsFromMyTischtennis() {
|
||||
this.isUpdatingRatings = true;
|
||||
try {
|
||||
@@ -2364,6 +2395,25 @@ table td {
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.rating-button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.rating-button:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.rating-button:not(:disabled):hover .ttr-value,
|
||||
.rating-button:not(:disabled):hover .qttr-value,
|
||||
.rating-button:not(:disabled):hover .no-rating {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ttr-value {
|
||||
font-weight: 600;
|
||||
color: #1a73e8;
|
||||
|
||||
@@ -319,7 +319,6 @@ import apiClient from '../apiClient.js';
|
||||
import PDFGenerator from '../components/PDFGenerator.js';
|
||||
import { getSafeErrorMessage } from '../utils/errorMessages.js';
|
||||
import SeasonSelector from '../components/SeasonSelector.vue';
|
||||
import MatchReportApiDialog from '../components/MatchReportApiDialog.vue';
|
||||
|
||||
import InfoDialog from '../components/InfoDialog.vue';
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue';
|
||||
@@ -338,7 +337,6 @@ export default {
|
||||
name: 'ScheduleView',
|
||||
components: {
|
||||
SeasonSelector,
|
||||
MatchReportApiDialog,
|
||||
InfoDialog,
|
||||
ConfirmDialog,
|
||||
BaseDialog,
|
||||
|
||||
Reference in New Issue
Block a user