diff --git a/backend/controllers/memberTransferConfigController.js b/backend/controllers/memberTransferConfigController.js new file mode 100644 index 0000000..3a25a72 --- /dev/null +++ b/backend/controllers/memberTransferConfigController.js @@ -0,0 +1,51 @@ +import MemberTransferConfigService from '../services/memberTransferConfigService.js'; + +export const getConfig = async (req, res) => { + try { + const { id: clubId } = req.params; + const { authcode: userToken } = req.headers; + + const result = await MemberTransferConfigService.getConfig(userToken, clubId); + res.status(result.status).json(result.response); + } catch (error) { + console.error('[getConfig] Error:', error); + res.status(500).json({ + success: false, + error: 'Fehler beim Laden der Konfiguration' + }); + } +}; + +export const saveConfig = async (req, res) => { + try { + const { id: clubId } = req.params; + const { authcode: userToken } = req.headers; + const configData = req.body; + + const result = await MemberTransferConfigService.saveConfig(userToken, clubId, configData); + res.status(result.status).json(result.response); + } catch (error) { + console.error('[saveConfig] Error:', error); + res.status(500).json({ + success: false, + error: 'Fehler beim Speichern der Konfiguration' + }); + } +}; + +export const deleteConfig = async (req, res) => { + try { + const { id: clubId } = req.params; + const { authcode: userToken } = req.headers; + + const result = await MemberTransferConfigService.deleteConfig(userToken, clubId); + res.status(result.status).json(result.response); + } catch (error) { + console.error('[deleteConfig] Error:', error); + res.status(500).json({ + success: false, + error: 'Fehler beim Löschen der Konfiguration' + }); + } +}; + diff --git a/backend/models/MemberTransferConfig.js b/backend/models/MemberTransferConfig.js new file mode 100644 index 0000000..b790aeb --- /dev/null +++ b/backend/models/MemberTransferConfig.js @@ -0,0 +1,117 @@ +import { DataTypes } from 'sequelize'; +import sequelize from '../database.js'; +import Club from './Club.js'; +import { encryptData, decryptData } from '../utils/encrypt.js'; + +const MemberTransferConfig = sequelize.define('MemberTransferConfig', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false + }, + clubId: { + type: DataTypes.INTEGER, + allowNull: false, + unique: true, + references: { + model: Club, + key: 'id' + }, + onDelete: 'CASCADE' + }, + server: { + type: DataTypes.STRING, + allowNull: true, + field: 'server', + comment: 'Base URL des Servers (z.B. https://example.com)' + }, + loginEndpoint: { + type: DataTypes.STRING, + allowNull: true, + field: 'login_endpoint', + comment: 'Relativer Pfad zum Login-Endpoint (z.B. /api/auth/login)' + }, + loginFormat: { + type: DataTypes.STRING, + allowNull: true, + defaultValue: 'json', + field: 'login_format' + }, + encryptedLoginCredentials: { + type: DataTypes.TEXT, + allowNull: true, + field: 'encrypted_login_credentials', + comment: 'Verschlüsselte Login-Daten als JSON-String' + }, + transferEndpoint: { + type: DataTypes.STRING, + allowNull: false, + field: 'transfer_endpoint', + comment: 'Relativer Pfad zum Übertragungs-Endpoint (z.B. /api/members/bulk)' + }, + transferMethod: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: 'POST', + field: 'transfer_method' + }, + transferFormat: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: 'json', + field: 'transfer_format' + }, + transferTemplate: { + type: DataTypes.TEXT, + allowNull: false, + field: 'transfer_template' + }, + useBulkMode: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + field: 'use_bulk_mode' + }, + bulkWrapperTemplate: { + type: DataTypes.TEXT, + allowNull: true, + field: 'bulk_wrapper_template', + comment: 'Optionales Template für die äußere Struktur im Bulk-Modus (z.B. {"data": {"members": "{{members}}"}})' + } +}, { + underscored: true, + tableName: 'member_transfer_config', + timestamps: true +}); + +// Getter/Setter für verschlüsselte Login-Daten +MemberTransferConfig.prototype.getLoginCredentials = function() { + if (!this.encryptedLoginCredentials) { + return null; + } + try { + const decrypted = decryptData(this.encryptedLoginCredentials); + return JSON.parse(decrypted); + } catch (error) { + console.error('[MemberTransferConfig] Error decrypting login credentials:', error); + return null; + } +}; + +MemberTransferConfig.prototype.setLoginCredentials = function(credentials) { + if (!credentials || Object.keys(credentials).length === 0) { + this.encryptedLoginCredentials = null; + return; + } + try { + const jsonString = JSON.stringify(credentials); + this.encryptedLoginCredentials = encryptData(jsonString); + } catch (error) { + console.error('[MemberTransferConfig] Error encrypting login credentials:', error); + this.encryptedLoginCredentials = null; + } +}; + +export default MemberTransferConfig; + diff --git a/backend/models/index.js b/backend/models/index.js index 972e731..dbcd4ad 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -39,6 +39,7 @@ import MyTischtennis from './MyTischtennis.js'; import MyTischtennisUpdateHistory from './MyTischtennisUpdateHistory.js'; import MyTischtennisFetchLog from './MyTischtennisFetchLog.js'; import ApiLog from './ApiLog.js'; +import MemberTransferConfig from './MemberTransferConfig.js'; // Official tournaments relations OfficialTournament.hasMany(OfficialCompetition, { foreignKey: 'tournamentId', as: 'competitions' }); OfficialCompetition.belongsTo(OfficialTournament, { foreignKey: 'tournamentId', as: 'tournament' }); @@ -242,6 +243,9 @@ MyTischtennisFetchLog.belongsTo(User, { foreignKey: 'userId', as: 'user' }); User.hasMany(ApiLog, { foreignKey: 'userId', as: 'apiLogs' }); ApiLog.belongsTo(User, { foreignKey: 'userId', as: 'user' }); +Club.hasOne(MemberTransferConfig, { foreignKey: 'clubId', as: 'memberTransferConfig' }); +MemberTransferConfig.belongsTo(Club, { foreignKey: 'clubId', as: 'club' }); + export { User, Log, @@ -283,4 +287,5 @@ export { MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, + MemberTransferConfig, }; diff --git a/backend/routes/memberTransferConfigRoutes.js b/backend/routes/memberTransferConfigRoutes.js new file mode 100644 index 0000000..f2b1e52 --- /dev/null +++ b/backend/routes/memberTransferConfigRoutes.js @@ -0,0 +1,14 @@ +import express from 'express'; +import { authenticate } from '../middleware/authMiddleware.js'; +import { authorize } from '../middleware/authorizationMiddleware.js'; +import { getConfig, saveConfig, deleteConfig } from '../controllers/memberTransferConfigController.js'; + +const router = express.Router(); + +router.get('/:id', authenticate, authorize('members', 'read'), getConfig); +router.post('/:id', authenticate, authorize('members', 'write'), saveConfig); +router.put('/:id', authenticate, authorize('members', 'write'), saveConfig); +router.delete('/:id', authenticate, authorize('members', 'write'), deleteConfig); + +export default router; + diff --git a/backend/server.js b/backend/server.js index 6b52534..23151cc 100644 --- a/backend/server.js +++ b/backend/server.js @@ -8,7 +8,7 @@ import { DiaryNote, DiaryTag, MemberDiaryTag, DiaryDateTag, DiaryMemberNote, DiaryMemberTag, PredefinedActivity, PredefinedActivityImage, DiaryDateActivity, DiaryMemberActivity, Match, League, Team, ClubTeam, TeamDocument, Group, GroupActivity, Tournament, TournamentGroup, TournamentMatch, TournamentResult, - TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog + TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig } from './models/index.js'; import authRoutes from './routes/authRoutes.js'; import clubRoutes from './routes/clubRoutes.js'; @@ -41,6 +41,7 @@ import seasonRoutes from './routes/seasonRoutes.js'; import memberActivityRoutes from './routes/memberActivityRoutes.js'; import permissionRoutes from './routes/permissionRoutes.js'; import apiLogRoutes from './routes/apiLogRoutes.js'; +import memberTransferConfigRoutes from './routes/memberTransferConfigRoutes.js'; import schedulerService from './services/schedulerService.js'; import { requestLoggingMiddleware } from './middleware/requestLoggingMiddleware.js'; @@ -99,6 +100,7 @@ app.use('/api/seasons', seasonRoutes); app.use('/api/member-activities', memberActivityRoutes); app.use('/api/permissions', permissionRoutes); app.use('/api/logs', apiLogRoutes); +app.use('/api/member-transfer-config', memberTransferConfigRoutes); app.use(express.static(path.join(__dirname, '../frontend/dist'))); @@ -202,6 +204,7 @@ app.get('*', (req, res) => { await safeSync(MyTischtennisUpdateHistory); await safeSync(MyTischtennisFetchLog); await safeSync(ApiLog); + await safeSync(MemberTransferConfig); // Start scheduler service schedulerService.start(); diff --git a/backend/services/memberTransferConfigService.js b/backend/services/memberTransferConfigService.js new file mode 100644 index 0000000..9b82ad6 --- /dev/null +++ b/backend/services/memberTransferConfigService.js @@ -0,0 +1,227 @@ +import MemberTransferConfig from '../models/MemberTransferConfig.js'; +import { checkAccess } from '../utils/userUtils.js'; +import { devLog } from '../utils/logger.js'; + +class MemberTransferConfigService { + /** + * Ruft die Konfiguration für einen Verein ab + */ + async getConfig(userToken, clubId) { + try { + await checkAccess(userToken, clubId); + + const config = await MemberTransferConfig.findOne({ + where: { clubId } + }); + + if (!config) { + return { + status: 404, + response: { + success: false, + message: 'Keine Konfiguration gefunden' + } + }; + } + + // Login-Credentials entschlüsseln + const loginCredentials = config.getLoginCredentials(); + + return { + status: 200, + response: { + success: true, + config: { + id: config.id, + clubId: config.clubId, + server: config.server, + loginEndpoint: config.loginEndpoint, + loginFormat: config.loginFormat, + loginCredentials: loginCredentials || {}, + transferEndpoint: config.transferEndpoint, + transferMethod: config.transferMethod, + transferFormat: config.transferFormat, + transferTemplate: config.transferTemplate, + useBulkMode: config.useBulkMode, + bulkWrapperTemplate: config.bulkWrapperTemplate + } + } + }; + } catch (error) { + devLog('[getConfig] Error:', error); + return { + status: 500, + response: { + success: false, + error: 'Fehler beim Laden der Konfiguration: ' + error.message + } + }; + } + } + + /** + * Speichert oder aktualisiert die Konfiguration + */ + async saveConfig(userToken, clubId, configData) { + try { + await checkAccess(userToken, clubId); + + // Validierung + if (!configData.server) { + return { + status: 400, + response: { + success: false, + error: 'Server-Basis-URL ist erforderlich' + } + }; + } + + if (!configData.transferEndpoint) { + return { + status: 400, + response: { + success: false, + error: 'Übertragungs-Endpoint ist erforderlich' + } + }; + } + + if (!configData.transferTemplate) { + return { + status: 400, + response: { + success: false, + error: 'Übertragungs-Template ist erforderlich' + } + }; + } + + // Prüfen, ob bereits eine Konfiguration existiert + let config = await MemberTransferConfig.findOne({ + where: { clubId } + }); + + const configToSave = { + clubId: clubId, + server: configData.server, + loginEndpoint: configData.loginEndpoint || null, + loginFormat: configData.loginFormat || 'json', + transferEndpoint: configData.transferEndpoint, + transferMethod: configData.transferMethod || 'POST', + transferFormat: configData.transferFormat || 'json', + transferTemplate: configData.transferTemplate, + useBulkMode: configData.useBulkMode || false, + bulkWrapperTemplate: configData.bulkWrapperTemplate || null + }; + + if (config) { + // Update + config.server = configToSave.server; + config.loginEndpoint = configToSave.loginEndpoint; + config.loginFormat = configToSave.loginFormat; + config.transferEndpoint = configToSave.transferEndpoint; + config.transferMethod = configToSave.transferMethod; + config.transferFormat = configToSave.transferFormat; + config.transferTemplate = configToSave.transferTemplate; + config.useBulkMode = configToSave.useBulkMode; + config.bulkWrapperTemplate = configToSave.bulkWrapperTemplate; + + // Login-Credentials setzen (nur wenn vorhanden) + if (configData.loginCredentials) { + config.setLoginCredentials(configData.loginCredentials); + } else { + // Wenn keine Credentials übergeben wurden, aber welche vorhanden sind, behalten + // Nur löschen, wenn explizit null/undefined übergeben wurde + if (configData.loginCredentials === null || configData.loginCredentials === undefined) { + config.encryptedLoginCredentials = null; + } + } + + await config.save(); + } else { + // Create + config = await MemberTransferConfig.create(configToSave); + + // Login-Credentials setzen (nur wenn vorhanden) + if (configData.loginCredentials) { + config.setLoginCredentials(configData.loginCredentials); + await config.save(); + } + } + + return { + status: 200, + response: { + success: true, + message: 'Konfiguration erfolgreich gespeichert', + config: { + id: config.id, + clubId: config.clubId, + server: config.server, + loginEndpoint: config.loginEndpoint, + loginFormat: config.loginFormat, + transferEndpoint: config.transferEndpoint, + transferMethod: config.transferMethod, + transferFormat: config.transferFormat, + transferTemplate: config.transferTemplate, + useBulkMode: config.useBulkMode, + bulkWrapperTemplate: config.bulkWrapperTemplate + } + } + }; + } catch (error) { + devLog('[saveConfig] Error:', error); + return { + status: 500, + response: { + success: false, + error: 'Fehler beim Speichern der Konfiguration: ' + error.message + } + }; + } + } + + /** + * Löscht die Konfiguration + */ + async deleteConfig(userToken, clubId) { + try { + await checkAccess(userToken, clubId); + + const deleted = await MemberTransferConfig.destroy({ + where: { clubId } + }); + + if (deleted === 0) { + return { + status: 404, + response: { + success: false, + message: 'Keine Konfiguration gefunden' + } + }; + } + + return { + status: 200, + response: { + success: true, + message: 'Konfiguration erfolgreich gelöscht' + } + }; + } catch (error) { + devLog('[deleteConfig] Error:', error); + return { + status: 500, + response: { + success: false, + error: 'Fehler beim Löschen der Konfiguration: ' + error.message + } + }; + } + } +} + +export default new MemberTransferConfigService(); + diff --git a/backend/services/memberTransferService.js b/backend/services/memberTransferService.js index 657fc81..5c192e9 100644 --- a/backend/services/memberTransferService.js +++ b/backend/services/memberTransferService.js @@ -1,5 +1,6 @@ import axios from 'axios'; import Member from '../models/Member.js'; +import MemberTransferConfig from '../models/MemberTransferConfig.js'; import { checkAccess, getUserByToken } from '../utils/userUtils.js'; import { encryptData, decryptData } from '../utils/encrypt.js'; import { devLog } from '../utils/logger.js'; @@ -49,12 +50,52 @@ class MemberTransferService { let authToken = null; let sessionCookie = null; - if (config.loginEndpoint && config.loginCredentials) { + // Server und Endpoints: Wenn vollständige URLs übergeben wurden, behalten wir sie + // Ansonsten kombinieren wir Server + relativen Pfad aus gespeicherter Konfiguration + let loginEndpointUrl = config.loginEndpoint; + let transferEndpointUrl = config.transferEndpoint; + let loginCredentials = config.loginCredentials; + let savedConfig = null; + + // Wenn relative Pfade verwendet werden, Server aus gespeicherter Konfiguration holen + if ((config.loginEndpoint && !config.loginEndpoint.startsWith('http')) || + (config.transferEndpoint && !config.transferEndpoint.startsWith('http')) || + config.useBulkMode) { + savedConfig = await MemberTransferConfig.findOne({ + where: { clubId } + }); + } + + if (config.loginEndpoint && !config.loginEndpoint.startsWith('http') && savedConfig) { + if (savedConfig.server) { + // Server + relativen Pfad kombinieren + loginEndpointUrl = savedConfig.server.replace(/\/$/, '') + '/' + config.loginEndpoint.replace(/^\//, ''); + + // Login-Credentials aus gespeicherter Konfiguration laden, falls vorhanden + if (!loginCredentials && savedConfig.loginEndpoint === config.loginEndpoint) { + loginCredentials = savedConfig.getLoginCredentials(); + } + } + } + + if (config.transferEndpoint && !config.transferEndpoint.startsWith('http') && savedConfig) { + if (savedConfig.server) { + // Server + relativen Pfad kombinieren + transferEndpointUrl = savedConfig.server.replace(/\/$/, '') + '/' + config.transferEndpoint.replace(/^\//, ''); + } + } + + // Bulk-Wrapper-Template aus gespeicherter Konfiguration laden, falls nicht übergeben + if (!config.bulkWrapperTemplate && config.useBulkMode && savedConfig) { + config.bulkWrapperTemplate = savedConfig.bulkWrapperTemplate; + } + + if (loginEndpointUrl && loginCredentials) { try { const loginResult = await this.performLogin( - config.loginEndpoint, + loginEndpointUrl, config.loginFormat || 'json', - config.loginCredentials + loginCredentials ); if (!loginResult.success) { @@ -112,12 +153,51 @@ class MemberTransferService { } // Bulk-Objekt erstellen - const bulkData = { - members: membersArray - }; + let bulkData; + + // Wenn ein Wrapper-Template vorhanden ist, verwende es + if (config.bulkWrapperTemplate) { + // Wrapper-Template verwenden, {{members}} wird durch das Array ersetzt + const wrapperTemplate = config.bulkWrapperTemplate; + const membersArrayString = JSON.stringify(membersArray); + + // Ersetze {{members}} Platzhalter im Wrapper-Template + // Der Platzhalter kann in zwei Formen vorkommen: + // 1. "{{members}}" (als JSON-String mit Anführungszeichen) + // 2. [{{members}}] (als Array-Platzhalter ohne Anführungszeichen) + let wrapperString = wrapperTemplate; + + // Ersetze zuerst "{{members}}" (String-Platzhalter in Anführungszeichen) + // durch das Array als JSON-String (ohne die äußeren Anführungszeichen) + wrapperString = wrapperString.replace(/"\{\{members\}\}"/g, membersArrayString); + + // Ersetze auch [{{members}}] (Array-Platzhalter) + wrapperString = wrapperString.replace(/\[\{\{members\}\}\]/g, membersArrayString); + + // Ersetze auch {{members}} ohne Anführungszeichen (falls direkt verwendet) + wrapperString = wrapperString.replace(/\{\{members\}\}/g, membersArrayString); + + // Parse zu Objekt + try { + bulkData = JSON.parse(wrapperString); + } catch (parseError) { + devLog('[transferMembers] Error parsing wrapper template after replacement:', parseError); + devLog('[transferMembers] Wrapper template:', wrapperTemplate); + devLog('[transferMembers] After replacement:', wrapperString); + // Fallback: Standard-Format + bulkData = { + members: membersArray + }; + } + } else { + // Standard: {"members": [...]} + bulkData = { + members: membersArray + }; + } const transferResult = await this.transferMember( - config.transferEndpoint, + transferEndpointUrl, config.transferMethod || 'POST', config.transferFormat || 'json', bulkData, @@ -191,7 +271,7 @@ class MemberTransferService { ); const transferResult = await this.transferMember( - config.transferEndpoint, + transferEndpointUrl, config.transferMethod || 'POST', config.transferFormat || 'json', memberData, diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 95bd5b6..3c6956d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -22,6 +22,10 @@ 🔐 Berechtigungen + + 📤 + Mitgliederübertragung + 📋 System-Logs diff --git a/frontend/src/components/MemberTransferDialog.vue b/frontend/src/components/MemberTransferDialog.vue index ee58831..c39855b 100644 --- a/frontend/src/components/MemberTransferDialog.vue +++ b/frontend/src/components/MemberTransferDialog.vue @@ -7,20 +7,41 @@ :close-on-overlay="false" @close="handleClose" > -
+
+ Gespeicherte Konfiguration wird geladen... +
+ +
+
+

Server-Konfiguration

+ +
+ + + Aus Einstellungen geladen (nur lesend) +
+
+

Login-Konfiguration

- + - Optional: Falls für die Übertragung ein Login erforderlich ist + Optional: Relativer Pfad zum Login-Endpoint
@@ -71,17 +92,18 @@

Übertragungs-Konfiguration

-
- - -
+
+ + + Relativer Pfad zum Übertragungs-Endpoint +
@@ -182,7 +204,7 @@ export default { ...mapGetters(['currentClub']), isValid() { - return !!(this.config.transferEndpoint && this.config.transferTemplate); + return !!(this.config.server && this.config.transferEndpoint && this.config.transferTemplate); }, additionalField1Placeholder() { @@ -213,6 +235,7 @@ export default { data() { return { config: { + server: '', loginEndpoint: '', loginFormat: 'json', transferEndpoint: '', @@ -227,10 +250,57 @@ export default { additionalField1: '', additionalField2: '' }, - isTransferring: false + isTransferring: false, + loadingConfig: false }; }, + watch: { + modelValue(newVal) { + if (newVal) { + this.loadSavedConfig(); + } + } + }, methods: { + async loadSavedConfig() { + if (!this.currentClub) { + return; + } + + this.loadingConfig = true; + try { + const response = await apiClient.get(`/member-transfer-config/${this.currentClub}`); + + if (response.data.success && response.data.config) { + const savedConfig = response.data.config; + + // Konfiguration aus gespeicherten Daten laden + this.config.server = savedConfig.server || ''; + this.config.loginEndpoint = savedConfig.loginEndpoint || ''; + this.config.loginFormat = savedConfig.loginFormat || 'json'; + this.config.transferEndpoint = savedConfig.transferEndpoint || ''; + this.config.transferMethod = savedConfig.transferMethod || 'POST'; + this.config.transferFormat = savedConfig.transferFormat || 'json'; + this.config.transferTemplate = savedConfig.transferTemplate || ''; + this.config.useBulkMode = savedConfig.useBulkMode || false; + + // Login-Credentials (Passwort wird nicht zurückgegeben, nur wenn vorhanden) + if (savedConfig.loginCredentials) { + this.loginCredentials.username = savedConfig.loginCredentials.username || ''; + this.loginCredentials.password = ''; // Passwort wird nicht zurückgegeben + // Zusätzliche Felder können nicht direkt zugewiesen werden, da sie verschlüsselt sind + // Benutzer muss diese neu eingeben + } + } + } catch (error) { + // Keine Konfiguration vorhanden - das ist OK, Dialog bleibt leer + if (error.response?.status !== 404) { + console.error('Fehler beim Laden der gespeicherten Konfiguration:', error); + } + } finally { + this.loadingConfig = false; + } + }, handleClose() { this.$emit('update:modelValue', false); this.resetForm(); @@ -238,6 +308,7 @@ export default { resetForm() { this.config = { + server: '', loginEndpoint: '', loginFormat: 'json', transferEndpoint: '', @@ -295,14 +366,20 @@ export default { transferEndpoint: this.config.transferEndpoint, transferMethod: this.config.transferMethod, transferFormat: this.config.transferFormat, - transferTemplate: this.config.transferTemplate + transferTemplate: this.config.transferTemplate, + useBulkMode: this.config.useBulkMode }; // 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; + + // Nur Login-Credentials hinzufügen, wenn welche eingegeben wurden + // Wenn keine eingegeben wurden, werden die gespeicherten verwendet (Backend holt diese) + if (Object.keys(loginCredentials).length > 0) { + transferConfig.loginCredentials = loginCredentials; + } } const response = await apiClient.post( @@ -401,6 +478,12 @@ export default { font-family: inherit; } +.form-input[readonly] { + background-color: #f8f9fa; + cursor: not-allowed; + color: #6c757d; +} + .form-textarea { font-family: 'Courier New', monospace; resize: vertical; @@ -489,6 +572,12 @@ export default { border-radius: 3px; } +.loading-config { + text-align: center; + padding: 2rem; + color: #666; +} + .btn-primary { background-color: #007bff; color: white; diff --git a/frontend/src/router.js b/frontend/src/router.js index cc629c6..c6053f5 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -17,6 +17,7 @@ import MyTischtennisAccount from './views/MyTischtennisAccount.vue'; import TeamManagementView from './views/TeamManagementView.vue'; import PermissionsView from './views/PermissionsView.vue'; import LogsView from './views/LogsView.vue'; +import MemberTransferSettingsView from './views/MemberTransferSettingsView.vue'; import Impressum from './views/Impressum.vue'; import Datenschutz from './views/Datenschutz.vue'; @@ -39,6 +40,7 @@ const routes = [ { path: '/team-management', component: TeamManagementView }, { path: '/permissions', component: PermissionsView }, { path: '/logs', component: LogsView }, + { path: '/member-transfer-settings', component: MemberTransferSettingsView }, { path: '/impressum', component: Impressum }, { path: '/datenschutz', component: Datenschutz }, ]; diff --git a/frontend/src/views/MemberTransferSettingsView.vue b/frontend/src/views/MemberTransferSettingsView.vue new file mode 100644 index 0000000..7dec2c3 --- /dev/null +++ b/frontend/src/views/MemberTransferSettingsView.vue @@ -0,0 +1,1206 @@ + + + + + +