feat(ParticipantController, ParticipantModel, ParticipantRoutes, DiaryParticipantsPanel, i18n): implement participant status management and UI updates

- Added functionality to update participant attendance status, allowing for 'excused' and 'cancelled' states.
- Enhanced the participant model to include a default attendance status and validation for status values.
- Updated participant routes to support status updates and integrated new status handling in the participant controller.
- Modified the DiaryParticipantsPanel to visually indicate participant status and added a toggle for changing status.
- Expanded localization files to include new keys for participant status, improving accessibility for users in both English and German.
This commit is contained in:
Torsten Schulz (local)
2026-03-17 15:23:35 +01:00
parent 46812a0c14
commit 483d5d2bc7
7 changed files with 262 additions and 25 deletions

View File

@@ -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)"
>
</label>
<span class="clickable participant-name" @click.stop="$emit('open-notes', member)">
<span v-if="member.testMembership && member.trainingParticipations >= 6" class="warning-icon warning-icon-severe" :title="$t('members.sixOrMoreParticipations')">🛑</span>
<span v-else-if="member.testMembership && member.trainingParticipations >= 3" class="warning-icon" :title="$t('members.threeOrMoreParticipations')"></span>
{{ member.firstName }} {{ member.lastName }}
<span :class="{ 'participant-name-excused': participantStatusMap[member.id] === 'excused' }">
{{ member.firstName }} {{ member.lastName }}
</span>
</span>
<div class="participant-actions">
<select
@@ -58,6 +61,15 @@
{{ group.name }}
</option>
</select>
<button
type="button"
class="participant-status-toggle"
:class="{ active: participantStatusMap[member.id] === 'excused' }"
:title="$t('diary.participantStatusExcused')"
@click="$emit('toggle-member-status', member.id)"
>
📴
</button>
<span @click="$emit('show-pic', member)" class="img-icon" v-if="member.hasImage">&#x1F5BC;</span>
<span
v-if="member.testMembership === true && member.memberFormHandedOver !== true"
@@ -108,12 +120,17 @@ export default {
memberGroupsMap: {
type: Object,
required: true
},
participantStatusMap: {
type: Object,
required: true
}
},
emits: [
'update:participantSearchQuery',
'update:participantFilter',
'toggle-participant',
'toggle-member-status',
'update-member-group',
'open-notes',
'show-pic',
@@ -206,4 +223,29 @@ export default {
background-color: var(--surface-color);
color: var(--text-color);
}
.participant-status-toggle {
margin-left: 10px;
padding: 0.28rem 0.5rem;
border: 1px solid var(--border-color);
border-radius: 999px;
background: var(--surface-color);
color: var(--text-color);
font-size: 0.72rem;
font-weight: 700;
cursor: pointer;
}
.participant-status-toggle.active {
background: rgba(217, 164, 65, 0.14);
color: #8b6113;
border-color: rgba(217, 164, 65, 0.3);
}
.participant-name-excused {
color: #7a6a48;
font-style: italic;
opacity: 0.72;
}
</style>

View File

@@ -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",

View File

@@ -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",

View File

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