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:
120
backend/controllers/memberActivityController.js
Normal file
120
backend/controllers/memberActivityController.js
Normal 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' });
|
||||
}
|
||||
};
|
||||
|
||||
12
backend/routes/memberActivityRoutes.js
Normal file
12
backend/routes/memberActivityRoutes.js
Normal 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;
|
||||
|
||||
@@ -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')));
|
||||
|
||||
|
||||
318
frontend/src/components/MemberActivitiesDialog.vue
Normal file
318
frontend/src/components/MemberActivitiesDialog.vue
Normal 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user