diff --git a/backend/controllers/participantController.js b/backend/controllers/participantController.js index 7eb36893..41148d4c 100644 --- a/backend/controllers/participantController.js +++ b/backend/controllers/participantController.js @@ -1,13 +1,17 @@ import Participant from '../models/Participant.js'; import DiaryDates from '../models/DiaryDates.js'; +import DiaryMemberActivity from '../models/DiaryMemberActivity.js'; import { devLog } from '../utils/logger.js'; import { emitParticipantAdded, emitParticipantRemoved, emitParticipantUpdated } from '../services/socketService.js'; + +const PARTICIPANT_ATTRIBUTES = ['id', 'diaryDateId', 'memberId', 'attendanceStatus', 'groupId', 'notes', 'createdAt', 'updatedAt']; + export const getParticipants = async (req, res) => { try { const { dateId } = req.params; const participants = await Participant.findAll({ where: { diaryDateId: dateId }, - attributes: ['id', 'diaryDateId', 'memberId', 'groupId', 'notes', 'createdAt', 'updatedAt'] + attributes: PARTICIPANT_ATTRIBUTES }); res.status(200).json(participants); } catch (error) { @@ -68,12 +72,22 @@ export const updateParticipantGroup = async (req, res) => { export const addParticipant = async (req, res) => { try { const { diaryDateId, memberId } = req.body; - const participant = await Participant.create({ diaryDateId, memberId }); + const [participant, created] = await Participant.findOrCreate({ + where: { diaryDateId, memberId }, + defaults: { diaryDateId, memberId, attendanceStatus: 'present' } + }); + + participant.attendanceStatus = 'present'; + await participant.save(); // Hole DiaryDate für clubId const diaryDate = await DiaryDates.findByPk(diaryDateId); if (diaryDate?.clubId) { - emitParticipantAdded(diaryDate.clubId, diaryDateId, participant); + if (created) { + emitParticipantAdded(diaryDate.clubId, diaryDateId, participant); + } else { + emitParticipantUpdated(diaryDate.clubId, diaryDateId, participant); + } } res.status(201).json(participant); @@ -90,6 +104,15 @@ export const removeParticipant = async (req, res) => { // Hole DiaryDate für clubId vor dem Löschen const diaryDate = await DiaryDates.findByPk(diaryDateId); const clubId = diaryDate?.clubId; + + const participant = await Participant.findOne({ + where: { diaryDateId, memberId }, + attributes: ['id'] + }); + + if (participant) { + await DiaryMemberActivity.destroy({ where: { participantId: participant.id } }); + } await Participant.destroy({ where: { diaryDateId, memberId } }); @@ -104,3 +127,53 @@ export const removeParticipant = async (req, res) => { res.status(500).json({ error: 'Fehler beim Entfernen des Teilnehmers' }); } }; + +export const updateParticipantStatus = async (req, res) => { + try { + const { dateId, memberId } = req.params; + const { attendanceStatus } = req.body; + + if (!['excused', 'cancelled'].includes(attendanceStatus)) { + return res.status(400).json({ error: 'Ungültiger Teilnehmerstatus' }); + } + + const diaryDate = await DiaryDates.findByPk(dateId); + if (!diaryDate) { + return res.status(404).json({ error: 'Trainingstag nicht gefunden' }); + } + + const [participant] = await Participant.findOrCreate({ + where: { + diaryDateId: dateId, + memberId + }, + defaults: { + diaryDateId: dateId, + memberId, + attendanceStatus + } + }); + + participant.attendanceStatus = attendanceStatus; + participant.groupId = null; + await participant.save(); + await DiaryMemberActivity.destroy({ where: { participantId: participant.id } }); + + const updatedParticipant = await Participant.findOne({ + where: { + diaryDateId: dateId, + memberId + }, + attributes: PARTICIPANT_ATTRIBUTES + }); + + if (diaryDate.clubId && updatedParticipant) { + emitParticipantUpdated(diaryDate.clubId, dateId, updatedParticipant); + } + + res.status(200).json(updatedParticipant || participant); + } catch (error) { + devLog(error); + res.status(500).json({ error: 'Fehler beim Aktualisieren des Teilnehmerstatus' }); + } +}; diff --git a/backend/models/Participant.js b/backend/models/Participant.js index b5438b5b..c5acd79f 100644 --- a/backend/models/Participant.js +++ b/backend/models/Participant.js @@ -28,6 +28,14 @@ const Participant = sequelize.define('Participant', { key: 'id' } }, + attendanceStatus: { + type: DataTypes.STRING(32), + allowNull: false, + defaultValue: 'present', + validate: { + isIn: [['present', 'excused', 'cancelled']] + } + }, groupId: { type: DataTypes.INTEGER, allowNull: true, diff --git a/backend/routes/participantRoutes.js b/backend/routes/participantRoutes.js index 023a67f2..68cbb559 100644 --- a/backend/routes/participantRoutes.js +++ b/backend/routes/participantRoutes.js @@ -1,5 +1,5 @@ import express from 'express'; -import { getParticipants, addParticipant, removeParticipant, updateParticipantGroup } from '../controllers/participantController.js'; +import { getParticipants, addParticipant, removeParticipant, updateParticipantGroup, updateParticipantStatus } from '../controllers/participantController.js'; import { authenticate } from '../middleware/authMiddleware.js'; const router = express.Router(); @@ -7,6 +7,7 @@ const router = express.Router(); router.get('/:dateId', authenticate, getParticipants); router.post('/add', authenticate, addParticipant); router.post('/remove', authenticate, removeParticipant); +router.put('/:dateId/:memberId/status', authenticate, updateParticipantStatus); router.put('/:dateId/:memberId/group', authenticate, updateParticipantGroup); export default router; diff --git a/frontend/src/components/DiaryParticipantsPanel.vue b/frontend/src/components/DiaryParticipantsPanel.vue index b2579560..5846a710 100644 --- a/frontend/src/components/DiaryParticipantsPanel.vue +++ b/frontend/src/components/DiaryParticipantsPanel.vue @@ -38,13 +38,16 @@ type="checkbox" :value="member.id" :checked="participants.includes(member.id)" + :disabled="participantStatusMap[member.id] === 'excused'" @change="$emit('toggle-participant', member.id)" > 🛑 ⚠️ - {{ member.firstName }} {{ member.lastName }} + + {{ member.firstName }} {{ member.lastName }} +
+ 🖼 diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 92c09f82..4f52b3b6 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -466,6 +466,9 @@ "filterPresent": "Anwesend", "filterAbsent": "Abwesend", "filterTest": "Probe", + "participantStatusNone": "Kein Status", + "participantStatusExcused": "Entschuldigt", + "participantStatusCancelled": "Abgesagt", "quickAdd": "+ Schnell hinzufügen", "selectTags": "Tags auswählen", "createDrawing": "Übungszeichnung erstellen", diff --git a/frontend/src/i18n/locales/en-GB.json b/frontend/src/i18n/locales/en-GB.json index 17813545..a14061bc 100644 --- a/frontend/src/i18n/locales/en-GB.json +++ b/frontend/src/i18n/locales/en-GB.json @@ -133,7 +133,10 @@ "standardActivities": "Standard activities", "standardDurationShort": "Min", "standardActivityAddError": "Standard activity could not be added.", - "statusReady": "Times and training plan are set." + "statusReady": "Times and training plan are set.", + "participantStatusNone": "No status", + "participantStatusExcused": "Excused", + "participantStatusCancelled": "Cancelled" }, "predefinedActivities": { "excludeFromStats": "Exclude from statistics", diff --git a/frontend/src/views/DiaryView.vue b/frontend/src/views/DiaryView.vue index 9b64cfd7..7c484b52 100644 --- a/frontend/src/views/DiaryView.vue +++ b/frontend/src/views/DiaryView.vue @@ -642,9 +642,11 @@ :participant-filter="participantFilter" :groups="groups" :member-groups-map="memberGroupsMap" + :participant-status-map="participantStatusMap" @update:participant-search-query="participantSearchQuery = $event" @update:participant-filter="participantFilter = $event" @toggle-participant="toggleParticipant" + @toggle-member-status="toggleParticipantStatus" @update-member-group="updateMemberGroup($event.memberId, $event.groupId)" @open-notes="openNotesModal" @show-pic="showPic" @@ -973,7 +975,9 @@ export default { activityMembersOpenId: null, activityMembersMap: {}, // key: activityId, value: Set(participantIds) activityGroupsMap: {}, // key: activityId, value: groupId + participantMapByMemberId: {}, // key: memberId, value: participantId memberGroupsMap: {}, // key: memberId, value: groupId + participantStatusMap: {}, // key: memberId, value: attendanceStatus groupActivityMembersOpenId: null, groupActivityMembersMap: {}, // key: groupActivityId, value: Set(participantIds) editingGroupActivity: null, // Gruppenaktivität, die gerade bearbeitet wird @@ -1647,16 +1651,24 @@ export default { async loadParticipants(dateId) { const response = await apiClient.get(`/participants/${dateId}`); - this.participants = response.data.map(participant => participant.memberId); - // Map für memberId -> participantId speichern - this.participantMapByMemberId = response.data.reduce((map, p) => { map[p.memberId] = p.id; return map; }, {}); - // Map für memberId -> groupId speichern und mit Reaktivität initialisieren - response.data.forEach(p => { + const participantRows = response.data || []; + this.participants = participantRows + .filter(participant => !participant.attendanceStatus || participant.attendanceStatus === 'present') + .map(participant => participant.memberId); + this.participantMapByMemberId = participantRows.reduce((map, p) => { + map[p.memberId] = p.id; + return map; + }, {}); + this.participantStatusMap = participantRows.reduce((map, p) => { + map[p.memberId] = p.attendanceStatus || 'present'; + return map; + }, {}); + this.memberGroupsMap = {}; + participantRows.forEach(p => { const groupValue = (p.groupId !== null && p.groupId !== undefined) ? String(p.groupId) : ''; if (this.$set) { this.$set(this.memberGroupsMap, p.memberId, groupValue); } else { - // Vue 3: Reaktivität wird automatisch gewährleistet this.memberGroupsMap = { ...this.memberGroupsMap, [p.memberId]: groupValue @@ -1710,6 +1722,10 @@ export default { return this.participants.includes(memberId); }, + getParticipantStatus(memberId) { + return this.participantStatusMap[memberId] || ''; + }, + async toggleParticipant(memberId) { const isParticipant = this.isParticipant(memberId); const dateId = this.date.id; @@ -1719,15 +1735,89 @@ export default { memberId }); this.participants = this.participants.filter(id => id !== memberId); + const nextStatusMap = { ...this.participantStatusMap }; + const nextParticipantMap = { ...this.participantMapByMemberId }; + const nextGroupMap = { ...this.memberGroupsMap }; + delete nextStatusMap[memberId]; + delete nextParticipantMap[memberId]; + delete nextGroupMap[memberId]; + this.participantStatusMap = nextStatusMap; + this.participantMapByMemberId = nextParticipantMap; + this.memberGroupsMap = nextGroupMap; } else { - await apiClient.post('/participants/add', { + const response = await apiClient.post('/participants/add', { diaryDateId: dateId, memberId }); - this.participants.push(memberId); + this.participants = [...new Set([...this.participants, memberId])]; + this.participantStatusMap = { + ...this.participantStatusMap, + [memberId]: 'present' + }; + this.participantMapByMemberId = { + ...this.participantMapByMemberId, + [memberId]: response.data.id + }; } }, + async updateParticipantStatus(memberId, status) { + const dateId = this.date.id; + const response = await apiClient.put(`/participants/${dateId}/${memberId}/status`, { + attendanceStatus: status + }); + const participant = response.data; + + this.participants = this.participants.filter(id => id !== memberId); + this.participantStatusMap = { + ...this.participantStatusMap, + [memberId]: participant.attendanceStatus + }; + this.participantMapByMemberId = { + ...this.participantMapByMemberId, + [memberId]: participant.id + }; + this.memberGroupsMap = { + ...this.memberGroupsMap, + [memberId]: '' + }; + }, + + async clearParticipantStatus(memberId) { + const dateId = this.date.id; + + if (this.isParticipant(memberId)) { + this.participantStatusMap = { + ...this.participantStatusMap, + [memberId]: 'present' + }; + return; + } + + await apiClient.post('/participants/remove', { + diaryDateId: dateId, + memberId + }); + const nextStatusMap = { ...this.participantStatusMap }; + const nextParticipantMap = { ...this.participantMapByMemberId }; + const nextGroupMap = { ...this.memberGroupsMap }; + delete nextStatusMap[memberId]; + delete nextParticipantMap[memberId]; + delete nextGroupMap[memberId]; + this.participantStatusMap = nextStatusMap; + this.participantMapByMemberId = nextParticipantMap; + this.memberGroupsMap = nextGroupMap; + }, + + async toggleParticipantStatus(memberId) { + if (this.getParticipantStatus(memberId) === 'excused') { + await this.clearParticipantStatus(memberId); + return; + } + + await this.updateParticipantStatus(memberId, 'excused'); + }, + async markFormHandedOver(member) { try { const memberData = { @@ -3569,35 +3659,52 @@ export default { if (this.date && this.date.id === data.dateId) { // Entferne aus participants-Array this.participants = this.participants.filter(memberId => memberId !== data.participantId); - // Entferne aus Maps - delete this.participantMapByMemberId[data.participantId]; - delete this.memberGroupsMap[data.participantId]; + const nextParticipantMap = { ...this.participantMapByMemberId }; + const nextGroupMap = { ...this.memberGroupsMap }; + const nextStatusMap = { ...this.participantStatusMap }; + delete nextParticipantMap[data.participantId]; + delete nextGroupMap[data.participantId]; + delete nextStatusMap[data.participantId]; + this.participantMapByMemberId = nextParticipantMap; + this.memberGroupsMap = nextGroupMap; + this.participantStatusMap = nextStatusMap; } }, async handleParticipantUpdated(data) { // Nur aktualisieren, wenn das aktuelle Datum betroffen ist if (this.date && this.date !== 'new' && String(this.date.id) === String(data.dateId)) { - // Aktualisiere groupId in memberGroupsMap + const status = data.participant.attendanceStatus || 'present'; const groupValue = (data.participant.groupId !== null && data.participant.groupId !== undefined) ? String(data.participant.groupId) : ''; - - // Verwende $set für Vue 2 - das ist wichtig für Reaktivität + if (this.$set) { this.$set(this.memberGroupsMap, data.participant.memberId, groupValue); + this.$set(this.participantStatusMap, data.participant.memberId, status); + this.$set(this.participantMapByMemberId, data.participant.memberId, data.participant.id); } else { - // Vue 3: Erstelle neues Objekt für Reaktivität this.memberGroupsMap = { ...this.memberGroupsMap, [data.participant.memberId]: groupValue }; + this.participantStatusMap = { + ...this.participantStatusMap, + [data.participant.memberId]: status + }; + this.participantMapByMemberId = { + ...this.participantMapByMemberId, + [data.participant.memberId]: data.participant.id + }; } - - // Warte auf Vue-Update und force dann ein Re-Render + + if (status === 'present') { + this.participants = [...new Set([...this.participants, data.participant.memberId])]; + } else { + this.participants = this.participants.filter(memberId => memberId !== data.participant.memberId); + } + await this.$nextTick(); - - // Force Vue update um sicherzustellen, dass die UI aktualisiert wird this.$forceUpdate(); } },