Add member transfer functionality to memberController and update routes and UI

Implemented a new transferMembers function in memberController to handle member transfers, including validation for transfer endpoint and template. Updated memberRoutes to include a new route for member transfers. Enhanced MembersView with a button to open a transfer dialog and integrated a MemberTransferDialog component for user interaction during the transfer process.
This commit is contained in:
Torsten Schulz (local)
2025-11-05 14:33:09 +01:00
parent 5bdcd946cf
commit 5bba9522b3
5 changed files with 1207 additions and 54 deletions

View File

@@ -1,4 +1,5 @@
import MemberService from "../services/memberService.js";
import MemberTransferService from "../services/memberTransferService.js";
import { devLog } from '../utils/logger.js';
const getClubMembers = async(req, res) => {
@@ -99,4 +100,36 @@ const rotateMemberImage = async (req, res) => {
}
};
export { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis, rotateMemberImage };
const transferMembers = async (req, res) => {
try {
const { id: clubId } = req.params;
const { authcode: userToken } = req.headers;
const config = req.body;
// Validierung
if (!config.transferEndpoint) {
return res.status(400).json({
success: false,
error: 'Übertragungs-Endpoint ist erforderlich'
});
}
if (!config.transferTemplate) {
return res.status(400).json({
success: false,
error: 'Übertragungs-Template ist erforderlich'
});
}
const result = await MemberTransferService.transferMembers(userToken, clubId, config);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[transferMembers] - Error:', error);
res.status(500).json({
success: false,
error: 'Fehler bei der Übertragung: ' + error.message
});
}
};
export { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis, rotateMemberImage, transferMembers };

View File

@@ -1,4 +1,4 @@
import { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis, rotateMemberImage } from '../controllers/memberController.js';
import { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis, rotateMemberImage, transferMembers } from '../controllers/memberController.js';
import express from 'express';
import { authenticate } from '../middleware/authMiddleware.js';
import { authorize } from '../middleware/authorizationMiddleware.js';
@@ -16,5 +16,6 @@ router.post('/set/:id', authenticate, authorize('members', 'write'), setClubMemb
router.get('/notapproved/:id', authenticate, authorize('members', 'read'), getWaitingApprovals);
router.post('/update-ratings/:id', authenticate, authorize('mytischtennis', 'write'), updateRatingsFromMyTischtennis);
router.post('/rotate-image/:clubId/:memberId', authenticate, authorize('members', 'write'), rotateMemberImage);
router.post('/transfer/:id', authenticate, authorize('members', 'write'), transferMembers);
export default router;

View File

@@ -0,0 +1,561 @@
import axios from 'axios';
import Member from '../models/Member.js';
import { checkAccess, getUserByToken } from '../utils/userUtils.js';
import { encryptData, decryptData } from '../utils/encrypt.js';
import { devLog } from '../utils/logger.js';
class MemberTransferService {
/**
* Überträgt alle Mitglieder mit testMembership = false an einen externen Endpoint
*
* @param {string} userToken - User Token für Authentifizierung
* @param {number} clubId - Club ID
* @param {Object} config - Übertragungskonfiguration
* @param {string} config.loginEndpoint - Login-Endpoint URL
* @param {string} config.loginFormat - Format für Login (json, form-data, x-www-form-urlencoded)
* @param {Object} config.loginCredentials - Login-Daten (z.B. { username: '...', password: '...' })
* @param {string} config.transferEndpoint - Übertragungs-Endpoint URL
* @param {string} config.transferMethod - HTTP-Methode (POST, PUT, GET)
* @param {string} config.transferFormat - Format für Übertragung (json, xml, form-data, x-www-form-urlencoded)
* @param {string} config.transferTemplate - Template mit Platzhaltern (z.B. '{"name": "{{firstName}} {{lastName}}", "email": "{{email}}"}')
* @param {boolean} config.useBulkMode - Wenn true, werden alle Mitglieder in einem Bulk-Request übertragen (als {members: [...]})
* @returns {Promise<Object>} Ergebnis der Übertragung
*/
async transferMembers(userToken, clubId, config) {
try {
await checkAccess(userToken, clubId);
// 1. Alle Mitglieder mit testMembership = false laden
const members = await Member.findAll({
where: {
clubId: clubId,
testMembership: false
}
});
if (members.length === 0) {
return {
status: 200,
response: {
success: true,
message: 'Keine Mitglieder zum Übertragen gefunden',
transferred: 0,
errors: []
}
};
}
// 2. Login durchführen
let authToken = null;
let sessionCookie = null;
if (config.loginEndpoint && config.loginCredentials) {
try {
const loginResult = await this.performLogin(
config.loginEndpoint,
config.loginFormat || 'json',
config.loginCredentials
);
if (!loginResult.success) {
return {
status: 401,
response: {
success: false,
message: 'Login fehlgeschlagen',
error: loginResult.error,
transferred: 0,
errors: []
}
};
}
authToken = loginResult.token;
sessionCookie = loginResult.cookie;
} catch (loginError) {
devLog('[transferMembers] Login error:', loginError);
return {
status: 401,
response: {
success: false,
message: 'Login fehlgeschlagen: ' + loginError.message,
transferred: 0,
errors: []
}
};
}
}
// 3. Mitglieder übertragen
const useBulkMode = config.useBulkMode === true;
if (useBulkMode) {
// Bulk-Import: Alle Mitglieder auf einmal übertragen
try {
const membersArray = [];
const memberErrors = [];
for (const member of members) {
try {
const memberData = this.replacePlaceholders(
config.transferTemplate,
member
);
membersArray.push(memberData);
} catch (error) {
devLog(`[transferMembers] Error processing ${member.firstName} ${member.lastName}:`, error);
memberErrors.push({
member: `${member.firstName} ${member.lastName}`,
error: error.message
});
}
}
// Bulk-Objekt erstellen
const bulkData = {
members: membersArray
};
const transferResult = await this.transferMember(
config.transferEndpoint,
config.transferMethod || 'POST',
config.transferFormat || 'json',
bulkData,
authToken,
sessionCookie
);
if (transferResult.success) {
const summary = transferResult.data?.summary || {};
const transferred = summary.imported || membersArray.length;
const bulkErrors = transferResult.data?.results?.errors || [];
const bulkDuplicates = transferResult.data?.results?.duplicates || [];
let message = `${transferred} von ${members.length} Mitglieder(n) erfolgreich übertragen.`;
if (bulkDuplicates.length > 0) {
message += ` ${bulkDuplicates.length} Duplikat(e) gefunden.`;
}
if (bulkErrors.length > 0 || memberErrors.length > 0) {
message += ` ${bulkErrors.length + memberErrors.length} Fehler.`;
}
return {
status: 200,
response: {
success: true,
message: message,
transferred: transferred,
total: members.length,
duplicates: bulkDuplicates.length,
errors: [...bulkErrors, ...memberErrors],
details: transferResult.data
}
};
} else {
return {
status: transferResult.status || 500,
response: {
success: false,
message: 'Bulk-Übertragung fehlgeschlagen',
error: transferResult.error,
transferred: 0,
total: members.length,
errors: memberErrors
}
};
}
} catch (error) {
devLog('[transferMembers] Bulk transfer error:', error);
return {
status: 500,
response: {
success: false,
message: 'Fehler bei der Bulk-Übertragung: ' + error.message,
transferred: 0,
total: members.length,
errors: []
}
};
}
} else {
// Einzelne Übertragung: Jedes Mitglied einzeln übertragen
const results = [];
const errors = [];
let successCount = 0;
for (const member of members) {
try {
const memberData = this.replacePlaceholders(
config.transferTemplate,
member
);
const transferResult = await this.transferMember(
config.transferEndpoint,
config.transferMethod || 'POST',
config.transferFormat || 'json',
memberData,
authToken,
sessionCookie
);
if (transferResult.success) {
successCount++;
results.push({
member: `${member.firstName} ${member.lastName}`,
success: true
});
} else {
errors.push({
member: `${member.firstName} ${member.lastName}`,
error: transferResult.error
});
}
} catch (error) {
devLog(`[transferMembers] Error transferring ${member.firstName} ${member.lastName}:`, error);
errors.push({
member: `${member.firstName} ${member.lastName}`,
error: error.message
});
}
}
const message = `${successCount} von ${members.length} Mitglieder(n) erfolgreich übertragen.`;
return {
status: 200,
response: {
success: true,
message: message,
transferred: successCount,
total: members.length,
results: results,
errors: errors
}
};
}
} catch (error) {
devLog('[transferMembers] Error:', error);
return {
status: 500,
response: {
success: false,
message: 'Fehler bei der Übertragung: ' + error.message,
transferred: 0,
errors: [error.message]
}
};
}
}
/**
* Führt Login am externen Endpoint durch
*/
async performLogin(endpoint, format, credentials) {
try {
let requestConfig = {
url: endpoint,
method: 'POST',
timeout: 10000,
maxRedirects: 5,
validateStatus: (status) => status >= 200 && status < 400
};
// Headers und Daten je nach Format setzen
if (format === 'json' || format === 'application/json') {
requestConfig.headers = {
'Content-Type': 'application/json'
};
requestConfig.data = credentials;
} else if (format === 'form-data' || format === 'multipart/form-data') {
const FormData = (await import('form-data')).default;
const formData = new FormData();
for (const [key, value] of Object.entries(credentials)) {
formData.append(key, value);
}
requestConfig.headers = formData.getHeaders();
requestConfig.data = formData;
} else if (format === 'x-www-form-urlencoded' || format === 'urlencoded') {
requestConfig.headers = {
'Content-Type': 'application/x-www-form-urlencoded'
};
const params = new URLSearchParams();
for (const [key, value] of Object.entries(credentials)) {
params.append(key, value);
}
requestConfig.data = params.toString();
} else {
// Default: JSON
requestConfig.headers = {
'Content-Type': 'application/json'
};
requestConfig.data = credentials;
}
const response = await axios(requestConfig);
// Versuche Token/Cookie aus Response zu extrahieren
// Typische Felder: token, accessToken, authToken, cookie, session
const token = response.data?.token ||
response.data?.accessToken ||
response.data?.authToken ||
response.headers['authorization'] ||
response.headers['x-auth-token'];
const cookie = response.headers['set-cookie']?.[0] ||
response.data?.cookie ||
response.data?.session;
return {
success: true,
token: token,
cookie: cookie,
response: response.data
};
} catch (error) {
devLog('[performLogin] Error:', error);
return {
success: false,
error: error.response?.data?.message || error.message || 'Login fehlgeschlagen'
};
}
}
/**
* Überträgt ein einzelnes Mitglied
*/
async transferMember(endpoint, method, format, data, authToken, sessionCookie) {
try {
let requestConfig = {
url: endpoint,
method: method.toUpperCase(),
timeout: 10000,
maxRedirects: 5
};
// Auth-Header setzen falls vorhanden
if (authToken) {
requestConfig.headers = {
...requestConfig.headers,
'Authorization': `Bearer ${authToken}`,
'X-Auth-Token': authToken
};
}
// Cookie setzen falls vorhanden
if (sessionCookie) {
requestConfig.headers = {
...requestConfig.headers,
'Cookie': sessionCookie
};
}
// Daten je nach Format setzen
if (format === 'json' || format === 'application/json') {
requestConfig.headers = {
...requestConfig.headers,
'Content-Type': 'application/json'
};
requestConfig.data = typeof data === 'string' ? JSON.parse(data) : data;
} else if (format === 'xml' || format === 'application/xml' || format === 'text/xml') {
requestConfig.headers = {
...requestConfig.headers,
'Content-Type': 'application/xml'
};
requestConfig.data = data; // XML als String
} else if (format === 'form-data' || format === 'multipart/form-data') {
const FormData = (await import('form-data')).default;
const formData = new FormData();
// Wenn data ein Objekt ist, alle Felder hinzufügen
if (typeof data === 'object') {
for (const [key, value] of Object.entries(data)) {
formData.append(key, value);
}
} else {
// Wenn data ein String ist, versuche es zu parsen
try {
const parsed = JSON.parse(data);
for (const [key, value] of Object.entries(parsed)) {
formData.append(key, value);
}
} catch {
// Fallback: als Text hinzufügen
formData.append('data', data);
}
}
requestConfig.headers = {
...formData.getHeaders(),
...requestConfig.headers
};
requestConfig.data = formData;
} else if (format === 'x-www-form-urlencoded' || format === 'urlencoded') {
requestConfig.headers = {
...requestConfig.headers,
'Content-Type': 'application/x-www-form-urlencoded'
};
const params = new URLSearchParams();
if (typeof data === 'object') {
for (const [key, value] of Object.entries(data)) {
params.append(key, value);
}
} else {
// Versuche JSON zu parsen
try {
const parsed = JSON.parse(data);
for (const [key, value] of Object.entries(parsed)) {
params.append(key, value);
}
} catch {
params.append('data', data);
}
}
requestConfig.data = params.toString();
} else {
// Default: JSON
requestConfig.headers = {
...requestConfig.headers,
'Content-Type': 'application/json'
};
requestConfig.data = typeof data === 'string' ? JSON.parse(data) : data;
}
const response = await axios(requestConfig);
return {
success: response.status >= 200 && response.status < 300,
status: response.status,
data: response.data
};
} catch (error) {
devLog('[transferMember] Error:', error);
return {
success: false,
error: error.response?.data?.message || error.message || 'Übertragung fehlgeschlagen',
status: error.response?.status
};
}
}
/**
* Ersetzt Platzhalter im Template mit Mitgliedsdaten
* Unterstützte Platzhalter: {{firstName}}, {{lastName}}, {{email}}, {{phone}},
* {{street}}, {{city}}, {{birthDate}}, {{ttr}}, {{qttr}}, {{gender}}
*/
replacePlaceholders(template, member) {
if (!template) {
return {};
}
// Wenn Template ein JSON-String ist, parsen
let parsedTemplate;
try {
parsedTemplate = JSON.parse(template);
} catch {
// Wenn kein JSON, als einfachen String behandeln
let result = template;
// Alle verfügbaren Platzhalter ersetzen
const placeholders = {
'{{firstName}}': member.firstName || '',
'{{lastName}}': member.lastName || '',
'{{email}}': member.email || '',
'{{phone}}': member.phone || '',
'{{street}}': member.street || '',
'{{city}}': member.city || '',
'{{birthDate}}': this.formatBirthDate(member.birthDate) || '',
'{{geburtsdatum}}': this.formatBirthDate(member.birthDate) || '',
'{{address}}': `${member.street || ''}, ${member.city || ''}`.trim().replace(/^,\s*|,\s*$/g, ''),
'{{ttr}}': member.ttr || '',
'{{qttr}}': member.qttr || '',
'{{gender}}': member.gender || '',
'{{fullName}}': `${member.firstName || ''} ${member.lastName || ''}`.trim()
};
for (const [placeholder, value] of Object.entries(placeholders)) {
result = result.replace(new RegExp(placeholder.replace(/[{}]/g, '\\$&'), 'g'), value);
}
return result;
}
// Wenn es ein Objekt ist, rekursiv Platzhalter ersetzen
const replaceInObject = (obj) => {
if (typeof obj === 'string') {
let result = obj;
const placeholders = {
'{{firstName}}': member.firstName || '',
'{{lastName}}': member.lastName || '',
'{{email}}': member.email || '',
'{{phone}}': member.phone || '',
'{{street}}': member.street || '',
'{{city}}': member.city || '',
'{{birthDate}}': this.formatBirthDate(member.birthDate) || '',
'{{geburtsdatum}}': this.formatBirthDate(member.birthDate) || '',
'{{address}}': `${member.street || ''}, ${member.city || ''}`.trim().replace(/^,\s*|,\s*$/g, ''),
'{{ttr}}': member.ttr || '',
'{{qttr}}': member.qttr || '',
'{{gender}}': member.gender || '',
'{{fullName}}': `${member.firstName || ''} ${member.lastName || ''}`.trim()
};
for (const [placeholder, value] of Object.entries(placeholders)) {
result = result.replace(new RegExp(placeholder.replace(/[{}]/g, '\\$&'), 'g'), String(value));
}
return result;
} else if (Array.isArray(obj)) {
return obj.map(item => replaceInObject(item));
} else if (obj && typeof obj === 'object') {
const result = {};
for (const [key, value] of Object.entries(obj)) {
result[replaceInObject(key)] = replaceInObject(value);
}
return result;
}
return obj;
};
return replaceInObject(parsedTemplate);
}
/**
* Formatiert Geburtsdatum in YYYY-MM-DD Format
*/
formatBirthDate(birthDate) {
if (!birthDate) return '';
// Wenn bereits im richtigen Format
if (typeof birthDate === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(birthDate)) {
return birthDate;
}
// Versuche zu parsen
try {
const date = new Date(birthDate);
if (!isNaN(date.getTime())) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
} catch (error) {
// Fallback: Versuche Format dd.mm.yyyy zu parsen
const match = String(birthDate).match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
if (match) {
const day = match[1].padStart(2, '0');
const month = match[2].padStart(2, '0');
const year = match[3];
return `${year}-${month}-${day}`;
}
}
return birthDate || '';
}
}
export default new MemberTransferService();

View File

@@ -0,0 +1,528 @@
<template>
<BaseDialog
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
title="Mitglieder übertragen"
size="large"
:close-on-overlay="false"
@close="handleClose"
>
<div class="transfer-form">
<div class="form-section">
<h4>Login-Konfiguration</h4>
<div class="form-group">
<label for="loginEndpoint">Login-Endpoint URL:</label>
<input
type="text"
id="loginEndpoint"
v-model="config.loginEndpoint"
placeholder="https://example.com/api/login"
class="form-input"
/>
<span class="hint">Optional: Falls für die Übertragung ein Login erforderlich ist</span>
</div>
<div class="form-group">
<label for="loginFormat">Login-Format:</label>
<select id="loginFormat" v-model="config.loginFormat" class="form-select">
<option value="json">JSON</option>
<option value="form-data">Form Data</option>
<option value="x-www-form-urlencoded">URL Encoded</option>
</select>
</div>
<div class="form-group">
<label>Login-Daten:</label>
<div class="credentials-group">
<div class="credential-row">
<input
type="text"
v-model="loginCredentials.username"
placeholder="Benutzername / Email"
class="form-input"
/>
<input
type="password"
v-model="loginCredentials.password"
placeholder="Passwort"
class="form-input"
/>
</div>
<div class="credential-row">
<input
type="text"
v-model="loginCredentials.additionalField1"
:placeholder="additionalField1Placeholder"
class="form-input"
/>
<input
type="text"
v-model="loginCredentials.additionalField2"
:placeholder="additionalField2Placeholder"
class="form-input"
/>
</div>
</div>
<span class="hint">Zusätzliche Felder werden nur verwendet, wenn sie ausgefüllt sind</span>
</div>
</div>
<div class="form-section">
<h4>Übertragungs-Konfiguration</h4>
<div class="form-group">
<label for="transferEndpoint">Übertragungs-Endpoint URL: <span class="required">*</span></label>
<input
type="text"
id="transferEndpoint"
v-model="config.transferEndpoint"
placeholder="https://example.com/api/members"
class="form-input"
required
/>
</div>
<div class="form-row">
<div class="form-group">
<label for="transferMethod">HTTP-Methode:</label>
<select id="transferMethod" v-model="config.transferMethod" class="form-select">
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="GET">GET</option>
</select>
</div>
<div class="form-group">
<label for="transferFormat">Übertragungs-Format:</label>
<select id="transferFormat" v-model="config.transferFormat" class="form-select">
<option value="json">JSON</option>
<option value="xml">XML</option>
<option value="form-data">Form Data</option>
<option value="x-www-form-urlencoded">URL Encoded</option>
</select>
</div>
</div>
<div class="form-group">
<label class="checkbox-item">
<input type="checkbox" v-model="config.useBulkMode" />
<span>Bulk-Import-Modus (alle Mitglieder auf einmal übertragen)</span>
</label>
<span class="hint">Wenn aktiviert, werden alle Mitglieder in einem Request als Array übertragen. Das Template definiert das Format für ein einzelnes Mitglied.</span>
</div>
<div class="form-group">
<label for="transferTemplate">Übertragungs-Template: <span class="required">*</span></label>
<textarea
id="transferTemplate"
v-model="config.transferTemplate"
rows="8"
class="form-textarea"
placeholder='{"name": "{{firstName}} {{lastName}}", "email": "{{email}}", "phone": "{{phone}}"}'
required
></textarea>
<div class="template-help">
<strong>Verfügbare Platzhalter:</strong>
<ul>
<li><code>{{firstName}}</code> - Vorname</li>
<li><code>{{lastName}}</code> - Nachname</li>
<li><code>{{fullName}}</code> - Vollständiger Name</li>
<li><code>{{email}}</code> - E-Mail-Adresse</li>
<li><code>{{phone}}</code> - Telefonnummer</li>
<li><code>{{street}}</code> - Straße</li>
<li><code>{{city}}</code> - Ort</li>
<li><code>{{birthDate}}</code> - Geburtsdatum (YYYY-MM-DD)</li>
<li><code>{{geburtsdatum}}</code> - Geburtsdatum (YYYY-MM-DD, alternativ)</li>
<li><code>{{address}}</code> - Kombinierte Adresse (Straße, Ort)</li>
<li><code>{{ttr}}</code> - TTR-Wert</li>
<li><code>{{qttr}}</code> - QTTR-Wert</li>
<li><code>{{gender}}</code> - Geschlecht</li>
</ul>
<p><strong>Beispiel JSON (für einzelnes Mitglied):</strong></p>
<pre>{{ jsonExample }}</pre>
<p v-if="config.useBulkMode" class="bulk-hint">
<strong>Bulk-Modus aktiv:</strong> Das Template wird für jedes Mitglied angewendet und automatisch in <code>{"members": [...]}</code> gewrappt.
</p>
</div>
</div>
</div>
</div>
<template #footer>
<button class="btn-secondary" @click="handleClose">Abbrechen</button>
<button
class="btn-primary"
@click="handleTransfer"
:disabled="!isValid || isTransferring"
>
{{ isTransferring ? 'Übertrage...' : 'Übertragen' }}
</button>
</template>
</BaseDialog>
</template>
<script>
import BaseDialog from './BaseDialog.vue';
import apiClient from '../apiClient.js';
import { mapGetters } from 'vuex';
export default {
name: 'MemberTransferDialog',
components: {
BaseDialog
},
props: {
modelValue: {
type: Boolean,
default: false
}
},
computed: {
...mapGetters(['currentClub']),
isValid() {
return !!(this.config.transferEndpoint && this.config.transferTemplate);
},
additionalField1Placeholder() {
if (this.config.loginFormat === 'form-data' || this.config.loginFormat === 'x-www-form-urlencoded') {
return 'Feldname: Wert (z.B. client_id: abc123)';
}
return 'Zusätzliches Feld (z.B. client_id)';
},
additionalField2Placeholder() {
if (this.config.loginFormat === 'form-data' || this.config.loginFormat === 'x-www-form-urlencoded') {
return 'Feldname: Wert (z.B. client_secret: xyz789)';
}
return 'Zusätzliches Feld (z.B. client_secret)';
},
jsonExample() {
return JSON.stringify({
firstName: "{{firstName}}",
lastName: "{{lastName}}",
geburtsdatum: "{{geburtsdatum}}",
email: "{{email}}",
phone: "{{phone}}",
address: "{{address}}"
}, null, 2);
}
},
data() {
return {
config: {
loginEndpoint: '',
loginFormat: 'json',
transferEndpoint: '',
transferMethod: 'POST',
transferFormat: 'json',
transferTemplate: '',
useBulkMode: false
},
loginCredentials: {
username: '',
password: '',
additionalField1: '',
additionalField2: ''
},
isTransferring: false
};
},
methods: {
handleClose() {
this.$emit('update:modelValue', false);
this.resetForm();
},
resetForm() {
this.config = {
loginEndpoint: '',
loginFormat: 'json',
transferEndpoint: '',
transferMethod: 'POST',
transferFormat: 'json',
transferTemplate: '',
useBulkMode: false
};
this.loginCredentials = {
username: '',
password: '',
additionalField1: '',
additionalField2: ''
};
this.isTransferring = false;
},
async handleTransfer() {
if (!this.isValid) {
return;
}
this.isTransferring = true;
try {
// Login-Credentials zusammenstellen
const loginCredentials = {};
if (this.loginCredentials.username) {
loginCredentials.username = this.loginCredentials.username;
}
if (this.loginCredentials.password) {
loginCredentials.password = this.loginCredentials.password;
}
// Zusätzliche Felder verarbeiten
if (this.loginCredentials.additionalField1) {
const parts1 = this.loginCredentials.additionalField1.split(':').map(s => s.trim());
if (parts1.length === 2) {
loginCredentials[parts1[0]] = parts1[1];
} else {
loginCredentials[parts1[0]] = parts1[0];
}
}
if (this.loginCredentials.additionalField2) {
const parts2 = this.loginCredentials.additionalField2.split(':').map(s => s.trim());
if (parts2.length === 2) {
loginCredentials[parts2[0]] = parts2[1];
} else {
loginCredentials[parts2[0]] = parts2[0];
}
}
// Konfiguration zusammenstellen
const transferConfig = {
transferEndpoint: this.config.transferEndpoint,
transferMethod: this.config.transferMethod,
transferFormat: this.config.transferFormat,
transferTemplate: this.config.transferTemplate
};
// Login-Konfiguration nur hinzufügen, wenn Endpoint vorhanden
if (this.config.loginEndpoint) {
transferConfig.loginEndpoint = this.config.loginEndpoint;
transferConfig.loginFormat = this.config.loginFormat;
transferConfig.loginCredentials = loginCredentials;
}
const response = await apiClient.post(
`/clubmembers/transfer/${this.currentClub}`,
transferConfig
);
if (response.data.success) {
const message = response.data.message ||
`${response.data.transferred} von ${response.data.total} Mitglieder(n) erfolgreich übertragen.`;
let details = '';
if (response.data.errors && response.data.errors.length > 0) {
details = 'Fehler:\n' + response.data.errors.map(e =>
`${e.member}: ${e.error}`
).join('\n');
}
this.$emit('success', {
message: message,
details: details,
results: response.data
});
this.handleClose();
} else {
this.$emit('error', {
message: response.data.message || 'Übertragung fehlgeschlagen',
error: response.data.error
});
}
} catch (error) {
console.error('Transfer error:', error);
const errorMessage = error.response?.data?.error ||
error.response?.data?.message ||
error.message ||
'Fehler bei der Übertragung';
this.$emit('error', {
message: 'Fehler bei der Übertragung',
error: errorMessage
});
} finally {
this.isTransferring = false;
}
}
}
};
</script>
<style scoped>
.transfer-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-section {
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 1.5rem;
background-color: #fafafa;
}
.form-section h4 {
margin: 0 0 1rem 0;
color: #333;
font-size: 1.1em;
border-bottom: 2px solid #007bff;
padding-bottom: 0.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #333;
}
.form-group .required {
color: #dc3545;
}
.form-input,
.form-select,
.form-textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 0.9em;
font-family: inherit;
}
.form-textarea {
font-family: 'Courier New', monospace;
resize: vertical;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.credentials-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.credential-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.hint {
display: block;
margin-top: 0.25rem;
font-size: 0.85em;
color: #666;
font-style: italic;
}
.template-help {
margin-top: 0.75rem;
padding: 1rem;
background-color: #f8f9fa;
border-radius: 4px;
border: 1px solid #e9ecef;
font-size: 0.85em;
}
.template-help strong {
display: block;
margin-bottom: 0.5rem;
color: #333;
}
.template-help ul {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.template-help li {
margin: 0.25rem 0;
}
.template-help code {
background-color: #e9ecef;
padding: 0.2rem 0.4rem;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
.template-help pre {
background-color: #282c34;
color: #abb2bf;
padding: 0.75rem;
border-radius: 4px;
overflow-x: auto;
margin-top: 0.5rem;
font-size: 0.85em;
}
.bulk-hint {
margin-top: 0.75rem;
padding: 0.75rem;
background-color: #e7f3ff;
border-left: 4px solid #007bff;
border-radius: 4px;
color: #004085;
}
.bulk-hint code {
background-color: #cce5ff;
padding: 0.2rem 0.4rem;
border-radius: 3px;
}
.btn-primary {
background-color: #007bff;
color: white;
border: none;
padding: 0.5rem 1.5rem;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
transition: background-color 0.2s;
}
.btn-primary:hover:not(:disabled) {
background-color: #0056b3;
}
.btn-primary:disabled {
background-color: #6c757d;
cursor: not-allowed;
opacity: 0.6;
}
.btn-secondary {
background-color: #6c757d;
color: white;
border: none;
padding: 0.5rem 1.5rem;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
transition: background-color 0.2s;
}
.btn-secondary:hover {
background-color: #5a6268;
}
</style>

View File

@@ -28,6 +28,9 @@
<button @click="updateRatingsFromMyTischtennis" class="btn-update-ratings" :disabled="isUpdatingRatings">
{{ isUpdatingRatings ? 'Aktualisiere...' : 'TTR/QTTR von myTischtennis aktualisieren' }}
</button>
<button @click="openTransferDialog" class="btn-transfer">
Mitglieder übertragen
</button>
</div>
<div class="newmember">
<div class="toggle-new-member">
@@ -156,35 +159,7 @@
<button @click.stop="openActivitiesModal(member)" class="btn-activities">Übungen</button>
</td>
</tr>
<!-- Info Dialog -->
<InfoDialog
v-model="infoDialog.isOpen"
:title="infoDialog.title"
:message="infoDialog.message"
:details="infoDialog.details"
:type="infoDialog.type"
/>
<!-- Confirm Dialog -->
<ConfirmDialog
v-model="confirmDialog.isOpen"
:title="confirmDialog.title"
:message="confirmDialog.message"
:details="confirmDialog.details"
:type="confirmDialog.type"
@confirm="handleConfirmResult(true)"
@cancel="handleConfirmResult(false)"
/>
<!-- Member Activities Dialog -->
<MemberActivitiesDialog
v-model="showActivitiesModal"
:member="selectedMemberForActivities"
:club-id="currentClub"
/>
</template>
</template>
</tbody>
</table>
</div>
@@ -212,28 +187,41 @@
@delete-note="deleteNote"
@close="closeNotesModal"
/>
</div>
<!-- Info Dialog -->
<InfoDialog
v-model="infoDialog.isOpen"
:title="infoDialog.title"
:message="infoDialog.message"
:details="infoDialog.details"
:type="infoDialog.type"
/>
<!-- Confirm Dialog -->
<ConfirmDialog
v-model="confirmDialog.isOpen"
:title="confirmDialog.title"
:message="confirmDialog.message"
:details="confirmDialog.details"
:type="confirmDialog.type"
@confirm="handleConfirmResult(true)"
@cancel="handleConfirmResult(false)"
/>
<!-- Info Dialog -->
<InfoDialog
v-model="infoDialog.isOpen"
:title="infoDialog.title"
:message="infoDialog.message"
:details="infoDialog.details"
:type="infoDialog.type"
/>
<!-- Confirm Dialog -->
<ConfirmDialog
v-model="confirmDialog.isOpen"
:title="confirmDialog.title"
:message="confirmDialog.message"
:details="confirmDialog.details"
:type="confirmDialog.type"
@confirm="handleConfirmResult(true)"
@cancel="handleConfirmResult(false)"
/>
<!-- Member Activities Dialog -->
<MemberActivitiesDialog
v-model="showActivitiesModal"
:member="selectedMemberForActivities"
:club-id="currentClub"
/>
<!-- Member Transfer Dialog -->
<MemberTransferDialog
v-model="showTransferDialog"
@success="handleTransferSuccess"
@error="handleTransferError"
/>
</div>
</template>
<script>
@@ -247,6 +235,7 @@ import ImageViewerDialog from '../components/ImageViewerDialog.vue';
import BaseDialog from '../components/BaseDialog.vue';
import MemberNotesDialog from '../components/MemberNotesDialog.vue';
import MemberActivitiesDialog from '../components/MemberActivitiesDialog.vue';
import MemberTransferDialog from '../components/MemberTransferDialog.vue';
export default {
name: 'MembersView',
components: {
@@ -255,7 +244,8 @@ export default {
ImageViewerDialog,
BaseDialog,
MemberNotesDialog,
MemberActivitiesDialog
MemberActivitiesDialog,
MemberTransferDialog
},
computed: {
...mapGetters(['isAuthenticated', 'currentClub']),
@@ -345,7 +335,8 @@ export default {
showActivitiesModal: false,
selectedMemberForActivities: null,
selectedAgeGroup: '',
selectedGender: ''
selectedGender: '',
showTransferDialog: false
}
},
async mounted() {
@@ -737,6 +728,30 @@ export default {
this.selectedAgeGroup = '';
this.selectedGender = '';
this.showInactiveMembers = false;
},
openTransferDialog() {
this.showTransferDialog = true;
},
handleTransferSuccess(event) {
const { message, details, results } = event;
this.showInfo(
'Übertragung erfolgreich',
message,
details,
'success'
);
},
handleTransferError(event) {
const { message, error } = event;
this.showInfo(
'Fehler bei der Übertragung',
message,
error,
'error'
);
}
}
}
@@ -1142,4 +1157,19 @@ table td {
.btn-clear-filters:hover {
background-color: #5a6268;
}
.btn-transfer {
background-color: #17a2b8;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
transition: background-color 0.2s ease;
}
.btn-transfer:hover {
background-color: #138496;
}
</style>