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 @@ +
+
SpielerbeobachtungWird in den gemeinsamen Mitgliedsnotizen für das gezielte Training gespeichert.Bitte zuerst eine eigene Spielerin bzw. einen eigenen Spieler auswählen.
+ +
Ungespeicherte Änderungen
@@ -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 {