Add member activity routes and UI enhancements in MembersView

Integrate member activity management by adding new routes in the backend for member activities. Update MembersView.vue to include a button for opening the activities modal and implement the MemberActivitiesDialog component for displaying member activities. Enhance the UI with new button styles for better user interaction.
This commit is contained in:
Torsten Schulz (local)
2025-10-16 22:36:49 +02:00
parent 01bbb85485
commit c74217f6d8
5 changed files with 487 additions and 2 deletions

View File

@@ -0,0 +1,120 @@
import { checkAccess } from '../utils/userUtils.js';
import DiaryMemberActivity from '../models/DiaryMemberActivity.js';
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 { Op } from 'sequelize';
export const getMemberActivities = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, memberId } = req.params;
const { period } = req.query; // 'month', '3months', '6months', 'year', 'all'
await checkAccess(userToken, clubId);
// Calculate date range based on period
const now = new Date();
let startDate = null;
switch (period) {
case 'month':
startDate = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate());
break;
case '3months':
startDate = new Date(now.getFullYear(), now.getMonth() - 3, now.getDate());
break;
case '6months':
startDate = new Date(now.getFullYear(), now.getMonth() - 6, now.getDate());
break;
case 'year':
startDate = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
break;
case 'all':
default:
startDate = null;
break;
}
// 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 all diary member activities for this member
const whereClause = {
participantId: participantIds
};
const memberActivities = await DiaryMemberActivity.findAll({
where: whereClause,
include: [
{
model: DiaryDateActivity,
as: 'activity',
include: [
{
model: DiaryDates,
as: 'diaryDate',
where: startDate ? {
date: {
[Op.gte]: startDate
}
} : {}
},
{
model: PredefinedActivity,
as: 'predefinedActivity'
}
]
}
],
order: [[{ model: DiaryDateActivity, as: 'activity' }, { model: DiaryDates, as: 'diaryDate' }, 'date', 'DESC']]
});
// Group activities by name and count occurrences
const activityMap = new Map();
for (const ma of memberActivities) {
if (!ma.activity || !ma.activity.predefinedActivity) {
continue;
}
const activity = ma.activity.predefinedActivity;
const activityName = activity.name;
const date = ma.activity.diaryDate?.date;
if (!activityMap.has(activityName)) {
activityMap.set(activityName, {
name: activityName,
count: 0,
dates: []
});
}
const activityData = activityMap.get(activityName);
activityData.count++;
if (date) {
activityData.dates.push(date);
}
}
// Convert map to array and sort by count
const activities = Array.from(activityMap.values())
.sort((a, b) => b.count - a.count);
return res.status(200).json(activities);
} catch (error) {
console.error('Error fetching member activities:', error);
return res.status(500).json({ error: 'Failed to fetch member activities' });
}
};

View File

@@ -0,0 +1,12 @@
import express from 'express';
import { authenticate } from '../middleware/authMiddleware.js';
import { getMemberActivities } from '../controllers/memberActivityController.js';
const router = express.Router();
router.use(authenticate);
router.get('/:clubId/:memberId', getMemberActivities);
export default router;

View File

@@ -38,6 +38,7 @@ import teamRoutes from './routes/teamRoutes.js';
import clubTeamRoutes from './routes/clubTeamRoutes.js';
import teamDocumentRoutes from './routes/teamDocumentRoutes.js';
import seasonRoutes from './routes/seasonRoutes.js';
import memberActivityRoutes from './routes/memberActivityRoutes.js';
import schedulerService from './services/schedulerService.js';
const app = express();
@@ -88,6 +89,7 @@ app.use('/api/teams', teamRoutes);
app.use('/api/club-teams', clubTeamRoutes);
app.use('/api/team-documents', teamDocumentRoutes);
app.use('/api/seasons', seasonRoutes);
app.use('/api/member-activities', memberActivityRoutes);
app.use(express.static(path.join(__dirname, '../frontend/dist')));

View File

@@ -0,0 +1,318 @@
<template>
<BaseDialog
v-model="isOpen"
:title="`Übungen von ${memberName}`"
@close="closeDialog"
:width="800"
>
<div class="activities-content">
<!-- Period Selection -->
<div class="period-selector">
<label>Zeitraum:</label>
<select v-model="selectedPeriod" @change="loadActivities">
<option value="month">Letzte 4 Wochen</option>
<option value="3months">Letzte 3 Monate</option>
<option value="6months">Letztes halbes Jahr</option>
<option value="year">Letztes Jahr</option>
<option value="all">Alle</option>
</select>
</div>
<!-- Loading State -->
<div v-if="loading" class="loading">
Lade Übungen...
</div>
<!-- No Activities -->
<div v-else-if="activities.length === 0" class="no-activities">
Keine Übungen im gewählten Zeitraum gefunden.
</div>
<!-- Activities List -->
<div v-else class="activities-list">
<table class="activities-table">
<thead>
<tr>
<th>Übung</th>
<th>Häufigkeit</th>
<th>Daten</th>
</tr>
</thead>
<tbody>
<tr v-for="activity in activities" :key="activity.name">
<td class="activity-name">{{ activity.name }}</td>
<td class="activity-count">{{ activity.count }}x</td>
<td class="activity-dates">
<div class="dates-container">
<span
v-for="(date, index) in activity.dates.slice(0, showAllDates[activity.name] ? undefined : 5)"
:key="index"
class="date-badge"
>
{{ formatDate(date) }}
</span>
<button
v-if="activity.dates.length > 5 && !showAllDates[activity.name]"
@click="toggleShowAllDates(activity.name)"
class="show-more-btn"
>
+{{ activity.dates.length - 5 }} weitere
</button>
<button
v-if="activity.dates.length > 5 && showAllDates[activity.name]"
@click="toggleShowAllDates(activity.name)"
class="show-less-btn"
>
weniger anzeigen
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<template #footer>
<button @click="closeDialog" class="btn-close">Schließen</button>
</template>
</BaseDialog>
</template>
<script>
import { ref, computed, watch } from 'vue';
import BaseDialog from './BaseDialog.vue';
import apiClient from '../apiClient.js';
export default {
name: 'MemberActivitiesDialog',
components: {
BaseDialog
},
props: {
modelValue: {
type: Boolean,
default: false
},
member: {
type: Object,
default: null
},
clubId: {
type: [String, Number],
required: true
}
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const isOpen = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
const memberName = computed(() => {
if (!props.member) return '';
return `${props.member.firstName} ${props.member.lastName}`;
});
const selectedPeriod = ref('6months');
const loading = ref(false);
const activities = ref([]);
const showAllDates = ref({});
const loadActivities = async () => {
if (!props.member) return;
loading.value = true;
try {
const response = await apiClient.get(
`/member-activities/${props.clubId}/${props.member.id}`,
{
params: {
period: selectedPeriod.value
}
}
);
activities.value = response.data;
// Reset showAllDates
showAllDates.value = {};
} catch (error) {
console.error('Error loading member activities:', error);
activities.value = [];
} finally {
loading.value = false;
}
};
const formatDate = (dateString) => {
if (!dateString) return '';
const date = new Date(dateString);
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
return `${day}.${month}.${year}`;
};
const toggleShowAllDates = (activityName) => {
showAllDates.value[activityName] = !showAllDates.value[activityName];
};
const closeDialog = () => {
isOpen.value = false;
};
// Watch for dialog open to load activities
watch(() => props.modelValue, (newValue) => {
if (newValue && props.member) {
loadActivities();
}
});
return {
isOpen,
memberName,
selectedPeriod,
loading,
activities,
showAllDates,
loadActivities,
formatDate,
toggleShowAllDates,
closeDialog
};
}
};
</script>
<style scoped>
.activities-content {
padding: 1rem;
}
.period-selector {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.period-selector label {
font-weight: 600;
color: var(--text-color, #2c3e50);
}
.period-selector select {
padding: 0.5rem 1rem;
border: 1px solid var(--border-color, #ddd);
border-radius: 4px;
font-size: 1rem;
background: white;
cursor: pointer;
}
.loading {
text-align: center;
padding: 2rem;
color: var(--text-secondary, #666);
font-style: italic;
}
.no-activities {
text-align: center;
padding: 2rem;
color: var(--text-secondary, #666);
font-style: italic;
}
.activities-table {
width: 100%;
border-collapse: collapse;
}
.activities-table thead th {
background-color: var(--header-bg, #f5f5f5);
padding: 0.75rem;
text-align: left;
font-weight: 600;
border-bottom: 2px solid var(--border-color, #ddd);
}
.activities-table tbody tr {
border-bottom: 1px solid var(--border-color, #eee);
}
.activities-table tbody tr:hover {
background-color: var(--row-hover, #f9f9f9);
}
.activities-table td {
padding: 0.75rem;
vertical-align: top;
}
.activity-name {
font-weight: 500;
color: var(--primary-color, #2c3e50);
}
.activity-count {
font-weight: 600;
color: var(--accent-color, #3498db);
text-align: center;
min-width: 80px;
}
.activity-dates {
max-width: 450px;
}
.dates-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.date-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
background-color: var(--badge-bg, #e8f4f8);
color: var(--badge-text, #2980b9);
border-radius: 4px;
font-size: 0.875rem;
white-space: nowrap;
}
.show-more-btn,
.show-less-btn {
padding: 0.25rem 0.5rem;
background-color: transparent;
color: var(--link-color, #3498db);
border: 1px solid var(--link-color, #3498db);
border-radius: 4px;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
}
.show-more-btn:hover,
.show-less-btn:hover {
background-color: var(--link-color, #3498db);
color: white;
}
.btn-close {
padding: 0.5rem 1.5rem;
background-color: var(--secondary-color, #95a5a6);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.2s;
}
.btn-close:hover {
background-color: var(--secondary-color-dark, #7f8c8d);
}
</style>

View File

@@ -120,6 +120,7 @@
<td>{{ member.email }}</td>
<td>
<button @click.stop="openNotesModal(member)">Notizen</button>
<button @click.stop="openActivitiesModal(member)" class="btn-activities">Übungen</button>
</td>
</tr>
@@ -143,6 +144,13 @@
@confirm="handleConfirmResult(true)"
@cancel="handleConfirmResult(false)"
/>
<!-- Member Activities Dialog -->
<MemberActivitiesDialog
v-model="showActivitiesModal"
:member="selectedMemberForActivities"
:club-id="currentClub"
/>
</template>
</tbody>
</table>
@@ -205,6 +213,7 @@ import ConfirmDialog from '../components/ConfirmDialog.vue';
import ImageViewerDialog from '../components/ImageViewerDialog.vue';
import BaseDialog from '../components/BaseDialog.vue';
import MemberNotesDialog from '../components/MemberNotesDialog.vue';
import MemberActivitiesDialog from '../components/MemberActivitiesDialog.vue';
export default {
name: 'MembersView',
components: {
@@ -212,7 +221,8 @@ export default {
ConfirmDialog,
ImageViewerDialog,
BaseDialog,
MemberNotesDialog
MemberNotesDialog,
MemberActivitiesDialog
},
computed: {
...mapGetters(['isAuthenticated', 'currentClub']),
@@ -271,7 +281,9 @@ export default {
showInactiveMembers: false,
newPicsInInternetAllowed: false,
isUpdatingRatings: false,
showMemberInfo: false
showMemberInfo: false,
showActivitiesModal: false,
selectedMemberForActivities: null
}
},
async mounted() {
@@ -483,6 +495,10 @@ export default {
closeNotesModal() {
this.showNotesModal = false;
},
openActivitiesModal(member) {
this.selectedMemberForActivities = member;
this.showActivitiesModal = true;
},
openImageModal(imageUrl, memberId) {
this.selectedImageUrl = imageUrl;
this.selectedMemberId = memberId;
@@ -915,4 +931,21 @@ table td {
min-width: 2rem;
text-align: center;
}
/* Button Styles */
.btn-activities {
margin-left: 0.5rem;
background-color: #28a745;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
transition: background-color 0.2s ease;
}
.btn-activities:hover {
background-color: #218838;
}
</style>