feat(MemberService, SchedulerService): implement inactive member cleanup functionality
- Added a new method in MemberService to clean up inactive members who haven't trained in the last specified days, enhancing data management. - Integrated the cleanup method into SchedulerService to run automatically via a scheduled job, improving system maintenance. - Updated the MembersView to reflect changes in member data, ensuring a consistent user experience.
This commit is contained in:
@@ -6,6 +6,7 @@ import MemberImage from "../models/MemberImage.js";
|
||||
import MemberTtrHistory from "../models/MemberTtrHistory.js";
|
||||
import Participant from "../models/Participant.js";
|
||||
import DiaryDate from "../models/DiaryDates.js";
|
||||
import { Op, fn, col } from 'sequelize';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import sharp from 'sharp';
|
||||
@@ -1709,6 +1710,80 @@ class MemberService {
|
||||
}
|
||||
}
|
||||
|
||||
async cleanupInactiveMembersWithoutRecentTraining(options = {}) {
|
||||
const { clubId = null, inactiveDays = 365 } = options;
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setHours(0, 0, 0, 0);
|
||||
cutoffDate.setDate(cutoffDate.getDate() - inactiveDays);
|
||||
|
||||
const diaryDateWhere = {};
|
||||
if (clubId) {
|
||||
diaryDateWhere.clubId = clubId;
|
||||
}
|
||||
|
||||
const lastTrainingRows = await Participant.findAll({
|
||||
attributes: [
|
||||
'memberId',
|
||||
[fn('MAX', col('diaryDate.date')), 'lastTrainingDate']
|
||||
],
|
||||
include: [
|
||||
{
|
||||
model: DiaryDate,
|
||||
as: 'diaryDate',
|
||||
attributes: [],
|
||||
required: true,
|
||||
where: diaryDateWhere
|
||||
}
|
||||
],
|
||||
where: {
|
||||
attendanceStatus: 'present'
|
||||
},
|
||||
group: ['memberId'],
|
||||
raw: true
|
||||
});
|
||||
|
||||
const staleMemberIds = lastTrainingRows
|
||||
.filter((row) => {
|
||||
const lastTrainingDate = row.lastTrainingDate ? new Date(row.lastTrainingDate) : null;
|
||||
return lastTrainingDate && !Number.isNaN(lastTrainingDate.getTime()) && lastTrainingDate < cutoffDate;
|
||||
})
|
||||
.map((row) => row.memberId);
|
||||
|
||||
if (staleMemberIds.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
deletedCount: 0,
|
||||
deletedMembers: []
|
||||
};
|
||||
}
|
||||
|
||||
const memberWhere = {
|
||||
id: { [Op.in]: staleMemberIds },
|
||||
active: false
|
||||
};
|
||||
|
||||
if (clubId) {
|
||||
memberWhere.clubId = clubId;
|
||||
}
|
||||
|
||||
const membersToDelete = await Member.findAll({
|
||||
where: memberWhere
|
||||
});
|
||||
|
||||
const deletedMembers = [];
|
||||
|
||||
for (const member of membersToDelete) {
|
||||
const result = await this._deleteMemberWithRelations(member);
|
||||
deletedMembers.push(result);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deletedCount: deletedMembers.length,
|
||||
deletedMembers
|
||||
};
|
||||
}
|
||||
|
||||
_selectLatestImage(images) {
|
||||
if (!Array.isArray(images) || images.length === 0) {
|
||||
return null;
|
||||
@@ -1786,6 +1861,91 @@ class MemberService {
|
||||
.replace(/\"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
async _deleteMemberWithRelations(member) {
|
||||
const sequelize = Member.sequelize;
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
const {
|
||||
default: DiaryMemberActivity
|
||||
} = await import('../models/DiaryMemberActivity.js');
|
||||
const {
|
||||
default: DiaryMemberNote
|
||||
} = await import('../models/DiaryMemberNote.js');
|
||||
const {
|
||||
default: DiaryMemberTag
|
||||
} = await import('../models/DiaryMemberTag.js');
|
||||
const {
|
||||
default: DiaryNote
|
||||
} = await import('../models/DiaryNote.js');
|
||||
const {
|
||||
default: MemberNote
|
||||
} = await import('../models/MemberNote.js');
|
||||
const {
|
||||
default: MemberContact
|
||||
} = await import('../models/MemberContact.js');
|
||||
const {
|
||||
default: MemberTrainingGroup
|
||||
} = await import('../models/MemberTrainingGroup.js');
|
||||
const {
|
||||
default: TournamentMember
|
||||
} = await import('../models/TournamentMember.js');
|
||||
const {
|
||||
default: OfficialCompetitionMember
|
||||
} = await import('../models/OfficialCompetitionMember.js');
|
||||
const {
|
||||
default: Accident
|
||||
} = await import('../models/Accident.js');
|
||||
|
||||
const participantIds = (await Participant.findAll({
|
||||
where: { memberId: member.id },
|
||||
attributes: ['id'],
|
||||
raw: true,
|
||||
transaction
|
||||
})).map((participant) => participant.id);
|
||||
|
||||
if (participantIds.length > 0) {
|
||||
await DiaryMemberActivity.destroy({
|
||||
where: { participantId: { [Op.in]: participantIds } },
|
||||
transaction
|
||||
});
|
||||
}
|
||||
|
||||
await DiaryMemberNote.destroy({ where: { memberId: member.id }, transaction });
|
||||
await DiaryMemberTag.destroy({ where: { memberId: member.id }, transaction });
|
||||
await DiaryNote.destroy({ where: { memberId: member.id }, transaction });
|
||||
await MemberNote.destroy({ where: { memberId: member.id }, transaction });
|
||||
await MemberTtrHistory.destroy({ where: { memberId: member.id }, transaction });
|
||||
await MemberContact.destroy({ where: { memberId: member.id }, transaction });
|
||||
await MemberTrainingGroup.destroy({ where: { memberId: member.id }, transaction });
|
||||
await OfficialCompetitionMember.destroy({ where: { memberId: member.id }, transaction });
|
||||
await Accident.destroy({ where: { memberId: member.id }, transaction });
|
||||
await TournamentMember.destroy({ where: { clubMemberId: member.id }, transaction });
|
||||
await Participant.destroy({ where: { memberId: member.id }, transaction });
|
||||
await MemberImage.destroy({ where: { memberId: member.id }, transaction });
|
||||
await Member.destroy({ where: { id: member.id }, transaction });
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
const imageDirectory = path.join('images', 'members', String(member.id));
|
||||
try {
|
||||
await fs.promises.rm(imageDirectory, { recursive: true, force: true });
|
||||
} catch (fileError) {
|
||||
console.warn('[cleanupInactiveMembersWithoutRecentTraining] - Failed to remove image directory:', fileError.message);
|
||||
}
|
||||
|
||||
return {
|
||||
id: member.id,
|
||||
clubId: member.clubId,
|
||||
firstName: member.firstName,
|
||||
lastName: member.lastName
|
||||
};
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new MemberService();
|
||||
|
||||
@@ -2,6 +2,7 @@ import cron from 'node-cron';
|
||||
import autoUpdateRatingsService from './autoUpdateRatingsService.js';
|
||||
import autoFetchMatchResultsService from './autoFetchMatchResultsService.js';
|
||||
import apiLogService from './apiLogService.js';
|
||||
import memberService from './memberService.js';
|
||||
import { devLog } from '../utils/logger.js';
|
||||
|
||||
class SchedulerService {
|
||||
@@ -38,6 +39,20 @@ class SchedulerService {
|
||||
}
|
||||
}
|
||||
|
||||
async runInactiveMemberCleanupJob(isAutomatic = true) {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const result = await memberService.cleanupInactiveMembersWithoutRecentTraining({ inactiveDays: 365 });
|
||||
const executionTime = Date.now() - startTime;
|
||||
await apiLogService.logSchedulerExecution('member_cleanup', true, result || { success: true }, executionTime, null);
|
||||
return { success: true, result, executionTime, isAutomatic };
|
||||
} catch (error) {
|
||||
const executionTime = Date.now() - startTime;
|
||||
await apiLogService.logSchedulerExecution('member_cleanup', false, { success: false }, executionTime, error?.message || String(error));
|
||||
return { success: false, error: error?.message || String(error), executionTime, isAutomatic };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the scheduler
|
||||
*/
|
||||
@@ -65,8 +80,16 @@ class SchedulerService {
|
||||
timezone: 'Europe/Berlin'
|
||||
});
|
||||
|
||||
const inactiveMemberCleanupJob = cron.schedule('45 6 * * *', async () => {
|
||||
devLog('[Scheduler] Running inactive member cleanup job...');
|
||||
await this.runInactiveMemberCleanupJob(true);
|
||||
}, {
|
||||
timezone: 'Europe/Berlin'
|
||||
});
|
||||
|
||||
this.jobs.set('ratingUpdates', ratingUpdateJob);
|
||||
this.jobs.set('matchResults', matchResultsJob);
|
||||
this.jobs.set('inactiveMemberCleanup', inactiveMemberCleanupJob);
|
||||
|
||||
this.isRunning = true;
|
||||
const now = new Date();
|
||||
|
||||
@@ -54,112 +54,134 @@
|
||||
{{ membersLoadError }}
|
||||
</div>
|
||||
|
||||
<div v-if="selectedMemberPreview" class="member-preview-panel">
|
||||
<div class="member-preview-header">
|
||||
<div>
|
||||
<div class="member-preview-label">{{ $t('members.memberDetails') }}</div>
|
||||
<h3 class="member-preview-name">{{ selectedMemberPreview.firstName }} {{ selectedMemberPreview.lastName }}</h3>
|
||||
</div>
|
||||
<div class="member-preview-actions">
|
||||
<button type="button" class="btn-primary" @click="editMember(selectedMemberPreview)">{{ $t('members.editMember') }}</button>
|
||||
<button type="button" @click="openImageModal(selectedMemberPreview)">{{ $t('members.memberImages') }}</button>
|
||||
<button type="button" @click="openNotesModal(selectedMemberPreview)">{{ $t('members.notes') }}</button>
|
||||
<button type="button" @click="openActivitiesModal(selectedMemberPreview)">{{ $t('members.exercises') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="member-preview-grid">
|
||||
<div class="member-preview-item">
|
||||
<span class="member-preview-item-label">{{ $t('members.status') }}</span>
|
||||
<div class="member-status-stack">
|
||||
<span
|
||||
v-for="badge in getMemberStatusBadges(selectedMemberPreview)"
|
||||
:key="badge.key"
|
||||
class="member-status-badge"
|
||||
:class="`member-status-badge-${badge.tone}`"
|
||||
>
|
||||
{{ badge.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="member-preview-item">
|
||||
<span class="member-preview-item-label">{{ $t('members.contact') }}</span>
|
||||
<div>{{ getFormattedPhoneNumbers(selectedMemberPreview) }}</div>
|
||||
<div>{{ getFormattedEmails(selectedMemberPreview) }}</div>
|
||||
<div class="member-preview-muted">{{ getCompactAddress(selectedMemberPreview) || $t('members.noAddressShort') }}</div>
|
||||
</div>
|
||||
<div class="member-preview-item">
|
||||
<span class="member-preview-item-label">{{ $t('members.birthdate') }}</span>
|
||||
<div>{{ getFormattedBirthdate(selectedMemberPreview.birthDate) }}</div>
|
||||
</div>
|
||||
<div class="member-preview-item">
|
||||
<span class="member-preview-item-label">{{ $t('members.previewLastTraining') }}</span>
|
||||
<div>{{ getOptionalFormattedDate(selectedMemberPreview.lastTraining, 'members.previewNoLastTraining') }}</div>
|
||||
<div class="member-preview-muted" v-if="selectedMemberPreview.notInTraining">{{ $t('members.notInTrainingTooltip', { weeks: selectedMemberPreview.missedTrainingWeeks || 0 }) }}</div>
|
||||
</div>
|
||||
<div class="member-preview-item">
|
||||
<span class="member-preview-item-label">{{ $t('members.trainingGroups') }}</span>
|
||||
<div v-if="selectedPreviewTrainingGroups.length" class="member-preview-tags">
|
||||
<span
|
||||
v-for="group in selectedPreviewTrainingGroups"
|
||||
:key="group.id"
|
||||
class="member-preview-tag"
|
||||
>
|
||||
{{ group.name }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="member-preview-muted">{{ $t('members.noGroupsAssigned') }}</div>
|
||||
</div>
|
||||
<div class="member-preview-item">
|
||||
<span class="member-preview-item-label">{{ $t('members.dataQuality') }}</span>
|
||||
<div v-if="getMemberDataQualityIssues(selectedMemberPreview).length" class="member-preview-tags">
|
||||
<span
|
||||
v-for="issue in getMemberDataQualityIssues(selectedMemberPreview)"
|
||||
:key="issue.key"
|
||||
class="member-preview-tag member-preview-tag-warning"
|
||||
>
|
||||
{{ issue.label }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="member-preview-muted">{{ $t('members.dataQualityComplete') }}</div>
|
||||
</div>
|
||||
<div class="member-preview-item">
|
||||
<span class="member-preview-item-label">{{ $t('members.openTasks') }}</span>
|
||||
<div v-if="getMemberOpenTasks(selectedMemberPreview).length" class="member-preview-tasklist">
|
||||
<div
|
||||
v-for="task in getMemberOpenTasks(selectedMemberPreview)"
|
||||
:key="task.key"
|
||||
class="member-preview-task"
|
||||
>
|
||||
<span>{{ task.label }}</span>
|
||||
<button
|
||||
v-if="task.action"
|
||||
type="button"
|
||||
class="member-preview-task-action"
|
||||
@click="runPreviewTask(task.action, selectedMemberPreview)"
|
||||
>
|
||||
{{ task.actionLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="member-preview-muted">{{ $t('members.noOpenTasks') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="newmember" v-if="memberFormIsOpen">
|
||||
<div class="toggle-new-member">
|
||||
<div class="member-editor-heading">
|
||||
<span class="add">{{ memberToEdit === null ? '+' : '✎' }}</span>
|
||||
<BaseDialog
|
||||
:model-value="!!selectedMemberPreview"
|
||||
title="Mitgliedsdetails"
|
||||
size="large"
|
||||
:width="1080"
|
||||
max-width="95vw"
|
||||
@update:model-value="!$event && closeMemberPreviewDialog()"
|
||||
@close="closeMemberPreviewDialog"
|
||||
>
|
||||
<div v-if="selectedMemberPreview" class="member-preview-panel">
|
||||
<div class="member-preview-header">
|
||||
<div>
|
||||
<div class="member-editor-title">{{ memberToEdit === null ? $t('members.newMember') : $t('members.editMember') }}</div>
|
||||
<div class="member-editor-subtitle">
|
||||
{{ memberToEdit === null ? $t('members.editorCreateHint') : $t('members.editorEditHint', { name: `${memberToEdit.firstName} ${memberToEdit.lastName}` }) }}
|
||||
</div>
|
||||
<div class="member-preview-label">{{ $t('members.memberDetails') }}</div>
|
||||
<h3 class="member-preview-name">{{ selectedMemberPreview.firstName }} {{ selectedMemberPreview.lastName }}</h3>
|
||||
</div>
|
||||
<div class="member-preview-actions">
|
||||
<button type="button" class="btn-primary" @click="editMember(selectedMemberPreview)">{{ $t('members.editMember') }}</button>
|
||||
<button type="button" @click="openImageModal(selectedMemberPreview)">{{ $t('members.memberImages') }}</button>
|
||||
<button type="button" @click="openNotesModal(selectedMemberPreview)">{{ $t('members.notes') }}</button>
|
||||
<button type="button" @click="openActivitiesModal(selectedMemberPreview)">{{ $t('members.exercises') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="member-preview-grid">
|
||||
<div class="member-preview-item">
|
||||
<span class="member-preview-item-label">{{ $t('members.status') }}</span>
|
||||
<div class="member-status-stack">
|
||||
<span
|
||||
v-for="badge in getMemberStatusBadges(selectedMemberPreview)"
|
||||
:key="badge.key"
|
||||
class="member-status-badge"
|
||||
:class="`member-status-badge-${badge.tone}`"
|
||||
>
|
||||
{{ badge.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="member-preview-item">
|
||||
<span class="member-preview-item-label">{{ $t('members.contact') }}</span>
|
||||
<div>{{ getFormattedPhoneNumbers(selectedMemberPreview) }}</div>
|
||||
<div>{{ getFormattedEmails(selectedMemberPreview) }}</div>
|
||||
<div class="member-preview-muted">{{ getCompactAddress(selectedMemberPreview) || $t('members.noAddressShort') }}</div>
|
||||
</div>
|
||||
<div class="member-preview-item">
|
||||
<span class="member-preview-item-label">{{ $t('members.birthdate') }}</span>
|
||||
<div>{{ getFormattedBirthdate(selectedMemberPreview.birthDate) }}</div>
|
||||
</div>
|
||||
<div class="member-preview-item">
|
||||
<span class="member-preview-item-label">{{ $t('members.previewLastTraining') }}</span>
|
||||
<div>{{ getOptionalFormattedDate(selectedMemberPreview.lastTraining, 'members.previewNoLastTraining') }}</div>
|
||||
<div class="member-preview-muted" v-if="selectedMemberPreview.notInTraining">{{ $t('members.notInTrainingTooltip', { weeks: selectedMemberPreview.missedTrainingWeeks || 0 }) }}</div>
|
||||
</div>
|
||||
<div class="member-preview-item">
|
||||
<span class="member-preview-item-label">{{ $t('members.trainingGroups') }}</span>
|
||||
<div v-if="selectedPreviewTrainingGroups.length" class="member-preview-tags">
|
||||
<span
|
||||
v-for="group in selectedPreviewTrainingGroups"
|
||||
:key="group.id"
|
||||
class="member-preview-tag"
|
||||
>
|
||||
{{ group.name }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="member-preview-muted">{{ $t('members.noGroupsAssigned') }}</div>
|
||||
</div>
|
||||
<div class="member-preview-item">
|
||||
<span class="member-preview-item-label">{{ $t('members.dataQuality') }}</span>
|
||||
<div v-if="getMemberDataQualityIssues(selectedMemberPreview).length" class="member-preview-tags">
|
||||
<span
|
||||
v-for="issue in getMemberDataQualityIssues(selectedMemberPreview)"
|
||||
:key="issue.key"
|
||||
class="member-preview-tag member-preview-tag-warning"
|
||||
>
|
||||
{{ issue.label }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="member-preview-muted">{{ $t('members.dataQualityComplete') }}</div>
|
||||
</div>
|
||||
<div class="member-preview-item">
|
||||
<span class="member-preview-item-label">{{ $t('members.openTasks') }}</span>
|
||||
<div v-if="getMemberOpenTasks(selectedMemberPreview).length" class="member-preview-tasklist">
|
||||
<div
|
||||
v-for="task in getMemberOpenTasks(selectedMemberPreview)"
|
||||
:key="task.key"
|
||||
class="member-preview-task"
|
||||
>
|
||||
<span>{{ task.label }}</span>
|
||||
<button
|
||||
v-if="task.action"
|
||||
type="button"
|
||||
class="member-preview-task-action"
|
||||
@click="runPreviewTask(task.action, selectedMemberPreview)"
|
||||
>
|
||||
{{ task.actionLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="member-preview-muted">{{ $t('members.noOpenTasks') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="memberToEdit !== null" @click="resetToNewMember">{{ $t('members.createNewMember') }}</button>
|
||||
</div>
|
||||
<div class="new-member-form">
|
||||
<template #footer>
|
||||
<button type="button" class="cancel-action" @click="closeMemberPreviewDialog">{{ $t('common.close') }}</button>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<BaseDialog
|
||||
:model-value="memberFormIsOpen"
|
||||
:title="memberToEdit === null ? $t('members.newMember') : $t('members.editMember')"
|
||||
size="large"
|
||||
:width="1120"
|
||||
max-width="95vw"
|
||||
@update:model-value="!$event && closeMemberFormDialog()"
|
||||
@close="closeMemberFormDialog"
|
||||
>
|
||||
<div class="newmember" v-if="memberFormIsOpen">
|
||||
<div class="toggle-new-member">
|
||||
<div class="member-editor-heading">
|
||||
<span class="add">{{ memberToEdit === null ? '+' : '✎' }}</span>
|
||||
<div>
|
||||
<div class="member-editor-title">{{ memberToEdit === null ? $t('members.newMember') : $t('members.editMember') }}</div>
|
||||
<div class="member-editor-subtitle">
|
||||
{{ memberToEdit === null ? $t('members.editorCreateHint') : $t('members.editorEditHint', { name: `${memberToEdit.firstName} ${memberToEdit.lastName}` }) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="memberToEdit !== null" @click="resetToNewMember">{{ $t('members.createNewMember') }}</button>
|
||||
</div>
|
||||
<div class="new-member-form">
|
||||
<label><span>{{ $t('members.firstName') }}:</span> <input type="text" v-model="newFirstname"></label>
|
||||
<label><span>{{ $t('members.lastName') }}:</span> <input type="text" v-model="newLastname"></label>
|
||||
<label><span>{{ $t('members.street') }}:</span> <input type="text" v-model="newStreet"></label>
|
||||
@@ -268,8 +290,9 @@
|
||||
<button @click="addNewMember">{{ memberToEdit ? $t('members.change') : $t('members.create') }}</button>
|
||||
<button @click="resetNewMember" v-if="memberToEdit === null" class="cancel-action">{{ $t('members.clearFields') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
<div class="members-table-panel">
|
||||
<div class="members-table-wrap">
|
||||
<table>
|
||||
@@ -953,6 +976,10 @@ export default {
|
||||
this.selectedPreviewTrainingGroups = [];
|
||||
}
|
||||
},
|
||||
closeMemberPreviewDialog() {
|
||||
this.selectedMemberPreview = null;
|
||||
this.selectedPreviewTrainingGroups = [];
|
||||
},
|
||||
async quickRemoveTestMembershipInternal(member, { silent = false } = {}) {
|
||||
const response = await apiClient.post(`/clubmembers/quick-update-test-membership/${this.currentClub}/${member.id}`);
|
||||
if (!response.data.success) {
|
||||
@@ -1211,6 +1238,10 @@ export default {
|
||||
this.resetToNewMember();
|
||||
}
|
||||
},
|
||||
closeMemberFormDialog() {
|
||||
this.memberFormIsOpen = false;
|
||||
this.resetToNewMember();
|
||||
},
|
||||
|
||||
toggleMemberInfo() {
|
||||
this.showMemberInfo = !this.showMemberInfo;
|
||||
@@ -1482,6 +1513,7 @@ export default {
|
||||
},
|
||||
async editMember(member) {
|
||||
const birthDate = member.birthDate;
|
||||
this.closeMemberPreviewDialog();
|
||||
this.memberToEdit = member;
|
||||
this.memberFormIsOpen = true;
|
||||
this.newFirstname = member.firstName;
|
||||
|
||||
Reference in New Issue
Block a user