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:
Torsten Schulz (local)
2026-03-18 15:34:10 +01:00
parent 79adad9564
commit 563a7e8dde
16 changed files with 1471 additions and 108 deletions

View File

@@ -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 */

View File

@@ -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();

View 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>

View File

@@ -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;

View File

@@ -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,