Implement last participations endpoint and enhance member activity retrieval
Added a new endpoint to fetch the last participations of a member, including group assignment checks for activities. Updated the member activity controller to include logic for filtering activities based on participant group IDs. Enhanced the DiaryView component to display activity statistics and last participations in a modal, improving user experience and data accessibility.
This commit is contained in:
@@ -4,6 +4,7 @@ import DiaryDateActivity from '../models/DiaryDateActivity.js';
|
||||
import DiaryDates from '../models/DiaryDates.js';
|
||||
import Participant from '../models/Participant.js';
|
||||
import PredefinedActivity from '../models/PredefinedActivity.js';
|
||||
import GroupActivity from '../models/GroupActivity.js';
|
||||
import { Op } from 'sequelize';
|
||||
|
||||
export const getMemberActivities = async (req, res) => {
|
||||
@@ -56,6 +57,11 @@ export const getMemberActivities = async (req, res) => {
|
||||
const memberActivities = await DiaryMemberActivity.findAll({
|
||||
where: whereClause,
|
||||
include: [
|
||||
{
|
||||
model: Participant,
|
||||
as: 'participant',
|
||||
attributes: ['id', 'groupId', 'diaryDateId']
|
||||
},
|
||||
{
|
||||
model: DiaryDateActivity,
|
||||
as: 'activity',
|
||||
@@ -72,6 +78,11 @@ export const getMemberActivities = async (req, res) => {
|
||||
{
|
||||
model: PredefinedActivity,
|
||||
as: 'predefinedActivity'
|
||||
},
|
||||
{
|
||||
model: GroupActivity,
|
||||
as: 'groupActivities',
|
||||
required: false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -79,11 +90,25 @@ export const getMemberActivities = async (req, res) => {
|
||||
order: [[{ model: DiaryDateActivity, as: 'activity' }, { model: DiaryDates, as: 'diaryDate' }, 'date', 'DESC']]
|
||||
});
|
||||
|
||||
// Group activities by name and count occurrences
|
||||
// Group activities by name and count occurrences, considering group assignment
|
||||
const activityMap = new Map();
|
||||
|
||||
for (const ma of memberActivities) {
|
||||
if (!ma.activity || !ma.activity.predefinedActivity) {
|
||||
if (!ma.activity || !ma.activity.predefinedActivity || !ma.participant) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check group assignment
|
||||
const participantGroupId = ma.participant.groupId;
|
||||
const activityGroupIds = ma.activity.groupActivities?.map(ga => ga.groupId) || [];
|
||||
|
||||
// Filter: Only count if:
|
||||
// 1. Activity has no group assignment (empty activityGroupIds) - activity is for all groups OR
|
||||
// 2. Participant's group matches one of the activity's groups
|
||||
const shouldCount = activityGroupIds.length === 0 ||
|
||||
(participantGroupId !== null && activityGroupIds.includes(participantGroupId));
|
||||
|
||||
if (!shouldCount) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -118,3 +143,90 @@ export const getMemberActivities = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const getMemberLastParticipations = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, memberId } = req.params;
|
||||
const { limit = 3 } = req.query;
|
||||
|
||||
await checkAccess(userToken, clubId);
|
||||
|
||||
// Get participant ID for this member
|
||||
const participants = await Participant.findAll({
|
||||
where: { memberId: memberId }
|
||||
});
|
||||
|
||||
if (participants.length === 0) {
|
||||
return res.status(200).json([]);
|
||||
}
|
||||
|
||||
const participantIds = participants.map(p => p.id);
|
||||
|
||||
// Get last participations for this member
|
||||
const memberActivities = await DiaryMemberActivity.findAll({
|
||||
where: {
|
||||
participantId: participantIds
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: Participant,
|
||||
as: 'participant',
|
||||
attributes: ['id', 'groupId', 'diaryDateId']
|
||||
},
|
||||
{
|
||||
model: DiaryDateActivity,
|
||||
as: 'activity',
|
||||
include: [
|
||||
{
|
||||
model: DiaryDates,
|
||||
as: 'diaryDate'
|
||||
},
|
||||
{
|
||||
model: PredefinedActivity,
|
||||
as: 'predefinedActivity'
|
||||
},
|
||||
{
|
||||
model: GroupActivity,
|
||||
as: 'groupActivities',
|
||||
required: false
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
order: [[{ model: DiaryDateActivity, as: 'activity' }, { model: DiaryDates, as: 'diaryDate' }, 'date', 'DESC']],
|
||||
limit: parseInt(limit) * 10 // Get more to filter by group
|
||||
});
|
||||
|
||||
// Format the results, considering group assignment
|
||||
const participations = memberActivities
|
||||
.filter(ma => {
|
||||
if (!ma.activity || !ma.activity.predefinedActivity || !ma.activity.diaryDate || !ma.participant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check group assignment
|
||||
const participantGroupId = ma.participant.groupId;
|
||||
const activityGroupIds = ma.activity.groupActivities?.map(ga => ga.groupId) || [];
|
||||
|
||||
// Filter: Only count if:
|
||||
// 1. Activity has no group assignment (empty activityGroupIds) - activity is for all groups OR
|
||||
// 2. Participant's group matches one of the activity's groups
|
||||
return activityGroupIds.length === 0 ||
|
||||
(participantGroupId !== null && activityGroupIds.includes(participantGroupId));
|
||||
})
|
||||
.slice(0, parseInt(limit)) // Limit after filtering
|
||||
.map(ma => ({
|
||||
id: ma.id,
|
||||
activityName: ma.activity.predefinedActivity.name,
|
||||
date: ma.activity.diaryDate.date,
|
||||
diaryDateId: ma.activity.diaryDate.id
|
||||
}));
|
||||
|
||||
return res.status(200).json(participations);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching member last participations:', error);
|
||||
return res.status(500).json({ error: 'Failed to fetch member last participations' });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import express from 'express';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import { getMemberActivities } from '../controllers/memberActivityController.js';
|
||||
import { getMemberActivities, getMemberLastParticipations } from '../controllers/memberActivityController.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
router.get('/:clubId/:memberId/last-participations', getMemberLastParticipations);
|
||||
router.get('/:clubId/:memberId', getMemberActivities);
|
||||
|
||||
export default router;
|
||||
|
||||
152
frontend/src/components/MemberActivityStatsDialog.vue
Normal file
152
frontend/src/components/MemberActivityStatsDialog.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:model-value="modelValue"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
:title="`Übungs-Statistiken ${member ? member.firstName + ' ' + member.lastName : ''}`"
|
||||
size="medium"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="activity-stats-content">
|
||||
<!-- Letzte 3 Teilnahmen -->
|
||||
<div class="section">
|
||||
<h3 class="section-title">Letzte 3 Teilnahmen</h3>
|
||||
<div v-if="lastParticipations && lastParticipations.length" class="participations-list">
|
||||
<div v-for="participation in lastParticipations" :key="participation.id" class="participation-item">
|
||||
<div class="participation-name">{{ participation.activityName }}</div>
|
||||
<div class="participation-date">{{ formatDate(participation.date) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-data">
|
||||
<em>Keine Teilnahmen vorhanden</em>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistik der Übungen -->
|
||||
<div class="section">
|
||||
<h3 class="section-title">Statistik der Übungen</h3>
|
||||
<div v-if="activityStats && activityStats.length" class="stats-list">
|
||||
<div v-for="stat in activityStats" :key="stat.name" class="stat-item">
|
||||
<div class="stat-name">{{ stat.name }}</div>
|
||||
<div class="stat-count">{{ stat.count }}x</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-data">
|
||||
<em>Keine Statistiken vorhanden</em>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BaseDialog from './BaseDialog.vue';
|
||||
|
||||
export default {
|
||||
name: 'MemberActivityStatsDialog',
|
||||
components: {
|
||||
BaseDialog
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
member: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
lastParticipations: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
activityStats: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'close'],
|
||||
methods: {
|
||||
handleClose() {
|
||||
this.$emit('update:modelValue', false);
|
||||
this.$emit('close');
|
||||
},
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('de-DE');
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.activity-stats-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
color: var(--primary-color);
|
||||
margin: 0;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid var(--background-light);
|
||||
}
|
||||
|
||||
.participations-list,
|
||||
.stats-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.participation-item,
|
||||
.stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
background: var(--background-light);
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.participation-item:hover,
|
||||
.stat-item:hover {
|
||||
background: var(--background-hover, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
|
||||
.participation-name,
|
||||
.stat-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.participation-date {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.stat-count {
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -361,6 +361,15 @@
|
||||
@close="closeTagHistoryModal"
|
||||
/>
|
||||
|
||||
<!-- Activity Stats Modal -->
|
||||
<MemberActivityStatsDialog
|
||||
v-model="showActivityStatsModal"
|
||||
:member="activityStatsMember"
|
||||
:last-participations="lastParticipations"
|
||||
:activity-stats="activityStats"
|
||||
@close="closeActivityStatsModal"
|
||||
/>
|
||||
|
||||
<!-- Notizen Modal -->
|
||||
<MemberNotesDialog
|
||||
v-model="showNotesModal"
|
||||
@@ -454,6 +463,7 @@ import ImageDialog from '../components/ImageDialog.vue';
|
||||
import BaseDialog from '../components/BaseDialog.vue';
|
||||
import MemberNotesDialog from '../components/MemberNotesDialog.vue';
|
||||
import TagHistoryDialog from '../components/TagHistoryDialog.vue';
|
||||
import MemberActivityStatsDialog from '../components/MemberActivityStatsDialog.vue';
|
||||
import AccidentFormDialog from '../components/AccidentFormDialog.vue';
|
||||
import QuickAddMemberDialog from '../components/QuickAddMemberDialog.vue';
|
||||
|
||||
@@ -469,6 +479,7 @@ export default {
|
||||
BaseDialog,
|
||||
MemberNotesDialog,
|
||||
TagHistoryDialog,
|
||||
MemberActivityStatsDialog,
|
||||
AccidentFormDialog,
|
||||
QuickAddMemberDialog
|
||||
},
|
||||
@@ -538,6 +549,10 @@ export default {
|
||||
showTagHistoryModal: false,
|
||||
tagHistoryMember: null,
|
||||
tagHistory: null,
|
||||
showActivityStatsModal: false,
|
||||
activityStatsMember: null,
|
||||
lastParticipations: [],
|
||||
activityStats: [],
|
||||
intermediateTimes: [],
|
||||
bellSound: null,
|
||||
thumbSound: null,
|
||||
@@ -1527,21 +1542,34 @@ export default {
|
||||
if (!member) {
|
||||
return;
|
||||
}
|
||||
this.showTagHistoryModal = true;
|
||||
this.tagHistoryMember = member;
|
||||
const tags = await apiClient.get(`/diarydatetags/${this.currentClub}/${member.id}`);
|
||||
this.tagHistory = tags.data;
|
||||
this.selectedMemberDayTags = [];
|
||||
this.selectedMemberDayTags = this.tagHistory.filter(tag => {
|
||||
return tag.diaryMemberTags.some(memberTag =>
|
||||
memberTag.diaryDates && memberTag.diaryDates.id === this.date.id
|
||||
);
|
||||
});
|
||||
this.showActivityStatsModal = true;
|
||||
this.activityStatsMember = member;
|
||||
|
||||
try {
|
||||
// Lade letzte 3 Teilnahmen
|
||||
const lastParticipationsResponse = await apiClient.get(`/member-activities/${this.currentClub}/${member.id}/last-participations?limit=3`);
|
||||
this.lastParticipations = lastParticipationsResponse.data || [];
|
||||
|
||||
// Lade Statistik der Übungen
|
||||
const statsResponse = await apiClient.get(`/member-activities/${this.currentClub}/${member.id}?period=all`);
|
||||
this.activityStats = statsResponse.data || [];
|
||||
} catch (error) {
|
||||
console.error('Error loading activity stats:', error);
|
||||
this.showInfo('Fehler', 'Fehler beim Laden der Statistiken.', '', 'error');
|
||||
this.lastParticipations = [];
|
||||
this.activityStats = [];
|
||||
}
|
||||
},
|
||||
closeTagHistoryModal() {
|
||||
this.showTagHistoryModal = false;
|
||||
this.tagHistoryMember = null;
|
||||
},
|
||||
closeActivityStatsModal() {
|
||||
this.showActivityStatsModal = false;
|
||||
this.activityStatsMember = null;
|
||||
this.lastParticipations = [];
|
||||
this.activityStats = [];
|
||||
},
|
||||
async addNewTagForDay(tag) {
|
||||
await apiClient.post(`/diarydatetags/${this.currentClub}`, {
|
||||
dateId: this.date.id,
|
||||
|
||||
Reference in New Issue
Block a user