diff --git a/backend/controllers/memberNoteController.js b/backend/controllers/memberNoteController.js
index 78c3467c..f415855f 100755
--- a/backend/controllers/memberNoteController.js
+++ b/backend/controllers/memberNoteController.js
@@ -17,8 +17,8 @@ const getMemberNotes = async (req, res) => {
const addMemberNote = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
- const { memberId, content, clubId } = req.body;
- await MemberNoteService.addNoteToMember(userToken, clubId, memberId, content);
+ const { memberId, content, clubId, kind, trainingFocus, sourceType, sourceLabel } = req.body;
+ await MemberNoteService.addNoteToMember(userToken, clubId, memberId, content, { kind, trainingFocus, sourceType, sourceLabel });
const notes = await MemberNoteService.getNotesForMember(userToken, clubId, memberId);
res.status(201).json(notes);
} catch (error) {
diff --git a/backend/models/MemberNote.js b/backend/models/MemberNote.js
index a8f0374e..265802a3 100755
--- a/backend/models/MemberNote.js
+++ b/backend/models/MemberNote.js
@@ -22,6 +22,40 @@ const MemberNote = sequelize.define('MemberNote', {
return decryptData(encryptedValue);
}
},
+ kind: {
+ type: DataTypes.ENUM('general', 'strength', 'development', 'training_focus'),
+ allowNull: false,
+ defaultValue: 'general',
+ },
+ trainingFocus: {
+ type: DataTypes.TEXT,
+ allowNull: true,
+ field: 'training_focus',
+ set(value) {
+ this.setDataValue('trainingFocus', value ? encryptData(value) : null);
+ },
+ get() {
+ const encryptedValue = this.getDataValue('trainingFocus');
+ return encryptedValue ? decryptData(encryptedValue) : null;
+ }
+ },
+ sourceType: {
+ type: DataTypes.STRING,
+ allowNull: true,
+ field: 'source_type',
+ },
+ sourceLabel: {
+ type: DataTypes.TEXT,
+ allowNull: true,
+ field: 'source_label',
+ set(value) {
+ this.setDataValue('sourceLabel', value ? encryptData(value) : null);
+ },
+ get() {
+ const encryptedValue = this.getDataValue('sourceLabel');
+ return encryptedValue ? decryptData(encryptedValue) : null;
+ }
+ },
memberId: {
type: DataTypes.INTEGER,
allowNull: false,
diff --git a/backend/services/memberNoteService.js b/backend/services/memberNoteService.js
index e9b4d5bc..c0dc41c2 100755
--- a/backend/services/memberNoteService.js
+++ b/backend/services/memberNoteService.js
@@ -1,16 +1,34 @@
import MemberNote from '../models/MemberNote.js';
+import Member from '../models/Member.js';
import { checkAccess } from '../utils/userUtils.js';
import { devLog } from '../utils/logger.js';
class MemberNoteService {
- async addNoteToMember(userToken, clubId, memberId, content) {
+ async ensureMemberInClub(clubId, memberId) {
+ const member = await Member.findOne({ where: { id: memberId, clubId } });
+ if (!member) throw new Error('Member not found in club');
+ return member;
+ }
+
+ async addNoteToMember(userToken, clubId, memberId, content, metadata = {}) {
await checkAccess(userToken, clubId);
- return await MemberNote.create({ memberId, content });
+ await this.ensureMemberInClub(clubId, memberId);
+ const allowedKinds = new Set(['general', 'strength', 'development', 'training_focus']);
+ const kind = allowedKinds.has(metadata.kind) ? metadata.kind : 'general';
+ return await MemberNote.create({
+ memberId,
+ content,
+ kind,
+ trainingFocus: metadata.trainingFocus || null,
+ sourceType: metadata.sourceType || null,
+ sourceLabel: metadata.sourceLabel || null,
+ });
}
async getNotesForMember(userToken, clubId, memberId) {
devLog(userToken, clubId);
await checkAccess(userToken, clubId);
+ await this.ensureMemberInClub(clubId, memberId);
return await MemberNote.findAll({
where: { memberId },
order: [['createdAt', 'DESC']]
@@ -23,6 +41,7 @@ class MemberNoteService {
if (!note) {
throw new Error('Note not found');
}
+ await this.ensureMemberInClub(clubId, note.memberId);
await note.destroy();
}
}
diff --git a/frontend/src/components/MemberNotesDialog.vue b/frontend/src/components/MemberNotesDialog.vue
index 88d91850..3e4d6299 100755
--- a/frontend/src/components/MemberNotesDialog.vue
+++ b/frontend/src/components/MemberNotesDialog.vue
@@ -33,15 +33,26 @@
{{ $t('memberNotes.notes') }}
@@ -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; }
}
diff --git a/frontend/src/views/MembersView.vue b/frontend/src/views/MembersView.vue
index 256bfc90..7cd552dd 100755
--- a/frontend/src/views/MembersView.vue
+++ b/frontend/src/views/MembersView.vue
@@ -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;
diff --git a/frontend/src/views/OfficialTournaments.vue b/frontend/src/views/OfficialTournaments.vue
index 5b00930f..c2c1de36 100755
--- a/frontend/src/views/OfficialTournaments.vue
+++ b/frontend/src/views/OfficialTournaments.vue
@@ -508,6 +508,10 @@
Stärken Gegner/in
Schwächen Gegner/in
+
+
Spielerbeobachtung Wird in den gemeinsamen Mitgliedsnotizen für das gezielte Training gespeichert. Bitte zuerst eine eigene Spielerin bzw. einen eigenen Spieler auswählen.
+
Beobachtung festhalten
+
Ungespeicherte Änderungen {{ tacticSaving ? 'Speichert …' : 'Speichern' }}
@@ -556,6 +560,18 @@
@confirm="handleConfirmResult(true)"
@cancel="handleConfirmResult(false)"
/>
+
@@ -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 {