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

@@ -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>