Update training group management and enhance UI components

This commit introduces the `TrainingGroup` model and related functionality, allowing for the management of training groups within the application. The `ClubService` is updated to automatically create preset groups upon club creation. The frontend is enhanced with new views and components, including `TrainingGroupsView` and `TrainingGroupsTab`, to facilitate the display and management of training groups. Additionally, the `MembersView` is updated to allow adding and removing members from training groups, improving the overall user experience and interactivity in managing club members and their associated training groups.
This commit is contained in:
Torsten Schulz (local)
2025-11-15 20:38:53 +01:00
parent fd4b47327f
commit 7a9e856961
21 changed files with 2232 additions and 299 deletions

View File

@@ -0,0 +1,128 @@
import trainingGroupService from '../services/trainingGroupService.js';
import { getSafeErrorMessage } from '../utils/errorUtils.js';
export const getTrainingGroups = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const groups = await trainingGroupService.getTrainingGroups(userToken, clubId);
res.status(200).json(groups);
} catch (error) {
console.error('[getTrainingGroups] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Laden der Trainingsgruppen');
res.status(error.statusCode || 500).json({ error: msg });
}
};
export const createTrainingGroup = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const { name, sortOrder } = req.body;
const group = await trainingGroupService.createTrainingGroup(userToken, clubId, name, sortOrder);
res.status(201).json(group);
} catch (error) {
console.error('[createTrainingGroup] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Erstellen der Trainingsgruppe');
res.status(error.statusCode || 500).json({ error: msg });
}
};
export const updateTrainingGroup = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, groupId } = req.params;
const { name, sortOrder } = req.body;
const group = await trainingGroupService.updateTrainingGroup(userToken, clubId, groupId, name, sortOrder);
res.status(200).json(group);
} catch (error) {
console.error('[updateTrainingGroup] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Aktualisieren der Trainingsgruppe');
res.status(error.statusCode || 500).json({ error: msg });
}
};
export const deleteTrainingGroup = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, groupId } = req.params;
await trainingGroupService.deleteTrainingGroup(userToken, clubId, groupId);
res.status(200).json({ success: true });
} catch (error) {
console.error('[deleteTrainingGroup] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Löschen der Trainingsgruppe');
res.status(error.statusCode || 500).json({ error: msg });
}
};
export const addMemberToGroup = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, groupId, memberId } = req.params;
const memberGroup = await trainingGroupService.addMemberToGroup(userToken, clubId, groupId, memberId);
res.status(201).json(memberGroup);
} catch (error) {
console.error('[addMemberToGroup] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Hinzufügen des Mitglieds zur Gruppe');
res.status(error.statusCode || 500).json({ error: msg });
}
};
export const removeMemberFromGroup = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, groupId, memberId } = req.params;
await trainingGroupService.removeMemberFromGroup(userToken, clubId, groupId, memberId);
res.status(200).json({ success: true });
} catch (error) {
console.error('[removeMemberFromGroup] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Entfernen des Mitglieds aus der Gruppe');
res.status(error.statusCode || 500).json({ error: msg });
}
};
export const getMemberGroups = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, memberId } = req.params;
const groups = await trainingGroupService.getMemberGroups(userToken, clubId, memberId);
res.status(200).json(groups);
} catch (error) {
console.error('[getMemberGroups] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Laden der Gruppen des Mitglieds');
res.status(error.statusCode || 500).json({ error: msg });
}
};
export const ensurePresetGroups = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const groups = await trainingGroupService.ensurePresetGroups(userToken, clubId);
res.status(200).json({
message: 'Preset-Gruppen wurden erstellt/überprüft',
groups: groups.length
});
} catch (error) {
console.error('[ensurePresetGroups] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Erstellen der Preset-Gruppen');
res.status(error.statusCode || 500).json({ error: msg });
}
};
export const enablePresetGroup = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, presetType } = req.params;
const group = await trainingGroupService.enablePresetGroup(userToken, clubId, presetType);
res.status(200).json({
message: 'Preset-Gruppe wurde aktiviert',
group
});
} catch (error) {
console.error('[enablePresetGroup] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Aktivieren der Preset-Gruppe');
res.status(error.statusCode || 500).json({ error: msg });
}
};

View File

@@ -0,0 +1,17 @@
-- Migration: Create club_disabled_preset_groups table
-- Date: 2025-01-16
-- For MariaDB/MySQL
-- Stores which preset groups are disabled for each club
CREATE TABLE IF NOT EXISTS `club_disabled_preset_groups` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`club_id` INT(11) NOT NULL,
`preset_type` ENUM('anfaenger', 'fortgeschrittene', 'erwachsene', 'nachwuchs', 'leistungsgruppe') NOT NULL,
`created_at` DATETIME NOT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_club_preset_type` (`club_id`, `preset_type`),
KEY `club_id` (`club_id`),
CONSTRAINT `club_disabled_preset_groups_ibfk_1` FOREIGN KEY (`club_id`) REFERENCES `clubs` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,34 @@
-- Migration: Create training_group and member_training_group tables
-- Date: 2025-01-16
-- For MariaDB/MySQL
-- Create training_group table
CREATE TABLE IF NOT EXISTS `training_group` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`club_id` INT(11) NOT NULL,
`name` VARCHAR(255) NOT NULL,
`is_preset` TINYINT(1) NOT NULL DEFAULT 0,
`preset_type` ENUM('anfaenger', 'fortgeschrittene', 'erwachsene', 'nachwuchs', 'leistungsgruppe') NULL,
`sort_order` INT(11) NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `club_id` (`club_id`),
CONSTRAINT `training_group_ibfk_1` FOREIGN KEY (`club_id`) REFERENCES `clubs` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Create member_training_group junction table
CREATE TABLE IF NOT EXISTS `member_training_group` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`member_id` INT(11) NOT NULL,
`training_group_id` INT(11) NOT NULL,
`created_at` DATETIME NOT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `unique_member_group` (`member_id`, `training_group_id`),
KEY `member_id` (`member_id`),
KEY `training_group_id` (`training_group_id`),
CONSTRAINT `member_training_group_ibfk_1` FOREIGN KEY (`member_id`) REFERENCES `member` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `member_training_group_ibfk_2` FOREIGN KEY (`training_group_id`) REFERENCES `training_group` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,33 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
import Club from './Club.js';
const ClubDisabledPresetGroup = sequelize.define('ClubDisabledPresetGroup', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: Club,
key: 'id',
},
onDelete: 'CASCADE',
},
presetType: {
type: DataTypes.ENUM('anfaenger', 'fortgeschrittene', 'erwachsene', 'nachwuchs', 'leistungsgruppe'),
allowNull: false,
comment: 'Type of preset group that is disabled for this club'
}
}, {
tableName: 'club_disabled_preset_groups',
underscored: true,
timestamps: true,
});
export default ClubDisabledPresetGroup;

View File

@@ -0,0 +1,38 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
import Member from './Member.js';
import TrainingGroup from './TrainingGroup.js';
const MemberTrainingGroup = sequelize.define('MemberTrainingGroup', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
memberId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: Member,
key: 'id',
},
onDelete: 'CASCADE',
},
trainingGroupId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: TrainingGroup,
key: 'id',
},
onDelete: 'CASCADE',
}
}, {
tableName: 'member_training_group',
underscored: true,
timestamps: true,
});
export default MemberTrainingGroup;

View File

@@ -0,0 +1,49 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
import Club from './Club.js';
const TrainingGroup = sequelize.define('TrainingGroup', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: Club,
key: 'id',
},
onDelete: 'CASCADE',
},
name: {
type: DataTypes.STRING,
allowNull: false,
},
isPreset: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
comment: 'True if this is a preset group (Anfänger, Fortgeschrittene, etc.)'
},
presetType: {
type: DataTypes.ENUM('anfaenger', 'fortgeschrittene', 'erwachsene', 'nachwuchs', 'leistungsgruppe'),
allowNull: true,
comment: 'Type of preset group'
},
sortOrder: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
comment: 'Order for displaying groups'
}
}, {
tableName: 'training_group',
underscored: true,
timestamps: true,
});
export default TrainingGroup;

View File

@@ -44,6 +44,9 @@ import ApiLog from './ApiLog.js';
import MemberTransferConfig from './MemberTransferConfig.js';
import MemberContact from './MemberContact.js';
import MemberImage from './MemberImage.js';
import TrainingGroup from './TrainingGroup.js';
import MemberTrainingGroup from './MemberTrainingGroup.js';
import ClubDisabledPresetGroup from './ClubDisabledPresetGroup.js';
// Official tournaments relations
OfficialTournament.hasMany(OfficialCompetition, { foreignKey: 'tournamentId', as: 'competitions' });
OfficialCompetition.belongsTo(OfficialTournament, { foreignKey: 'tournamentId', as: 'tournament' });
@@ -292,6 +295,27 @@ MemberContact.belongsTo(Member, { foreignKey: 'memberId', as: 'member' });
Member.hasMany(MemberImage, { foreignKey: 'memberId', as: 'images' });
MemberImage.belongsTo(Member, { foreignKey: 'memberId', as: 'member' });
// Training Groups
Club.hasMany(TrainingGroup, { foreignKey: 'clubId', as: 'trainingGroups' });
TrainingGroup.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
Member.belongsToMany(TrainingGroup, {
through: MemberTrainingGroup,
foreignKey: 'memberId',
otherKey: 'trainingGroupId',
as: 'trainingGroups'
});
TrainingGroup.belongsToMany(Member, {
through: MemberTrainingGroup,
foreignKey: 'trainingGroupId',
otherKey: 'memberId',
as: 'members'
});
// Club Disabled Preset Groups
Club.hasMany(ClubDisabledPresetGroup, { foreignKey: 'clubId', as: 'disabledPresetGroups' });
ClubDisabledPresetGroup.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
export {
User,
Log,
@@ -338,4 +362,7 @@ export {
MemberTransferConfig,
MemberContact,
MemberImage,
TrainingGroup,
MemberTrainingGroup,
ClubDisabledPresetGroup,
};

View File

@@ -2670,7 +2670,9 @@
"license": "ISC"
},
"node_modules/js-yaml": {
"version": "4.1.0",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"peer": true,

View File

@@ -2706,7 +2706,9 @@
"license": "ISC"
},
"node_modules/js-yaml": {
"version": "4.1.0",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"peer": true,

View File

@@ -0,0 +1,33 @@
import express from 'express';
import { authenticate } from '../middleware/authMiddleware.js';
import {
getTrainingGroups,
createTrainingGroup,
updateTrainingGroup,
deleteTrainingGroup,
addMemberToGroup,
removeMemberFromGroup,
getMemberGroups,
ensurePresetGroups,
enablePresetGroup,
} from '../controllers/trainingGroupController.js';
const router = express.Router();
router.use(authenticate);
// Spezifischere Routen zuerst (mit /member/ im Pfad)
router.get('/:clubId/member/:memberId', getMemberGroups);
router.post('/:clubId/:groupId/member/:memberId', addMemberToGroup);
router.delete('/:clubId/:groupId/member/:memberId', removeMemberFromGroup);
// Allgemeinere Routen danach
router.post('/:clubId/ensure-preset-groups', ensurePresetGroups);
router.post('/:clubId/enable-preset-group/:presetType', enablePresetGroup);
router.get('/:clubId', getTrainingGroups);
router.post('/:clubId', createTrainingGroup);
router.put('/:clubId/:groupId', updateTrainingGroup);
router.delete('/:clubId/:groupId', deleteTrainingGroup);
export default router;

View File

@@ -46,6 +46,7 @@ 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 trainingGroupRoutes from './routes/trainingGroupRoutes.js';
import schedulerService from './services/schedulerService.js';
import { requestLoggingMiddleware } from './middleware/requestLoggingMiddleware.js';
@@ -132,6 +133,7 @@ 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('/api/training-groups', trainingGroupRoutes);
app.use(express.static(path.join(__dirname, '../frontend/dist')));

View File

@@ -5,6 +5,7 @@ import Member from '../models/Member.js';
import { Op, fn, where, col } from 'sequelize';
import { checkAccess } from '../utils/userUtils.js';
import permissionService from './permissionService.js';
import trainingGroupService from './trainingGroupService.js';
class ClubService {
async getAllClubs() {
@@ -18,7 +19,10 @@ class ClubService {
}
async createClub(clubName) {
return await Club.create({ name: clubName });
const club = await Club.create({ name: clubName });
// Erstelle automatisch die Vorgaben-Gruppen
await trainingGroupService.createPresetGroups(club.id);
return club;
}
async addUserToClub(userId, clubId, isOwner = false) {

View File

@@ -0,0 +1,324 @@
import { Op } from 'sequelize';
import { checkAccess } from '../utils/userUtils.js';
import TrainingGroup from '../models/TrainingGroup.js';
import MemberTrainingGroup from '../models/MemberTrainingGroup.js';
import Member from '../models/Member.js';
import ClubDisabledPresetGroup from '../models/ClubDisabledPresetGroup.js';
import HttpError from '../exceptions/HttpError.js';
class TrainingGroupService {
// Vorgaben-Gruppen beim Vereins-Erstellen anlegen (idempotent - erstellt nur fehlende)
async createPresetGroups(clubId) {
const presetGroups = [
{ name: 'Anfänger', presetType: 'anfaenger', sortOrder: 1 },
{ name: 'Fortgeschrittene', presetType: 'fortgeschrittene', sortOrder: 2 },
{ name: 'Erwachsene', presetType: 'erwachsene', sortOrder: 3 },
{ name: 'Nachwuchs', presetType: 'nachwuchs', sortOrder: 4 },
{ name: 'Leistungsgruppe', presetType: 'leistungsgruppe', sortOrder: 5 },
];
// Hole alle deaktivierten Preset-Gruppen für diesen Verein
const disabledPresetGroups = await ClubDisabledPresetGroup.findAll({
where: { clubId },
});
const disabledPresetTypes = new Set(disabledPresetGroups.map(d => d.presetType));
const createdGroups = [];
for (const preset of presetGroups) {
// Überspringe deaktivierte Preset-Gruppen
if (disabledPresetTypes.has(preset.presetType)) {
continue;
}
// Prüfe, ob diese Preset-Gruppe bereits existiert
const existing = await TrainingGroup.findOne({
where: {
clubId,
isPreset: true,
presetType: preset.presetType
},
});
if (!existing) {
const group = await TrainingGroup.create({
clubId,
name: preset.name,
isPreset: true,
presetType: preset.presetType,
sortOrder: preset.sortOrder,
});
createdGroups.push(group);
} else {
createdGroups.push(existing);
}
}
return createdGroups;
}
// Stelle sicher, dass alle Preset-Gruppen für einen Verein existieren
async ensurePresetGroups(userToken, clubId) {
await checkAccess(userToken, clubId);
return await this.createPresetGroups(clubId);
}
// Aktiviere eine deaktivierte Preset-Gruppe wieder
async enablePresetGroup(userToken, clubId, presetType) {
await checkAccess(userToken, clubId);
// Entferne die Deaktivierung
await ClubDisabledPresetGroup.destroy({
where: { clubId, presetType },
});
// Erstelle die Gruppe, falls sie nicht existiert
const presetGroups = [
{ name: 'Anfänger', presetType: 'anfaenger', sortOrder: 1 },
{ name: 'Fortgeschrittene', presetType: 'fortgeschrittene', sortOrder: 2 },
{ name: 'Erwachsene', presetType: 'erwachsene', sortOrder: 3 },
{ name: 'Nachwuchs', presetType: 'nachwuchs', sortOrder: 4 },
{ name: 'Leistungsgruppe', presetType: 'leistungsgruppe', sortOrder: 5 },
];
const preset = presetGroups.find(p => p.presetType === presetType);
if (!preset) {
throw new HttpError('Ungültiger Preset-Typ', 400);
}
const existing = await TrainingGroup.findOne({
where: {
clubId,
isPreset: true,
presetType: presetType
},
});
if (!existing) {
return await TrainingGroup.create({
clubId,
name: preset.name,
isPreset: true,
presetType: preset.presetType,
sortOrder: preset.sortOrder,
});
}
return existing;
}
async getTrainingGroups(userToken, clubId) {
await checkAccess(userToken, clubId);
// Stelle sicher, dass alle Preset-Gruppen existieren
await this.ensurePresetGroups(userToken, clubId);
const groups = await TrainingGroup.findAll({
where: { clubId },
order: [
['isPreset', 'DESC'], // Preset-Gruppen zuerst
['sortOrder', 'ASC'],
['name', 'ASC'],
],
include: [
{
model: Member,
as: 'members',
through: { attributes: [] }, // Keine Junction-Table-Attribute
attributes: ['id', 'firstName', 'lastName'],
required: false, // LEFT JOIN, damit auch Gruppen ohne Mitglieder zurückgegeben werden
},
],
});
// Stelle sicher, dass es ein Array ist und dass jedes Gruppen-Objekt ein members-Array hat
return groups.map(group => {
const groupData = group.toJSON ? group.toJSON() : group;
return {
...groupData,
members: Array.isArray(groupData.members) ? groupData.members : []
};
});
}
async createTrainingGroup(userToken, clubId, name, sortOrder = 0) {
await checkAccess(userToken, clubId);
// Prüfe, ob bereits eine Gruppe mit diesem Namen existiert
const existing = await TrainingGroup.findOne({
where: { clubId, name },
});
if (existing) {
throw new HttpError('Eine Gruppe mit diesem Namen existiert bereits', 409);
}
const group = await TrainingGroup.create({
clubId,
name,
isPreset: false,
presetType: null,
sortOrder,
});
return group;
}
async updateTrainingGroup(userToken, clubId, groupId, name, sortOrder) {
await checkAccess(userToken, clubId);
const group = await TrainingGroup.findOne({
where: { id: groupId, clubId },
});
if (!group) {
throw new HttpError('Gruppe nicht gefunden', 404);
}
// Preset-Gruppen können nicht umbenannt werden
if (group.isPreset && name !== group.name) {
throw new HttpError('Vorgaben-Gruppen können nicht umbenannt werden', 400);
}
// Prüfe, ob bereits eine andere Gruppe mit diesem Namen existiert
if (name !== group.name) {
const existing = await TrainingGroup.findOne({
where: { clubId, name, id: { [Op.ne]: groupId } },
});
if (existing) {
throw new HttpError('Eine Gruppe mit diesem Namen existiert bereits', 409);
}
}
group.name = name;
if (sortOrder !== undefined) {
group.sortOrder = sortOrder;
}
await group.save();
return group;
}
async deleteTrainingGroup(userToken, clubId, groupId) {
await checkAccess(userToken, clubId);
const group = await TrainingGroup.findOne({
where: { id: groupId, clubId },
});
if (!group) {
throw new HttpError('Gruppe nicht gefunden', 404);
}
// Wenn es eine Preset-Gruppe ist, markiere sie als deaktiviert statt sie zu löschen
if (group.isPreset && group.presetType) {
// Prüfe, ob bereits als deaktiviert markiert
const existing = await ClubDisabledPresetGroup.findOne({
where: { clubId, presetType: group.presetType },
});
if (!existing) {
await ClubDisabledPresetGroup.create({
clubId,
presetType: group.presetType,
});
}
// Lösche die Gruppe
await group.destroy();
return { success: true, message: 'Preset-Gruppe wurde deaktiviert und wird nicht automatisch wieder erstellt' };
}
await group.destroy();
return { success: true };
}
async addMemberToGroup(userToken, clubId, groupId, memberId) {
await checkAccess(userToken, clubId);
// Prüfe, ob Gruppe existiert und zum Verein gehört
const group = await TrainingGroup.findOne({
where: { id: groupId, clubId },
});
if (!group) {
throw new HttpError('Gruppe nicht gefunden', 404);
}
// Prüfe, ob Mitglied existiert und zum Verein gehört
const member = await Member.findOne({
where: { id: memberId, clubId },
});
if (!member) {
throw new HttpError('Mitglied nicht gefunden', 404);
}
// Prüfe, ob Zuordnung bereits existiert
const existing = await MemberTrainingGroup.findOne({
where: { memberId, trainingGroupId: groupId },
});
if (existing) {
throw new HttpError('Mitglied ist bereits in dieser Gruppe', 409);
}
const memberGroup = await MemberTrainingGroup.create({
memberId,
trainingGroupId: groupId,
});
return memberGroup;
}
async removeMemberFromGroup(userToken, clubId, groupId, memberId) {
await checkAccess(userToken, clubId);
// Prüfe, ob Gruppe existiert und zum Verein gehört
const group = await TrainingGroup.findOne({
where: { id: groupId, clubId },
});
if (!group) {
throw new HttpError('Gruppe nicht gefunden', 404);
}
// Prüfe, ob Mitglied existiert und zum Verein gehört
const member = await Member.findOne({
where: { id: memberId, clubId },
});
if (!member) {
throw new HttpError('Mitglied nicht gefunden', 404);
}
const memberGroup = await MemberTrainingGroup.findOne({
where: { memberId, trainingGroupId: groupId },
});
if (!memberGroup) {
throw new HttpError('Zuordnung nicht gefunden', 404);
}
await memberGroup.destroy();
return { success: true };
}
async getMemberGroups(userToken, clubId, memberId) {
await checkAccess(userToken, clubId);
const member = await Member.findOne({
where: { id: memberId, clubId },
});
if (!member) {
throw new HttpError('Mitglied nicht gefunden', 404);
}
const groups = await TrainingGroup.findAll({
where: { clubId },
include: [
{
model: Member,
as: 'members',
where: { id: memberId },
through: { attributes: [] },
required: true,
},
],
order: [
['isPreset', 'DESC'],
['sortOrder', 'ASC'],
['name', 'ASC'],
],
});
return groups;
}
}
export default new TrainingGroupService();

View File

@@ -0,0 +1,33 @@
/**
* Gibt eine sichere Fehlermeldung zurück, die an den Client gesendet werden kann.
* Verhindert, dass sensible Informationen (wie Stack-Traces oder interne Details) nach außen gelangen.
*
* @param {Error} error - Der Fehler-Objekt
* @param {string} defaultMessage - Standard-Nachricht, die verwendet wird, wenn keine sichere Nachricht verfügbar ist
* @returns {string} - Sichere Fehlermeldung
*/
export function getSafeErrorMessage(error, defaultMessage = 'Ein Fehler ist aufgetreten') {
// Wenn kein Fehler vorhanden ist, gib die Standard-Nachricht zurück
if (!error) {
return defaultMessage;
}
// Wenn der Fehler eine message hat und es keine interne/technische Nachricht ist
if (error.message) {
const message = error.message;
// Prüfe, ob die Nachricht sicher ist (keine Stack-Traces, keine internen Pfade, etc.)
// Erlaube nur benutzerfreundliche Nachrichten
if (message &&
!message.includes('at ') && // Keine Stack-Traces
!message.includes('Error:') && // Keine technischen Fehler-Präfixe
!message.startsWith('/') && // Keine Dateipfade
message.length < 500) { // Keine sehr langen Nachrichten (könnten Stack-Traces sein)
return message;
}
}
// Fallback: Standard-Nachricht verwenden
return defaultMessage;
}

View File

@@ -23,12 +23,12 @@
"vuex": "^4.1.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.6.2",
"@vitejs/plugin-vue": "^5.2.1",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^8.0.3",
"sass": "^1.77.8",
"sass-loader": "^14.2.1",
"vite": "^7.2.2"
"vite": "^5.4.2"
}
},
"node_modules/@babel/code-frame": {
@@ -191,9 +191,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
"cpu": [
"ppc64"
],
@@ -204,13 +204,13 @@
"aix"
],
"engines": {
"node": ">=18"
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
"cpu": [
"arm"
],
@@ -221,13 +221,13 @@
"android"
],
"engines": {
"node": ">=18"
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
"cpu": [
"arm64"
],
@@ -238,13 +238,13 @@
"android"
],
"engines": {
"node": ">=18"
"node": ">=12"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
"cpu": [
"x64"
],
@@ -255,13 +255,13 @@
"android"
],
"engines": {
"node": ">=18"
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
"cpu": [
"arm64"
],
@@ -272,13 +272,13 @@
"darwin"
],
"engines": {
"node": ">=18"
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
"cpu": [
"x64"
],
@@ -289,13 +289,13 @@
"darwin"
],
"engines": {
"node": ">=18"
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
"cpu": [
"arm64"
],
@@ -306,13 +306,13 @@
"freebsd"
],
"engines": {
"node": ">=18"
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
"cpu": [
"x64"
],
@@ -323,13 +323,13 @@
"freebsd"
],
"engines": {
"node": ">=18"
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
"cpu": [
"arm"
],
@@ -340,13 +340,13 @@
"linux"
],
"engines": {
"node": ">=18"
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
"cpu": [
"arm64"
],
@@ -357,13 +357,13 @@
"linux"
],
"engines": {
"node": ">=18"
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
"cpu": [
"ia32"
],
@@ -374,13 +374,13 @@
"linux"
],
"engines": {
"node": ">=18"
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
"cpu": [
"loong64"
],
@@ -391,13 +391,13 @@
"linux"
],
"engines": {
"node": ">=18"
"node": ">=12"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
"cpu": [
"mips64el"
],
@@ -408,13 +408,13 @@
"linux"
],
"engines": {
"node": ">=18"
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
"cpu": [
"ppc64"
],
@@ -425,13 +425,13 @@
"linux"
],
"engines": {
"node": ">=18"
"node": ">=12"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
"cpu": [
"riscv64"
],
@@ -442,13 +442,13 @@
"linux"
],
"engines": {
"node": ">=18"
"node": ">=12"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
"cpu": [
"s390x"
],
@@ -459,13 +459,13 @@
"linux"
],
"engines": {
"node": ">=18"
"node": ">=12"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
"cpu": [
"x64"
],
@@ -476,30 +476,13 @@
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
"cpu": [
"x64"
],
@@ -510,30 +493,13 @@
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
"cpu": [
"x64"
],
@@ -544,30 +510,13 @@
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
"node": ">=12"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
"cpu": [
"x64"
],
@@ -578,13 +527,13 @@
"sunos"
],
"engines": {
"node": ">=18"
"node": ">=12"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
"cpu": [
"arm64"
],
@@ -595,13 +544,13 @@
"win32"
],
"engines": {
"node": ">=18"
"node": ">=12"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
"cpu": [
"ia32"
],
@@ -612,13 +561,13 @@
"win32"
],
"engines": {
"node": ">=18"
"node": ">=12"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
"cpu": [
"x64"
],
@@ -629,7 +578,7 @@
"win32"
],
"engines": {
"node": ">=18"
"node": ">=12"
}
},
"node_modules/@eslint/eslintrc": {
@@ -1335,16 +1284,16 @@
"optional": true
},
"node_modules/@vitejs/plugin-vue": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.6.2.tgz",
"integrity": "sha512-kqf7SGFoG+80aZG6Pf+gsZIVvGSCKE98JbiWqcCV9cThtg91Jav0yvYFC9Zb+jKetNGF6ZKeoaxgZfND21fWKw==",
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
"integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.18.0 || >=16.0.0"
"node": "^18.0.0 || >=20.0.0"
},
"peerDependencies": {
"vite": "^4.0.0 || ^5.0.0",
"vite": "^5.0.0 || ^6.0.0",
"vue": "^3.2.25"
}
},
@@ -1993,9 +1942,9 @@
}
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -2003,35 +1952,32 @@
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.25.12",
"@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
"@esbuild/aix-ppc64": "0.21.5",
"@esbuild/android-arm": "0.21.5",
"@esbuild/android-arm64": "0.21.5",
"@esbuild/android-x64": "0.21.5",
"@esbuild/darwin-arm64": "0.21.5",
"@esbuild/darwin-x64": "0.21.5",
"@esbuild/freebsd-arm64": "0.21.5",
"@esbuild/freebsd-x64": "0.21.5",
"@esbuild/linux-arm": "0.21.5",
"@esbuild/linux-arm64": "0.21.5",
"@esbuild/linux-ia32": "0.21.5",
"@esbuild/linux-loong64": "0.21.5",
"@esbuild/linux-mips64el": "0.21.5",
"@esbuild/linux-ppc64": "0.21.5",
"@esbuild/linux-riscv64": "0.21.5",
"@esbuild/linux-s390x": "0.21.5",
"@esbuild/linux-x64": "0.21.5",
"@esbuild/netbsd-x64": "0.21.5",
"@esbuild/openbsd-x64": "0.21.5",
"@esbuild/sunos-x64": "0.21.5",
"@esbuild/win32-arm64": "0.21.5",
"@esbuild/win32-ia32": "0.21.5",
"@esbuild/win32-x64": "0.21.5"
}
},
"node_modules/escape-string-regexp": {
@@ -3651,54 +3597,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyglobby/node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -3773,24 +3671,21 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
"rollup": "^4.43.0",
"tinyglobby": "^0.2.15"
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
"rollup": "^4.20.0"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
"node": "^18.0.0 || >=20.0.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
@@ -3799,25 +3694,19 @@
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"@types/node": "^18.0.0 || >=20.0.0",
"less": "*",
"lightningcss": "^1.21.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.4.0"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
@@ -3838,46 +3727,9 @@
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
},
"node_modules/vite/node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vue": {
"version": "3.5.17",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.17.tgz",

View File

@@ -79,14 +79,14 @@
<span class="nav-icon">📊</span>
Trainings-Statistik
</router-link>
<router-link v-if="isAdmin" to="/club-settings" class="nav-link" title="Vereinseinstellungen">
<span class="nav-icon"></span>
Vereinseinstellungen
</router-link>
</div>
<div class="nav-section">
<h4 class="nav-title">Organisation</h4>
<router-link v-if="isAdmin" to="/club-settings" class="nav-link" title="Vereinseinstellungen">
<span class="nav-icon"></span>
Vereinseinstellungen
</router-link>
<router-link v-if="hasPermission('schedule', 'read')" to="/schedule" class="nav-link" title="Spielpläne">
<span class="nav-icon">📅</span>
Spielpläne

View File

@@ -0,0 +1,565 @@
<template>
<div class="training-groups-tab">
<div class="groups-section">
<div class="section-header">
<h3>Gruppen</h3>
<button @click="showAddGroupForm = true" class="btn-primary">+ Neue Gruppe</button>
</div>
<!-- Add Group Form -->
<div v-if="showAddGroupForm" class="add-group-form">
<input
v-model="newGroupName"
type="text"
placeholder="Gruppenname"
@keyup.enter="createGroup"
class="input-field"
/>
<button @click="createGroup" class="btn-primary">Erstellen</button>
<button @click="cancelAddGroup" class="btn-secondary">Abbrechen</button>
</div>
<!-- Groups List -->
<div class="groups-list">
<div
v-for="group in sortedGroups"
:key="group.id"
class="group-card"
:class="{ 'preset-group': group.isPreset }"
>
<div class="group-header">
<h4>{{ group.name }}</h4>
<div class="group-actions">
<button
v-if="!group.isPreset"
@click="editGroup(group)"
class="btn-icon"
title="Bearbeiten"
>
</button>
<button
v-if="!group.isPreset"
@click="deleteGroup(group)"
class="btn-icon btn-danger"
title="Löschen"
>
🗑
</button>
</div>
</div>
<div class="group-members">
<div class="members-count">
{{ group.members ? group.members.length : 0 }} Mitglieder
</div>
<div class="members-list">
<span
v-for="member in (group.members || [])"
:key="member.id"
class="member-tag"
>
{{ member.firstName }} {{ member.lastName }}
<button
@click="removeMemberFromGroup(group.id, member.id)"
class="remove-member-btn"
title="Entfernen"
>
×
</button>
</span>
</div>
</div>
<!-- Add Member to Group -->
<div class="add-member-section">
<select
v-model="selectedMembers[group.id]"
class="member-select"
@change="addMemberToGroup(group.id, $event.target.value)"
:disabled="availableMembersForGroup(group.id).length === 0"
>
<option value="">
{{ availableMembersForGroup(group.id).length === 0 ? 'Keine Mitglieder verfügbar' : 'Mitglied hinzufügen...' }}
</option>
<option
v-for="member in availableMembersForGroup(group.id)"
:key="member.id"
:value="member.id"
>
{{ member.firstName }} {{ member.lastName }}
</option>
</select>
</div>
</div>
</div>
</div>
<!-- Edit Group Dialog -->
<div v-if="editingGroup" class="edit-group-dialog">
<div class="dialog-content">
<h3>Gruppe bearbeiten</h3>
<input
v-model="editingGroup.name"
type="text"
placeholder="Gruppenname"
class="input-field"
/>
<div class="dialog-actions">
<button @click="saveGroupEdit" class="btn-primary">Speichern</button>
<button @click="cancelGroupEdit" class="btn-secondary">Abbrechen</button>
</div>
</div>
</div>
<!-- Info/Confirm Dialogs -->
<InfoDialog
v-model="infoDialog.isOpen"
:title="infoDialog.title"
:message="infoDialog.message"
:details="infoDialog.details"
:type="infoDialog.type"
/>
<ConfirmDialog
v-model="confirmDialog.isOpen"
:title="confirmDialog.title"
:message="confirmDialog.message"
:details="confirmDialog.details"
:type="confirmDialog.type"
@confirm="confirmDialog.resolveCallback && confirmDialog.resolveCallback(true)"
@cancel="confirmDialog.resolveCallback && confirmDialog.resolveCallback(false)"
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import apiClient from '../apiClient.js';
import { getSafeErrorMessage } from '../utils/errorMessages.js';
import InfoDialog from './InfoDialog.vue';
import ConfirmDialog from './ConfirmDialog.vue';
export default {
name: 'TrainingGroupsTab',
components: {
InfoDialog,
ConfirmDialog,
},
computed: {
...mapGetters(['isAuthenticated', 'currentClub', 'token']),
sortedGroups() {
if (!Array.isArray(this.groups)) {
return [];
}
return [...this.groups].sort((a, b) => {
// Preset-Gruppen zuerst
if (a.isPreset && !b.isPreset) return -1;
if (!a.isPreset && b.isPreset) return 1;
// Dann nach sortOrder
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder;
// Dann alphabetisch
return a.name.localeCompare(b.name);
});
},
},
data() {
return {
groups: [],
members: [],
showAddGroupForm: false,
newGroupName: '',
editingGroup: null,
selectedMembers: {},
infoDialog: {
isOpen: false,
title: '',
message: '',
details: '',
type: 'info',
},
confirmDialog: {
isOpen: false,
title: '',
message: '',
details: '',
type: 'info',
resolveCallback: null,
},
};
},
async created() {
if (this.isAuthenticated && this.currentClub) {
await this.loadGroups();
await this.loadMembers();
}
},
watch: {
currentClub() {
if (this.isAuthenticated && this.currentClub) {
this.loadGroups();
this.loadMembers();
}
},
},
methods: {
async loadGroups() {
try {
const response = await apiClient.get(`/training-groups/${this.currentClub}`);
// Stelle sicher, dass es ein Array ist und dass jedes Gruppen-Objekt ein members-Array hat
const groups = Array.isArray(response.data) ? response.data : [];
this.groups = groups.map(group => ({
...group,
members: Array.isArray(group.members) ? group.members : []
}));
} catch (error) {
const msg = getSafeErrorMessage(error, 'Fehler beim Laden der Trainingsgruppen');
this.showInfo('Fehler', msg, '', 'error');
this.groups = [];
}
},
async loadMembers() {
try {
const response = await apiClient.get(`/clubmembers/get/${this.currentClub}/false`);
const members = Array.isArray(response.data) ? response.data : [];
// Die API filtert bereits nach active, also müssen wir nicht nochmal filtern
const filteredMembers = members.filter(m => m != null);
// Sortiere alphabetisch nach Nachname, dann Vorname
this.members = filteredMembers.sort((a, b) => {
const lastNameA = (a.lastName || '').toLowerCase();
const lastNameB = (b.lastName || '').toLowerCase();
if (lastNameA !== lastNameB) {
return lastNameA.localeCompare(lastNameB);
}
const firstNameA = (a.firstName || '').toLowerCase();
const firstNameB = (b.firstName || '').toLowerCase();
return firstNameA.localeCompare(firstNameB);
});
console.log('[loadMembers] Loaded', this.members.length, 'active members');
} catch (error) {
console.error('[loadMembers] Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Laden der Mitglieder');
this.showInfo('Fehler', msg, '', 'error');
this.members = [];
}
},
availableMembersForGroup(groupId) {
if (!Array.isArray(this.members) || this.members.length === 0) {
return [];
}
const group = this.groups.find(g => g.id === groupId);
if (!group) {
return this.members;
}
const groupMembers = Array.isArray(group.members) ? group.members : [];
const memberIdsInGroup = new Set(groupMembers.map(m => m && m.id).filter(id => id != null));
return this.members.filter(m => m && m.id && !memberIdsInGroup.has(m.id));
},
async createGroup() {
if (!this.newGroupName.trim()) {
this.showInfo('Hinweis', 'Bitte geben Sie einen Gruppennamen ein.', '', 'warning');
return;
}
try {
await apiClient.post(`/training-groups/${this.currentClub}`, {
name: this.newGroupName.trim(),
});
this.newGroupName = '';
this.showAddGroupForm = false;
await this.loadGroups();
} catch (error) {
const msg = getSafeErrorMessage(error, 'Fehler beim Erstellen der Gruppe');
this.showInfo('Fehler', msg, '', 'error');
}
},
cancelAddGroup() {
this.newGroupName = '';
this.showAddGroupForm = false;
},
editGroup(group) {
this.editingGroup = { ...group };
},
async saveGroupEdit() {
if (!this.editingGroup.name.trim()) {
this.showInfo('Hinweis', 'Bitte geben Sie einen Gruppennamen ein.', '', 'warning');
return;
}
try {
await apiClient.put(`/training-groups/${this.currentClub}/${this.editingGroup.id}`, {
name: this.editingGroup.name.trim(),
sortOrder: this.editingGroup.sortOrder,
});
this.editingGroup = null;
await this.loadGroups();
} catch (error) {
const msg = getSafeErrorMessage(error, 'Fehler beim Aktualisieren der Gruppe');
this.showInfo('Fehler', msg, '', 'error');
}
},
cancelGroupEdit() {
this.editingGroup = null;
},
async deleteGroup(group) {
const confirmed = await this.showConfirm(
'Gruppe löschen',
`Möchten Sie die Gruppe "${group.name}" wirklich löschen?`,
'Alle Mitglieder-Zuordnungen werden entfernt.',
'warning'
);
if (!confirmed) return;
try {
await apiClient.delete(`/training-groups/${this.currentClub}/${group.id}`);
await this.loadGroups();
} catch (error) {
const msg = getSafeErrorMessage(error, 'Fehler beim Löschen der Gruppe');
this.showInfo('Fehler', msg, '', 'error');
}
},
async addMemberToGroup(groupId, memberId) {
if (!memberId || !groupId) {
return;
}
try {
await apiClient.post(`/training-groups/${this.currentClub}/${groupId}/member/${memberId}`);
this.selectedMembers[groupId] = '';
await this.loadGroups();
} catch (error) {
console.error('[addMemberToGroup] Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Hinzufügen des Mitglieds');
this.showInfo('Fehler', msg, '', 'error');
}
},
async removeMemberFromGroup(groupId, memberId) {
try {
await apiClient.delete(`/training-groups/${this.currentClub}/${groupId}/member/${memberId}`);
await this.loadGroups();
} catch (error) {
const msg = getSafeErrorMessage(error, 'Fehler beim Entfernen des Mitglieds');
this.showInfo('Fehler', msg, '', 'error');
}
},
showInfo(title, message, details, type) {
this.infoDialog = { isOpen: true, title, message, details, type };
},
showConfirm(title, message, details, type) {
return new Promise((resolve) => {
this.confirmDialog = {
isOpen: true,
title,
message,
details,
type,
resolveCallback: resolve,
};
});
},
},
};
</script>
<style scoped>
.training-groups-tab {
padding: 1rem 0;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.add-group-form {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
padding: 1rem;
background: #f5f5f5;
border-radius: 4px;
}
.input-field {
flex: 1;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.groups-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.group-card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem;
background: white;
}
.group-card.preset-group {
border-color: #4CAF50;
background: #f1f8f4;
}
.group-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.group-header h4 {
margin: 0;
color: #333;
}
.group-actions {
display: flex;
gap: 0.5rem;
}
.btn-icon {
background: none;
border: none;
cursor: pointer;
font-size: 1.2rem;
padding: 0.25rem 0.5rem;
}
.btn-icon:hover {
opacity: 0.7;
}
.btn-danger {
color: #d32f2f;
}
.group-members {
margin-top: 1rem;
}
.members-count {
font-size: 0.9rem;
color: #666;
margin-bottom: 0.5rem;
}
.members-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.member-tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: #e3f2fd;
border-radius: 4px;
font-size: 0.9rem;
}
.remove-member-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1.2rem;
line-height: 1;
color: #d32f2f;
padding: 0;
margin-left: 0.25rem;
}
.remove-member-btn:hover {
opacity: 0.7;
}
.add-member-section {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #eee;
}
.member-select {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.edit-group-dialog {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog-content {
background: white;
padding: 2rem;
border-radius: 8px;
min-width: 300px;
}
.dialog-actions {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
justify-content: flex-end;
}
.btn-primary {
background: #4CAF50;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
}
.btn-primary:hover {
background: #45a049;
}
.btn-secondary {
background: #f5f5f5;
color: #333;
border: 1px solid #ddd;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
}
.btn-secondary:hover {
background: #e0e0e0;
}
</style>

View File

@@ -20,6 +20,7 @@ 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 TrainingGroupsView from './views/TrainingGroupsView.vue';
import Impressum from './views/Impressum.vue';
import Datenschutz from './views/Datenschutz.vue';
@@ -45,6 +46,7 @@ const routes = [
{ path: '/permissions', component: PermissionsView },
{ path: '/logs', component: LogsView },
{ path: '/member-transfer-settings', component: MemberTransferSettingsView },
{ path: '/training-groups', component: TrainingGroupsView },
{ path: '/impressum', component: Impressum },
{ path: '/datenschutz', component: Datenschutz },
];

View File

@@ -2,6 +2,24 @@
<div class="club-settings">
<h1>Vereins-Einstellungen</h1>
<!-- Tab Navigation -->
<div class="tab-navigation">
<button
:class="['tab-button', { active: activeTab === 'settings' }]"
@click="activeTab = 'settings'"
>
Einstellungen
</button>
<button
:class="['tab-button', { active: activeTab === 'training-groups' }]"
@click="activeTab = 'training-groups'"
>
👨👩👧👦 Trainingsgruppen
</button>
</div>
<!-- Settings Tab -->
<div v-if="activeTab === 'settings'">
<section class="card">
<h2>Begrüßungstext</h2>
<div class="greeting-grid">
@@ -30,15 +48,29 @@
<span v-if="saved" class="saved-hint">Gespeichert</span>
</div>
</section>
</div>
<!-- End Settings Tab -->
<!-- Training Groups Tab -->
<div v-if="activeTab === 'training-groups'">
<TrainingGroupsTab />
</div>
<!-- End Training Groups Tab -->
</div>
</template>
<script>
import apiClient from '../apiClient';
import TrainingGroupsTab from '../components/TrainingGroupsTab.vue';
export default {
name: 'ClubSettings',
components: {
TrainingGroupsTab,
},
data() {
return {
activeTab: 'settings',
greeting: '',
associationMemberNumber: '',
saved: false,
@@ -102,6 +134,36 @@ export default {
.btn.btn-primary:hover { background: var(--primary-hover); }
.saved-hint { color: #28a745; font-weight: 600; }
.hint { color: #666; font-size: 12px; margin-top: 8px; }
.tab-navigation {
display: flex;
gap: 0;
border-bottom: 2px solid #e0e0e0;
margin-bottom: 20px;
}
.tab-button {
background: none;
border: none;
padding: 12px 24px;
font-size: 16px;
font-weight: 500;
color: #666;
cursor: pointer;
border-bottom: 3px solid transparent;
transition: all 0.3s ease;
margin-bottom: -2px;
}
.tab-button:hover {
color: #333;
background-color: #f8f9fa;
}
.tab-button.active {
color: #28a745;
border-bottom-color: #28a745;
}
</style>

View File

@@ -93,6 +93,46 @@
<label class="checkbox-item"><span>Pics in Internet erlaubt:</span> <input type="checkbox" v-model="newPicsInInternetAllowed"></label>
<label class="checkbox-item"><span>Testmitgliedschaft:</span> <input type="checkbox" v-model="testMembership"></label>
<label class="checkbox-item"><span>Mitgliedsformular ausgehändigt:</span> <input type="checkbox" v-model="newMemberFormHandedOver"></label>
<!-- Trainingsgruppen -->
<div class="contact-section" v-if="memberToEdit">
<label><span>Trainingsgruppen:</span></label>
<div v-if="memberTrainingGroups.length > 0" class="member-groups-list">
<span
v-for="group in memberTrainingGroups"
:key="group.id"
class="member-group-tag"
>
{{ group.name }}
<button
@click="removeMemberFromGroup(group.id)"
class="remove-group-btn"
title="Entfernen"
>
×
</button>
</span>
</div>
<div v-else class="no-groups-hint">Keine Gruppen zugeordnet</div>
<select
v-model="selectedGroupToAdd"
class="group-select"
@change="addMemberToGroup($event.target.value)"
:disabled="availableGroupsForMember.length === 0"
>
<option value="">
{{ availableGroupsForMember.length === 0 ? 'Keine Gruppen verfügbar' : 'Gruppe hinzufügen...' }}
</option>
<option
v-for="group in availableGroupsForMember"
:key="group.id"
:value="group.id"
>
{{ group.name }}
</option>
</select>
</div>
<label><span>Bild:</span>
<div style="display: flex; gap: 10px; align-items: center;">
<input type="file" accept="image/*" @change="onFileSelected" ref="fileInput" style="display: none;" id="member-image-file">
@@ -157,7 +197,6 @@
<th>Name, Vorname</th>
<th>TTR / QTTR</th>
<th>Adresse</th>
<th>Mitgliedsformular</th>
<th>Geburtsdatum</th>
<th>Telefon-Nr.</th>
<th>Email-Adresse</th>
@@ -199,7 +238,6 @@
<span v-else class="no-rating">-</span>
</td>
<td>{{ member.street }}{{ member.postalCode ? ', ' + member.postalCode : '' }}, {{ member.city }}</td>
<td>{{ member.memberFormHandedOver ? '✓' : '' }}</td>
<td>{{ getFormattedBirthdate(member.birthDate) }}</td>
<td>{{ getFormattedPhoneNumbers(member) }}</td>
<td>{{ getFormattedEmails(member) }}</td>
@@ -370,6 +408,14 @@ export default {
hasTestMembers() {
return this.members.some(member => member.testMembership);
},
availableGroupsForMember() {
if (!Array.isArray(this.trainingGroups) || this.trainingGroups.length === 0) {
return [];
}
const memberGroupIds = new Set(this.memberTrainingGroups.map(g => g && g.id).filter(id => id != null));
return this.trainingGroups.filter(g => g && g.id && !memberGroupIds.has(g.id));
}
},
data() {
@@ -425,12 +471,16 @@ export default {
showMemberInfo: false,
showActivitiesModal: false,
selectedMemberForActivities: null,
memberTrainingGroups: [],
trainingGroups: [],
selectedGroupToAdd: '',
showTransferDialog: false,
selectedAgeGroup: '',
selectedGender: '',
showTransferDialog: false
selectedGender: ''
}
},
async mounted() {
await this.loadTrainingGroups();
await this.init();
},
methods: {
@@ -919,6 +969,9 @@ export default {
this.memberContacts.phones = [{ value: member.phone || '', isParent: false, parentName: '', isPrimary: true }];
this.memberContacts.emails = [{ value: member.email || '', isParent: false, parentName: '', isPrimary: true }];
}
// Load training groups for this member
await this.loadMemberTrainingGroups(member.id);
try {
const response = await apiClient.get(`/clubmembers/image/${member.id}`, {
responseType: 'blob'
@@ -947,8 +1000,66 @@ export default {
},
resetToNewMember() {
this.memberToEdit = null;
this.memberTrainingGroups = [];
this.selectedGroupToAdd = '';
this.resetNewMember();
},
async loadTrainingGroups() {
try {
const response = await apiClient.get(`/training-groups/${this.currentClub}`);
const groups = Array.isArray(response.data) ? response.data : [];
this.trainingGroups = groups.map(group => ({
...group,
members: Array.isArray(group.members) ? group.members : []
}));
} catch (error) {
console.error('[loadTrainingGroups] Error:', error);
this.trainingGroups = [];
}
},
async loadMemberTrainingGroups(memberId) {
try {
const response = await apiClient.get(`/training-groups/${this.currentClub}/member/${memberId}`);
this.memberTrainingGroups = Array.isArray(response.data) ? response.data : [];
} catch (error) {
console.error('[loadMemberTrainingGroups] Error:', error);
this.memberTrainingGroups = [];
}
},
async addMemberToGroup(groupId) {
if (!groupId || !this.memberToEdit) {
return;
}
try {
await apiClient.post(`/training-groups/${this.currentClub}/${groupId}/member/${this.memberToEdit.id}`);
this.selectedGroupToAdd = '';
await this.loadMemberTrainingGroups(this.memberToEdit.id);
} catch (error) {
console.error('[addMemberToGroup] Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Hinzufügen zur Gruppe');
this.showInfo('Fehler', msg, '', 'error');
}
},
async removeMemberFromGroup(groupId) {
if (!this.memberToEdit) {
return;
}
try {
await apiClient.delete(`/training-groups/${this.currentClub}/${groupId}/member/${this.memberToEdit.id}`);
await this.loadMemberTrainingGroups(this.memberToEdit.id);
} catch (error) {
console.error('[removeMemberFromGroup] Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Entfernen aus der Gruppe');
this.showInfo('Fehler', msg, '', 'error');
}
},
async loadNotes(member) {
this.selectedMember = member;
const response = await apiClient.get(`/membernotes/${member.id}`, {
@@ -1925,6 +2036,52 @@ table td {
background-color: #c82333;
}
.member-groups-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.member-group-tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: #e3f2fd;
border-radius: 4px;
font-size: 0.9rem;
}
.remove-group-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1.2rem;
line-height: 1;
color: #d32f2f;
padding: 0;
margin-left: 0.25rem;
}
.remove-group-btn:hover {
opacity: 0.7;
}
.group-select {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
margin-top: 0.5rem;
}
.no-groups-hint {
color: #666;
font-style: italic;
margin-bottom: 0.5rem;
}
.warning-icon {
margin-right: 0.25rem;
font-size: 0.8rem;

View File

@@ -0,0 +1,569 @@
<template>
<div class="training-groups-view">
<h2>Trainingsgruppen</h2>
<div class="groups-section">
<div class="section-header">
<h3>Gruppen</h3>
<button @click="showAddGroupForm = true" class="btn-primary">+ Neue Gruppe</button>
</div>
<!-- Add Group Form -->
<div v-if="showAddGroupForm" class="add-group-form">
<input
v-model="newGroupName"
type="text"
placeholder="Gruppenname"
@keyup.enter="createGroup"
class="input-field"
/>
<button @click="createGroup" class="btn-primary">Erstellen</button>
<button @click="cancelAddGroup" class="btn-secondary">Abbrechen</button>
</div>
<!-- Groups List -->
<div class="groups-list">
<div
v-for="group in sortedGroups"
:key="group.id"
class="group-card"
:class="{ 'preset-group': group.isPreset }"
>
<div class="group-header">
<h4>{{ group.name }}</h4>
<div class="group-actions">
<button
v-if="!group.isPreset"
@click="editGroup(group)"
class="btn-icon"
title="Bearbeiten"
>
</button>
<button
v-if="!group.isPreset"
@click="deleteGroup(group)"
class="btn-icon btn-danger"
title="Löschen"
>
🗑
</button>
</div>
</div>
<div class="group-members">
<div class="members-count">
{{ group.members ? group.members.length : 0 }} Mitglieder
</div>
<div class="members-list">
<span
v-for="member in (group.members || [])"
:key="member.id"
class="member-tag"
>
{{ member.firstName }} {{ member.lastName }}
<button
@click="removeMemberFromGroup(group.id, member.id)"
class="remove-member-btn"
title="Entfernen"
>
×
</button>
</span>
</div>
</div>
<!-- Add Member to Group -->
<div class="add-member-section">
<select
v-model="selectedMembers[group.id]"
class="member-select"
@change="addMemberToGroup(group.id, $event.target.value)"
:disabled="availableMembersForGroup(group.id).length === 0"
>
<option value="">
{{ availableMembersForGroup(group.id).length === 0 ? 'Keine Mitglieder verfügbar' : 'Mitglied hinzufügen...' }}
</option>
<option
v-for="member in availableMembersForGroup(group.id)"
:key="member.id"
:value="member.id"
>
{{ member.firstName }} {{ member.lastName }}
</option>
</select>
</div>
</div>
</div>
</div>
<!-- Edit Group Dialog -->
<div v-if="editingGroup" class="edit-group-dialog">
<div class="dialog-content">
<h3>Gruppe bearbeiten</h3>
<input
v-model="editingGroup.name"
type="text"
placeholder="Gruppenname"
class="input-field"
/>
<div class="dialog-actions">
<button @click="saveGroupEdit" class="btn-primary">Speichern</button>
<button @click="cancelGroupEdit" class="btn-secondary">Abbrechen</button>
</div>
</div>
</div>
<!-- Info/Confirm Dialogs -->
<InfoDialog
v-model="infoDialog.isOpen"
:title="infoDialog.title"
:message="infoDialog.message"
:details="infoDialog.details"
:type="infoDialog.type"
/>
<ConfirmDialog
v-model="confirmDialog.isOpen"
:title="confirmDialog.title"
:message="confirmDialog.message"
:details="confirmDialog.details"
:type="confirmDialog.type"
@confirm="confirmDialog.resolveCallback && confirmDialog.resolveCallback(true)"
@cancel="confirmDialog.resolveCallback && confirmDialog.resolveCallback(false)"
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import apiClient from '../apiClient.js';
import { getSafeErrorMessage } from '../utils/errorMessages.js';
import InfoDialog from '../components/InfoDialog.vue';
import ConfirmDialog from '../components/ConfirmDialog.vue';
export default {
name: 'TrainingGroupsView',
components: {
InfoDialog,
ConfirmDialog,
},
computed: {
...mapGetters(['isAuthenticated', 'currentClub', 'token']),
sortedGroups() {
if (!Array.isArray(this.groups)) {
return [];
}
return [...this.groups].sort((a, b) => {
// Preset-Gruppen zuerst
if (a.isPreset && !b.isPreset) return -1;
if (!a.isPreset && b.isPreset) return 1;
// Dann nach sortOrder
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder;
// Dann alphabetisch
return a.name.localeCompare(b.name);
});
},
},
data() {
return {
groups: [],
members: [],
showAddGroupForm: false,
newGroupName: '',
editingGroup: null,
selectedMembers: {},
infoDialog: {
isOpen: false,
title: '',
message: '',
details: '',
type: 'info',
},
confirmDialog: {
isOpen: false,
title: '',
message: '',
details: '',
type: 'info',
resolveCallback: null,
},
};
},
async created() {
if (this.isAuthenticated && this.currentClub) {
await this.loadGroups();
await this.loadMembers();
}
},
watch: {
currentClub() {
if (this.isAuthenticated && this.currentClub) {
this.loadGroups();
this.loadMembers();
}
},
},
methods: {
async loadGroups() {
try {
const response = await apiClient.get(`/training-groups/${this.currentClub}`);
// Stelle sicher, dass es ein Array ist und dass jedes Gruppen-Objekt ein members-Array hat
const groups = Array.isArray(response.data) ? response.data : [];
this.groups = groups.map(group => ({
...group,
members: Array.isArray(group.members) ? group.members : []
}));
} catch (error) {
const msg = getSafeErrorMessage(error, 'Fehler beim Laden der Trainingsgruppen');
this.showInfo('Fehler', msg, '', 'error');
this.groups = [];
}
},
async loadMembers() {
try {
const response = await apiClient.get(`/clubmembers/get/${this.currentClub}/false`);
const members = Array.isArray(response.data) ? response.data : [];
// Die API filtert bereits nach active, also müssen wir nicht nochmal filtern
const filteredMembers = members.filter(m => m != null);
// Sortiere alphabetisch nach Nachname, dann Vorname
this.members = filteredMembers.sort((a, b) => {
const lastNameA = (a.lastName || '').toLowerCase();
const lastNameB = (b.lastName || '').toLowerCase();
if (lastNameA !== lastNameB) {
return lastNameA.localeCompare(lastNameB);
}
const firstNameA = (a.firstName || '').toLowerCase();
const firstNameB = (b.firstName || '').toLowerCase();
return firstNameA.localeCompare(firstNameB);
});
console.log('[loadMembers] Loaded', this.members.length, 'active members');
} catch (error) {
console.error('[loadMembers] Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Laden der Mitglieder');
this.showInfo('Fehler', msg, '', 'error');
this.members = [];
}
},
availableMembersForGroup(groupId) {
if (!Array.isArray(this.members) || this.members.length === 0) {
return [];
}
const group = this.groups.find(g => g.id === groupId);
if (!group) {
return this.members;
}
const groupMembers = Array.isArray(group.members) ? group.members : [];
const memberIdsInGroup = new Set(groupMembers.map(m => m && m.id).filter(id => id != null));
return this.members.filter(m => m && m.id && !memberIdsInGroup.has(m.id));
},
async createGroup() {
if (!this.newGroupName.trim()) {
this.showInfo('Hinweis', 'Bitte geben Sie einen Gruppennamen ein.', '', 'warning');
return;
}
try {
await apiClient.post(`/training-groups/${this.currentClub}`, {
name: this.newGroupName.trim(),
});
this.newGroupName = '';
this.showAddGroupForm = false;
await this.loadGroups();
} catch (error) {
const msg = getSafeErrorMessage(error, 'Fehler beim Erstellen der Gruppe');
this.showInfo('Fehler', msg, '', 'error');
}
},
cancelAddGroup() {
this.newGroupName = '';
this.showAddGroupForm = false;
},
editGroup(group) {
this.editingGroup = { ...group };
},
async saveGroupEdit() {
if (!this.editingGroup.name.trim()) {
this.showInfo('Hinweis', 'Bitte geben Sie einen Gruppennamen ein.', '', 'warning');
return;
}
try {
await apiClient.put(`/training-groups/${this.currentClub}/${this.editingGroup.id}`, {
name: this.editingGroup.name.trim(),
sortOrder: this.editingGroup.sortOrder,
});
this.editingGroup = null;
await this.loadGroups();
} catch (error) {
const msg = getSafeErrorMessage(error, 'Fehler beim Aktualisieren der Gruppe');
this.showInfo('Fehler', msg, '', 'error');
}
},
cancelGroupEdit() {
this.editingGroup = null;
},
async deleteGroup(group) {
const confirmed = await this.showConfirm(
'Gruppe löschen',
`Möchten Sie die Gruppe "${group.name}" wirklich löschen?`,
'Alle Mitglieder-Zuordnungen werden entfernt.',
'warning'
);
if (!confirmed) return;
try {
await apiClient.delete(`/training-groups/${this.currentClub}/${group.id}`);
await this.loadGroups();
} catch (error) {
const msg = getSafeErrorMessage(error, 'Fehler beim Löschen der Gruppe');
this.showInfo('Fehler', msg, '', 'error');
}
},
async addMemberToGroup(groupId, memberId) {
if (!memberId || !groupId) {
return;
}
try {
await apiClient.post(`/training-groups/${this.currentClub}/${groupId}/member/${memberId}`);
this.selectedMembers[groupId] = '';
await this.loadGroups();
} catch (error) {
console.error('[addMemberToGroup] Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Hinzufügen des Mitglieds');
this.showInfo('Fehler', msg, '', 'error');
}
},
async removeMemberFromGroup(groupId, memberId) {
try {
await apiClient.delete(`/training-groups/${this.currentClub}/${groupId}/member/${memberId}`);
await this.loadGroups();
} catch (error) {
const msg = getSafeErrorMessage(error, 'Fehler beim Entfernen des Mitglieds');
this.showInfo('Fehler', msg, '', 'error');
}
},
showInfo(title, message, details, type) {
this.infoDialog = { isOpen: true, title, message, details, type };
},
showConfirm(title, message, details, type) {
return new Promise((resolve) => {
this.confirmDialog = {
isOpen: true,
title,
message,
details,
type,
resolveCallback: resolve,
};
});
},
},
};
</script>
<style scoped>
.training-groups-view {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.add-group-form {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
padding: 1rem;
background: #f5f5f5;
border-radius: 4px;
}
.input-field {
flex: 1;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.groups-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.group-card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem;
background: white;
}
.group-card.preset-group {
border-color: #4CAF50;
background: #f1f8f4;
}
.group-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.group-header h4 {
margin: 0;
color: #333;
}
.group-actions {
display: flex;
gap: 0.5rem;
}
.btn-icon {
background: none;
border: none;
cursor: pointer;
font-size: 1.2rem;
padding: 0.25rem 0.5rem;
}
.btn-icon:hover {
opacity: 0.7;
}
.btn-danger {
color: #d32f2f;
}
.group-members {
margin-top: 1rem;
}
.members-count {
font-size: 0.9rem;
color: #666;
margin-bottom: 0.5rem;
}
.members-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.member-tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: #e3f2fd;
border-radius: 4px;
font-size: 0.9rem;
}
.remove-member-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1.2rem;
line-height: 1;
color: #d32f2f;
padding: 0;
margin-left: 0.25rem;
}
.remove-member-btn:hover {
opacity: 0.7;
}
.add-member-section {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #eee;
}
.member-select {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.edit-group-dialog {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog-content {
background: white;
padding: 2rem;
border-radius: 8px;
min-width: 300px;
}
.dialog-actions {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
justify-content: flex-end;
}
.btn-primary {
background: #4CAF50;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
}
.btn-primary:hover {
background: #45a049;
}
.btn-secondary {
background: #f5f5f5;
color: #333;
border: 1px solid #ddd;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
}
.btn-secondary:hover {
background: #e0e0e0;
}
</style>