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();
}
},