feat(member-notes): enhance member note functionality with structured data and observation tracking
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 58s

This commit is contained in:
Torsten Schulz (local)
2026-08-14 13:39:00 +02:00
parent f59c7391b8
commit 313790428d
7 changed files with 262 additions and 16 deletions

View File

@@ -33,15 +33,26 @@
</div>
<div class="form-group">
<label>{{ $t('memberNotes.newNote') }}</label>
<div v-if="structuredNotes" class="note-kind-picker" aria-label="Art der Notiz">
<button v-for="option in noteKinds" :key="option.value" type="button" :class="['note-kind-chip', `is-${option.value}`, { active: localNoteKind === option.value }]" @click="localNoteKind = option.value">{{ option.label }}</button>
</div>
<textarea v-model="localNoteContent" :placeholder="$t('memberNotes.newNote')" rows="4" class="note-textarea"></textarea>
<label v-if="structuredNotes" class="training-focus-label">Trainingsziel / nächster Fokus
<input v-model.trim="localTrainingFocus" type="text" placeholder="z. B. Rückschlag kurz ablegen" class="training-focus-input" />
</label>
<button @click="handleAddNote" class="btn-primary">{{ $t('memberNotes.add') }}</button>
</div>
<div class="notes-list">
<h4>{{ $t('memberNotes.notes') }}</h4>
<ul>
<li v-for="note in notes" :key="note.id" class="note-item">
<li v-for="note in notes" :key="note.id" :class="['note-item', { 'observation-note': isObservation(note) }]">
<button @click="$emit('delete-note', note.id)" class="trash-btn">🗑</button>
<span class="note-content">{{ note.content }}</span>
<div class="note-copy">
<span v-if="isObservation(note)" :class="['note-kind-badge', `is-${note.kind}`]">{{ kindLabel(note.kind) }}</span>
<span class="note-content">{{ note.content }}</span>
<span v-if="note.trainingFocus" class="note-training-focus"><strong>Trainingsziel:</strong> {{ note.trainingFocus }}</span>
<span v-if="note.sourceLabel" class="note-source">{{ sourcePrefix(note.sourceType) }}{{ note.sourceLabel }}</span>
</div>
</li>
</ul>
</div>
@@ -85,13 +96,23 @@ export default {
noteContent: {
type: String,
default: ''
},
structuredNotes: {
type: Boolean,
default: false
},
sourceDefaults: {
type: Object,
default: () => ({})
}
},
emits: ['update:modelValue', 'close', 'add-note', 'delete-note', 'add-tag', 'remove-tag', 'update:noteContent', 'update:selectedTags'],
data() {
return {
localNoteContent: this.noteContent,
localSelectedTags: this.selectedTags
localSelectedTags: this.selectedTags,
localNoteKind: 'general',
localTrainingFocus: ''
};
},
watch: {
@@ -108,14 +129,40 @@ export default {
this.$emit('update:selectedTags', newVal);
}
},
computed: {
noteKinds() {
return [
{ value: 'general', label: 'Allgemeine Notiz' },
{ value: 'strength', label: 'Stärke' },
{ value: 'development', label: 'Entwicklungsfeld' },
{ value: 'training_focus', label: 'Trainingsfokus' }
];
}
},
methods: {
handleClose() {
this.$emit('update:modelValue', false);
this.$emit('close');
},
handleAddNote() {
this.$emit('add-note', this.localNoteContent);
const content = this.localNoteContent.trim();
if (!content) return;
const payload = this.structuredNotes
? { content, kind: this.localNoteKind, trainingFocus: this.localTrainingFocus.trim() || null, ...this.sourceDefaults }
: content;
this.$emit('add-note', payload);
this.localNoteContent = '';
this.localTrainingFocus = '';
this.localNoteKind = 'general';
},
isObservation(note) {
return note && note.kind && note.kind !== 'general';
},
kindLabel(kind) {
return ({ strength: 'Stärke', development: 'Entwicklungsfeld', training_focus: 'Trainingsfokus' })[kind] || 'Allgemeine Notiz';
},
sourcePrefix(type) {
return type === 'tournament' ? 'Turnier · ' : type === 'match' ? 'Mannschaftsspiel · ' : '';
}
}
};
@@ -183,6 +230,16 @@ export default {
color: var(--text-color);
}
.note-kind-picker { display:flex; flex-wrap:wrap; gap:.4rem; }
.note-kind-chip,.note-kind-badge { border:1px solid var(--border-color); background:var(--surface-muted); color:var(--text-color); font:inherit; font-size:.8rem; font-weight:600; padding:.28rem .55rem; border-radius:7px; }
.note-kind-chip { cursor:pointer; }
.note-kind-chip.active { outline:2px solid currentColor; outline-offset:1px; }
.is-strength { color:#17764a; background:#edf8f1; border-color:#9bd5b5; }
.is-development { color:#a85d14; background:#fff4e8; border-color:#edbd7e; }
.is-training_focus { color:#2563a7; background:#edf5ff; border-color:#a8c9ec; }
.training-focus-label { display:flex; flex-direction:column; gap:.35rem; font-size:.9rem; }
.training-focus-input { width:100%; box-sizing:border-box; padding:.6rem .75rem; border:1px solid var(--border-color); border-radius:8px; font:inherit; background:var(--surface-muted); color:var(--text-color); }
.notes-list h4 {
margin: 0 0 0.5rem 0;
font-size: 1rem;
@@ -196,7 +253,7 @@ export default {
.note-item {
display: flex;
align-items: center;
align-items: flex-start;
gap: 0.5rem;
padding: 0.5rem;
margin-bottom: 0.5rem;
@@ -205,6 +262,11 @@ export default {
border-radius: 10px;
}
.observation-note { border-left:4px solid var(--primary-color); }
.note-copy { flex:1; display:flex; flex-direction:column; align-items:flex-start; gap:.3rem; min-width:0; }
.note-training-focus { color:var(--text-muted); font-size:.88rem; }
.note-source { color:var(--text-muted); font-size:.78rem; }
.note-content {
flex: 1;
}
@@ -249,5 +311,7 @@ export default {
height: auto;
max-height: 300px;
}
.note-kind-picker { gap:.3rem; }
.note-kind-chip { flex:1 1 130px; }
}
</style>

View File

@@ -673,6 +673,7 @@
v-model="showNotesModal"
:member="memberToEdit"
:notes="notes"
:structured-notes="true"
v-model:note-content="newNoteContent"
:selected-tags="[]"
:available-tags="[]"
@@ -2486,10 +2487,15 @@ export default {
this.notes = response.data;
this.showNotesModal = true;
},
async addNote() {
async addNote(note = null) {
const payload = typeof note === 'object' && note !== null ? note : { content: this.newNoteContent };
const response = await apiClient.post('/membernotes', {
memberId: this.selectedMember.id,
content: this.newNoteContent,
content: payload.content,
kind: payload.kind,
trainingFocus: payload.trainingFocus,
sourceType: payload.sourceType,
sourceLabel: payload.sourceLabel,
clubId: this.currentClub
});
this.notes = response.data;
@@ -2501,9 +2507,9 @@ export default {
});
this.notes = response.data;
},
openNotesModal(member) {
async openNotesModal(member) {
this.memberToEdit = member;
this.showNotesModal = true;
await this.loadNotes(member);
},
closeNotesModal() {
this.showNotesModal = false;

View File

@@ -508,6 +508,10 @@
<label class="opponent-note">Stärken Gegner/in<textarea v-model="tacticPlan.opponentStrengths" placeholder="Gefährliche Aufschläge, Muster, Lieblingsseiten …"></textarea></label>
<label class="opponent-note">Schwächen Gegner/in<textarea v-model="tacticPlan.opponentWeaknesses" placeholder="Wo lässt sich Druck erzeugen?"></textarea></label>
</div>
<div class="tactic-observation-action">
<div><strong>Spielerbeobachtung</strong><span>Wird in den gemeinsamen Mitgliedsnotizen für das gezielte Training gespeichert.</span><span v-if="!tacticPlan.memberId" id="tournament-observation-hint" class="tactic-observation-hint">Bitte zuerst eine eigene Spielerin bzw. einen eigenen Spieler auswählen.</span></div>
<button type="button" class="btn-secondary" :disabled="!tacticPlan.memberId" :aria-describedby="!tacticPlan.memberId ? 'tournament-observation-hint' : null" :title="!tacticPlan.memberId ? 'Bitte zuerst eine eigene Spielerin bzw. einen eigenen Spieler auswählen.' : 'Beobachtung in Mitgliedsnotizen festhalten'" @click="openTournamentObservation">Beobachtung festhalten</button>
</div>
<TacticBoard :key="tacticPlan.id || 'new-tactic-plan'" v-model="tacticPlan.drawingData" />
<div class="editor-actions"><span v-if="tacticPlanDirty" class="dirty-note">Ungespeicherte Änderungen</span><button class="btn-primary" :disabled="tacticSaving" @click="saveTacticPlan">{{ tacticSaving ? 'Speichert ' : 'Speichern' }}</button></div>
</section>
@@ -556,6 +560,18 @@
@confirm="handleConfirmResult(true)"
@cancel="handleConfirmResult(false)"
/>
<MemberNotesDialog
v-model="tournamentObservationDialog.isOpen"
:member="tournamentObservationDialog.member"
:notes="tournamentObservationDialog.notes"
:structured-notes="true"
:source-defaults="tournamentObservationSource"
:selected-tags="[]"
:available-tags="[]"
@add-note="addTournamentObservation"
@delete-note="deleteTournamentObservation"
@close="closeTournamentObservation"
/>
</div>
</template>
@@ -571,6 +587,7 @@ import PDFGenerator from '../components/PDFGenerator.js';
import BaseDialog from '../components/BaseDialog.vue';
import MemberSelectionDialog from '../components/MemberSelectionDialog.vue';
import TacticBoard from '../components/tournament/TacticBoard.vue';
import MemberNotesDialog from '../components/MemberNotesDialog.vue';
export default {
name: 'OfficialTournaments',
components: {
@@ -578,7 +595,8 @@ export default {
ConfirmDialog,
BaseDialog,
MemberSelectionDialog,
TacticBoard
TacticBoard,
MemberNotesDialog
},
data() {
return {
@@ -624,6 +642,7 @@ export default {
autoRegistering: false,
suggestions: [], fetchingSuggestions: false, suggestionError: '',
tacticPlans: [], tacticPlan: null, tacticPlansSnapshot: '', tacticsLoading: false, tacticSaving: false, tacticsError: '', tacticTournamentId: null, tacticLoadToken: 0, reloadToken: 0,
tournamentObservationDialog: { isOpen: false, member: null, notes: [] },
};
},
computed: {
@@ -1015,6 +1034,12 @@ export default {
},
tacticPlanDirty() {
return !!this.tacticPlan && JSON.stringify(this.tacticPlan) !== this.tacticPlansSnapshot;
},
tournamentObservationSource() {
if (!this.tacticPlan) return {};
const tournament = (this.list || []).find((item) => String(item.id) === String(this.uploadedId));
const competition = this.tacticEligibleOptions.find((option) => option.value === this.tacticPlan.selection)?.label?.split(' ')[1] || 'Konkurrenz';
return { sourceType: 'tournament', sourceLabel: [tournament?.title || 'Öffentliches Turnier', competition, this.tacticPlan.round, this.tacticPlan.opponentName ? `gegen ${this.tacticPlan.opponentName}` : ''].filter(Boolean).join(' · ') };
}
},
methods: {
@@ -1456,6 +1481,32 @@ export default {
},
rememberTacticSnapshot() { this.tacticPlansSnapshot = JSON.stringify(this.tacticPlan); },
async openTactics() { this.activeTab = 'tactics'; await this.loadTacticPlans(); },
async openTournamentObservation() {
const member = (this.members || []).find((item) => String(item.id) === String(this.tacticPlan?.memberId));
if (!member) { this.tacticsError = 'Bitte zuerst eine eigene Spielerin bzw. einen eigenen Spieler auswählen.'; return; }
this.tournamentObservationDialog.member = member;
this.tournamentObservationDialog.notes = [];
this.tournamentObservationDialog.isOpen = true;
try {
const response = await apiClient.get(`/membernotes/${member.id}`, { params: { clubId: this.currentClub } });
if (String(this.tournamentObservationDialog.member?.id) === String(member.id)) this.tournamentObservationDialog.notes = response.data || [];
} catch (error) { this.tacticsError = getSafeErrorMessage(error, 'Mitgliedsnotizen konnten nicht geladen werden.'); }
},
async addTournamentObservation(note) {
const member = this.tournamentObservationDialog.member;
if (!member || !note?.content) return;
try {
const response = await apiClient.post('/membernotes', { memberId: member.id, clubId: this.currentClub, ...note });
this.tournamentObservationDialog.notes = response.data || [];
} catch (error) { this.tacticsError = getSafeErrorMessage(error, 'Beobachtung konnte nicht gespeichert werden.'); }
},
async deleteTournamentObservation(noteId) {
try {
const response = await apiClient.delete(`/membernotes/${noteId}`, { data: { clubId: this.currentClub } });
this.tournamentObservationDialog.notes = response.data || [];
} catch (error) { this.tacticsError = getSafeErrorMessage(error, 'Notiz konnte nicht gelöscht werden.'); }
},
closeTournamentObservation() { this.tournamentObservationDialog = { isOpen: false, member: null, notes: [] }; },
async loadTacticPlans(requestedTournamentId = String(this.uploadedId || '')) {
if (!requestedTournamentId) return;
if (this.tacticTournamentId !== requestedTournamentId) this.resetTacticsForTournament(requestedTournamentId);
@@ -2052,6 +2103,9 @@ export default {
<style scoped>
.official-tournaments { display: flex; flex-direction: column; gap: 0.75rem; }
.tactic-observation-action { display:flex; justify-content:space-between; align-items:center; gap:.8rem; margin:-.1rem 0 .85rem; padding:.65rem .75rem; border:1px solid #cbd9e8; background:#f7fafc; }
.tactic-observation-action strong,.tactic-observation-action span { display:block; }.tactic-observation-action span { margin-top:.15rem; color:#64748b; font-size:.82rem; }
.tactic-observation-action .tactic-observation-hint { color:#9a6511; font-weight:600; }
.tactics-header { display:flex; align-items:flex-start; justify-content:space-between; gap:1rem; margin:.4rem 0 1rem; }
.tactics-header h3,.editor-heading h4 { margin:0; color:#17366d; }.tactics-header p { margin:.3rem 0 0; color:#64748b; max-width:720px; }
.tactics-workspace { display:grid; grid-template-columns:minmax(205px, .38fr) minmax(0, 1fr); border:1px solid #cbd9e8; background:#fff; min-height:420px; }.tactic-list { padding:.55rem; background:#eef3f8; border-right:1px solid #cbd9e8; display:flex; flex-direction:column; gap:.35rem; }.tactic-list-item { appearance:none; text-align:left; background:#fff; border:1px solid #d5dfe9; padding:.6rem; cursor:pointer; color:#203247; }.tactic-list-item strong,.tactic-list-item span { display:block; }.tactic-list-item span { color:#667789; font-size:.82rem; margin-top:.2rem; }.tactic-list-item.active { border-left:4px solid #d79727; background:#fff9ed; }.tactic-editor { padding:1rem; }.editor-heading,.editor-actions { display:flex; justify-content:space-between; align-items:center; gap:.75rem; margin-bottom:.8rem; }.tactic-form-grid,.tactic-notes-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.75rem; margin-bottom:.85rem; }.tactic-form-grid label,.tactic-notes-grid label { color:#26394a; font-weight:600; font-size:.88rem; }.tactic-form-grid input,.tactic-form-grid select,.tactic-notes-grid textarea { display:block; box-sizing:border-box; width:100%; margin-top:.28rem; border:1px solid #b9c8d7; padding:.52rem; font:inherit; background:#fff; }.tactic-notes-grid textarea { min-height:82px; resize:vertical; }.own-note textarea { border-left:4px solid #d79727; }.opponent-note textarea { border-left:4px solid #dc7865; }.tactics-error { color:#a43d30; background:#fff0eb; border-left:3px solid #dc7865; padding:.6rem; }.dirty-note { color:#9a6511; font-size:.88rem; }.danger-button { color:#9b3326; }.tactic-list-empty { color:#65758a; font-size:.88rem; padding:.3rem; }.tactic-empty { display:flex; flex-direction:column; gap:.35rem; text-align:left; }

View File

@@ -338,7 +338,8 @@
<p><strong>{{ $t('schedule.time') }}:</strong> {{ playerSelectionDialog.match?.time ? playerSelectionDialog.match.time.toString().slice(0, 5) + ' ' + $t('common.time') : 'N/A' }}</p>
</div>
<div class="player-list">
<div class="player-list" tabindex="0" aria-label="Spielerliste; auf kleinen Bildschirmen horizontal scrollbar">
<p class="player-list-scroll-hint">Für weitere Spalten seitlich wischen.</p>
<table class="player-selection-table">
<thead>
<tr>
@@ -346,6 +347,7 @@
<th>{{ $t('schedule.ready') }}</th>
<th>{{ $t('schedule.planned') }}</th>
<th>{{ $t('schedule.played') }}</th>
<th>Beobachtung</th>
</tr>
</thead>
<tbody>
@@ -377,6 +379,7 @@
@change="togglePlayerPlayed(member)"
/>
</td>
<td class="observation-cell"><button type="button" class="btn-observation" @click="openMatchObservation(member)">Beobachtung</button></td>
</tr>
</tbody>
</table>
@@ -393,6 +396,19 @@
</div>
</BaseDialog>
<MemberNotesDialog
v-model="matchObservationDialog.isOpen"
:member="matchObservationDialog.member"
:notes="matchObservationDialog.notes"
:structured-notes="true"
:source-defaults="matchObservationSource"
:selected-tags="[]"
:available-tags="[]"
@add-note="addMatchObservation"
@delete-note="deleteMatchObservation"
@close="closeMatchObservation"
/>
<BaseDialog
v-model="friendlyResultDialog.isOpen"
:title="`${friendlyResultReadonly ? 'Ergebnis' : 'Ergebniseingabe'} - ${friendlyResultDialog.match?.homeTeam?.name || ''} vs ${friendlyResultDialog.match?.guestTeam?.name || ''}`"
@@ -662,6 +678,7 @@ import BaseDialog from '../components/BaseDialog.vue';
import CsvImportDialog from '../components/CsvImportDialog.vue';
import ScheduleLayoutShell from '../components/schedule/ScheduleLayoutShell.vue';
import FriendlyParticipantsColumn from '../components/schedule/FriendlyParticipantsColumn.vue';
import MemberNotesDialog from '../components/MemberNotesDialog.vue';
import {
connectSocket,
disconnectSocket,
@@ -695,7 +712,8 @@ export default {
BaseDialog,
CsvImportDialog,
ScheduleLayoutShell,
FriendlyParticipantsColumn
FriendlyParticipantsColumn,
MemberNotesDialog
},
computed: {
...mapGetters(['isAuthenticated', 'currentClub', 'clubs', 'currentClubName']),
@@ -784,6 +802,12 @@ export default {
friendlyMatchesLabel() {
return 'Freundschaftsspiele';
},
matchObservationSource() {
const match = this.playerSelectionDialog.match;
if (!match) return {};
const matchup = `${match.homeTeam?.name || 'Heim'} ${match.guestTeam?.name || 'Gast'}`;
return { sourceType: 'match', sourceLabel: [matchup, match.date ? this.formatDate(match.date) : ''].filter(Boolean).join(' · ') };
},
friendlyInvitationTargetClubs() {
return (this.clubs || []).filter((club) => Number(club.id) !== Number(this.currentClub));
},
@@ -862,6 +886,7 @@ export default {
loading: false,
readonly: false
},
matchObservationDialog: { isOpen: false, member: null, notes: [] },
locationDialog: {
isOpen: false,
match: null
@@ -1342,6 +1367,41 @@ export default {
this.playerSelectionDialog.match = null;
this.playerSelectionDialog.members = [];
},
async openMatchObservation(member) {
if (!member?.id) return;
this.matchObservationDialog.member = member;
this.matchObservationDialog.notes = [];
this.matchObservationDialog.isOpen = true;
try {
const response = await apiClient.get(`/membernotes/${member.id}`, { params: { clubId: this.currentClub } });
if (this.matchObservationDialog.member?.id === member.id) this.matchObservationDialog.notes = response.data || [];
} catch (error) {
await this.showInfo(this.$t('messages.error'), 'Mitgliedsnotizen konnten nicht geladen werden.', getSafeErrorMessage(error), 'error');
}
},
async addMatchObservation(note) {
const member = this.matchObservationDialog.member;
if (!member || !note?.content) return;
try {
const response = await apiClient.post('/membernotes', { memberId: member.id, clubId: this.currentClub, ...note });
this.matchObservationDialog.notes = response.data || [];
} catch (error) {
await this.showInfo(this.$t('messages.error'), 'Beobachtung konnte nicht gespeichert werden.', getSafeErrorMessage(error), 'error');
}
},
async deleteMatchObservation(noteId) {
try {
const response = await apiClient.delete(`/membernotes/${noteId}`, { data: { clubId: this.currentClub } });
this.matchObservationDialog.notes = response.data || [];
} catch (error) {
await this.showInfo(this.$t('messages.error'), 'Notiz konnte nicht gelöscht werden.', getSafeErrorMessage(error), 'error');
}
},
closeMatchObservation() {
this.matchObservationDialog.isOpen = false;
this.matchObservationDialog.member = null;
this.matchObservationDialog.notes = [];
},
openLocationDialog(match) {
if (!match?.location) {
return;
@@ -3629,6 +3689,8 @@ li {
.player-selection-table tbody tr:hover {
background-color: #f8f9fa;
}
.btn-observation { border:1px solid var(--border-color); background:var(--surface-muted); color:var(--primary-color); border-radius:7px; padding:.35rem .55rem; font:inherit; font-size:.8rem; font-weight:600; cursor:pointer; }
.btn-observation:hover { background:var(--primary-color); color:var(--text-on-primary); }
.player-name {
font-weight: 500;
@@ -3637,7 +3699,12 @@ li {
.player-list {
overflow-x: auto;
max-width: 100%;
overscroll-behavior-inline: contain;
-webkit-overflow-scrolling: touch;
}
.player-list:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; }
.player-list-scroll-hint { display:none; }
/* Gallery Styles */
.gallery-dialog-content {
@@ -4113,6 +4180,8 @@ li {
}
@media (max-width: 640px) {
.player-list { border: 1px solid var(--border-color); border-radius: 8px; }
.player-list-scroll-hint { display:block; position:sticky; left:0; margin:0; padding:7px 10px; background:var(--surface-muted); color:var(--text-muted); font-size:.78rem; }
.player-selection-table {
min-width: 520px;
}