feat(MemberPlayInterest): implement play interest management for members
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 38s

- Added new endpoints to get and set member play interests in the memberController.
- Integrated MemberPlayInterest model into the application, establishing relationships with Member and Club models.
- Updated memberRoutes to include routes for managing member play interests.
- Enhanced memberService to handle play interest retrieval and updates.
- Updated localization files to include new terms related to member play interests.
- Refactored server.js to include MemberPlayInterest in the synchronization process.
This commit is contained in:
Torsten Schulz (local)
2026-04-15 10:48:10 +02:00
parent 45c701b149
commit 2dff5221e3
15 changed files with 1226 additions and 14 deletions

View File

@@ -47,6 +47,43 @@ const setClubMembers = async (req, res) => {
}
}
const getMemberPlayInterests = async (req, res) => {
try {
const { clubId } = req.params;
const { seasonId, lineupHalf } = req.query;
const { authcode: userToken } = req.headers;
const result = await MemberService.getMemberPlayInterests(userToken, Number(clubId), Number(seasonId), String(lineupHalf || ''));
res.status(result.status || 500).json(result.response);
} catch (error) {
console.error('[getMemberPlayInterests] - Error:', error);
res.status(500).json({ error: 'Failed to load member play interests' });
}
};
const setMemberPlayInterest = async (req, res) => {
try {
const { clubId } = req.params;
const { memberId, seasonId, lineupHalf, interested = true } = req.body;
const { authcode: userToken } = req.headers;
const normalizedInterested = interested === true || interested === 'true' || interested === 1 || interested === '1';
const result = await MemberService.setMemberPlayInterest(
userToken,
Number(clubId),
Number(memberId),
Number(seasonId),
String(lineupHalf || ''),
normalizedInterested
);
if (result.status === 200) {
emitMemberChanged(clubId);
}
res.status(result.status || 500).json(result.response);
} catch (error) {
console.error('[setMemberPlayInterest] - Error:', error);
res.status(500).json({ error: 'Failed to save member play interest' });
}
};
const uploadMemberImage = async (req, res) => {
try {
const { clubId, memberId } = req.params;
@@ -290,6 +327,8 @@ export {
getClubMembers,
getWaitingApprovals,
setClubMembers,
getMemberPlayInterests,
setMemberPlayInterest,
uploadMemberImage,
getMemberImage,
updateRatingsFromMyTischtennis,

View File

@@ -0,0 +1,16 @@
-- Halbserienbasierte Spielinteressen (pro Mitglied, Club, Saison und Halbserie)
CREATE TABLE IF NOT EXISTS `member_play_interest` (
`id` INT NOT NULL AUTO_INCREMENT,
`club_id` INT NOT NULL,
`member_id` INT NOT NULL,
`season_id` INT NOT NULL,
`lineup_half` ENUM('first_half', 'second_half') NOT NULL,
`interested` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_member_play_interest_half` (`club_id`, `member_id`, `season_id`, `lineup_half`),
KEY `idx_member_play_interest_member` (`member_id`),
KEY `idx_member_play_interest_season` (`season_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -137,8 +137,7 @@ const Member = sequelize.define('Member', {
type: DataTypes.BOOLEAN,
allowNull: false,
default: false,
}
,
},
gender: {
type: DataTypes.ENUM('male','female','diverse','unknown'),
allowNull: true,

View File

@@ -0,0 +1,44 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const MemberPlayInterest = sequelize.define('MemberPlayInterest', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false
},
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id'
},
memberId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'member_id'
},
seasonId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'season_id'
},
lineupHalf: {
type: DataTypes.ENUM('first_half', 'second_half'),
allowNull: false,
field: 'lineup_half'
},
interested: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true
}
}, {
underscored: true,
sequelize,
modelName: 'MemberPlayInterest',
tableName: 'member_play_interest',
timestamps: true
});
export default MemberPlayInterest;

View File

@@ -50,6 +50,7 @@ import MemberTransferConfig from './MemberTransferConfig.js';
import MemberContact from './MemberContact.js';
import MemberImage from './MemberImage.js';
import MemberTtrHistory from './MemberTtrHistory.js';
import MemberPlayInterest from './MemberPlayInterest.js';
import MemberOrder from './MemberOrder.js';
import MemberOrderHistory from './MemberOrderHistory.js';
import TrainingGroup from './TrainingGroup.js';
@@ -96,6 +97,10 @@ MemberNote.belongsTo(Member, { foreignKey: 'memberId' });
Member.hasMany(MemberTtrHistory, { as: 'ttrHistoryEntries', foreignKey: 'memberId' });
MemberTtrHistory.belongsTo(Member, { as: 'member', foreignKey: 'memberId' });
Member.hasMany(MemberPlayInterest, { as: 'playInterests', foreignKey: 'memberId' });
MemberPlayInterest.belongsTo(Member, { as: 'member', foreignKey: 'memberId' });
Club.hasMany(MemberPlayInterest, { as: 'memberPlayInterests', foreignKey: 'clubId' });
MemberPlayInterest.belongsTo(Club, { as: 'club', foreignKey: 'clubId' });
Member.hasMany(MemberOrder, { as: 'orders', foreignKey: 'memberId' });
MemberOrder.belongsTo(Member, { as: 'member', foreignKey: 'memberId' });
@@ -438,6 +443,7 @@ export {
MemberContact,
MemberImage,
MemberTtrHistory,
MemberPlayInterest,
MemberOrder,
MemberOrderHistory,
TrainingGroup,

View File

@@ -2,6 +2,8 @@ import {
getClubMembers,
getWaitingApprovals,
setClubMembers,
getMemberPlayInterests,
setMemberPlayInterest,
uploadMemberImage,
getMemberImage,
updateRatingsFromMyTischtennis,
@@ -35,6 +37,8 @@ router.post('/image/:clubId/:memberId/:imageId/primary', authenticate, authorize
router.get('/get/:id/:showAll', authenticate, authorize('members', 'read'), getClubMembers);
router.get('/gallery/:clubId', authenticate, authorize('members', 'read'), generateMemberGallery);
router.post('/set/:id', authenticate, authorize('members', 'write'), setClubMembers);
router.get('/play-interest/:clubId', authenticate, authorize('members', 'read'), getMemberPlayInterests);
router.post('/play-interest/:clubId', authenticate, authorize('members', 'write'), setMemberPlayInterest);
router.get('/notapproved/:id', authenticate, authorize('members', 'read'), getWaitingApprovals);
router.post('/update-ratings/:id', authenticate, authorize('mytischtennis', 'write'), updateRatingsFromMyTischtennis);
router.get('/ttr-history/:clubId/:memberId', authenticate, authorize('members', 'read'), getMemberTtrHistory);

View File

@@ -13,7 +13,7 @@ import {
DiaryNote, DiaryTag, MemberDiaryTag, DiaryDateTag, DiaryMemberNote, DiaryMemberTag,
PredefinedActivity, PredefinedActivityImage, DiaryDateActivity, DiaryMemberActivity, Match, League, Team, ClubTeam, ClubTeamMember, TeamDocument, Group,
GroupActivity, Tournament, TournamentGroup, TournamentMatch, TournamentResult,
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, ClickTtAccount, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact, MemberTtrHistory
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, ClickTtAccount, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact, MemberTtrHistory, MemberPlayInterest
, MemberOrder, MemberOrderHistory
} from './models/index.js';
import authRoutes from './routes/authRoutes.js';
@@ -543,6 +543,7 @@ app.use((err, req, res, next) => {
await safeSync(MemberTransferConfig);
await safeSync(MemberContact);
await safeSync(MemberTtrHistory);
await safeSync(MemberPlayInterest);
await safeSync(MemberOrder);
await safeSync(MemberOrderHistory);
await safeSync(ClubTeam);

View File

@@ -4,6 +4,7 @@ import { checkAccess, getUserByToken, hasUserClubAccess } from "../utils/userUti
import Member from "../models/Member.js";
import MemberImage from "../models/MemberImage.js";
import MemberTtrHistory from "../models/MemberTtrHistory.js";
import MemberPlayInterest from "../models/MemberPlayInterest.js";
import Participant from "../models/Participant.js";
import DiaryDate from "../models/DiaryDates.js";
import { Op, fn, col } from 'sequelize';
@@ -14,6 +15,64 @@ import sharp from 'sharp';
import { devLog } from '../utils/logger.js';
import { standardizePhoneNumber } from '../utils/phoneUtils.js';
class MemberService {
async getMemberPlayInterests(userToken, clubId, seasonId, lineupHalf) {
await checkAccess(userToken, clubId);
if (!seasonId || !['first_half', 'second_half'].includes(String(lineupHalf || ''))) {
return {
status: 400,
response: { error: 'invalidplayinterestparams' }
};
}
const rows = await MemberPlayInterest.findAll({
where: {
clubId,
seasonId,
lineupHalf,
interested: true
},
attributes: ['memberId', 'seasonId', 'lineupHalf', 'interested']
});
return {
status: 200,
response: rows.map((row) => row.toJSON())
};
}
async setMemberPlayInterest(userToken, clubId, memberId, seasonId, lineupHalf, interested = true) {
await checkAccess(userToken, clubId);
if (!memberId || !seasonId || !['first_half', 'second_half'].includes(String(lineupHalf || ''))) {
return {
status: 400,
response: { error: 'invalidplayinterestparams' }
};
}
const member = await Member.findOne({ where: { id: memberId, clubId } });
if (!member) {
return {
status: 404,
response: { error: 'membernotfound' }
};
}
const [row] = await MemberPlayInterest.findOrCreate({
where: { clubId, memberId, seasonId, lineupHalf },
defaults: { interested: !!interested }
});
if (row.interested !== !!interested) {
row.interested = !!interested;
await row.save();
}
return {
status: 200,
response: { result: 'success' }
};
}
async getApprovalRequests(userToken, clubId) {
await checkAccess(userToken, clubId);
const user = await getUserByToken(userToken);

View File

@@ -263,12 +263,13 @@ export default {
background: white;
border-radius: 999px;
padding: 0.4rem 0.75rem;
color: var(--text-color) !important;
}
.team-filter-chip.active {
background: var(--primary-light);
border-color: var(--primary-color);
color: var(--primary-dark);
color: var(--primary-dark) !important;
}
.team-search-input {

View File

@@ -0,0 +1,346 @@
<template>
<section class="team-planning-board">
<div class="team-planning-head">
<div>
<h3>{{ t('teamManagement.planningTitle') }}</h3>
<p>{{ t('teamManagement.planningSubtitle') }}</p>
</div>
<div class="team-planning-actions">
<input
:value="memberSearchQuery"
type="search"
class="team-search-input"
:placeholder="t('teamManagement.searchMembers')"
@input="$emit('update:member-search-query', $event.target.value)"
>
<button type="button" class="btn-secondary" @click="$emit('add-planning-team')">
{{ t('teamManagement.addPlanningTeam') }}
</button>
</div>
</div>
<div class="team-planning-quick-assign">
<select v-model="memberSortMode" class="planning-select planning-sort-select">
<option value="firstLast">{{ t('teamManagement.sortFirstLast') }}</option>
<option value="lastFirst">{{ t('teamManagement.sortLastFirst') }}</option>
</select>
<select v-model="selectedMemberId" class="planning-select">
<option value="">{{ t('teamManagement.selectMember') }}</option>
<option v-for="member in sortedSelectableMembers" :key="member.id" :value="String(member.id)">
{{ formatMemberLabel(member) }}
</option>
</select>
<button
type="button"
class="btn-secondary"
:disabled="!selectedMemberId || markingInterest"
@click="markSelectedMemberInterested"
>
{{ markingInterest ? t('common.saving') : t('teamManagement.markAsInterested') }}
</button>
</div>
<div class="team-planning-grid">
<TeamPlanningLane
lane-id="pool"
:title="t('teamManagement.playersWantToPlay')"
:subtitle="t('teamManagement.playersPoolSubtitle')"
:members="poolMembers"
:show-actions="false"
:empty-label="t('teamManagement.noMembersInPool')"
@drag-start="$emit('drag-member', $event)"
/>
<div class="team-planning-teams">
<article v-for="team in plannedTeams" :key="team.id" class="team-planning-team-card">
<div class="team-planning-team-head">
<input
:value="team.name"
type="text"
class="planning-team-name-input"
:placeholder="t('teamManagement.teamName')"
@input="$emit('update-team-field', team.id, 'name', $event.target.value)"
>
<button type="button" class="btn-secondary btn-upload-sm" @click="$emit('remove-planning-team', team.id)">×</button>
</div>
<div v-if="getTeamStatusLabel(team.id)" class="planning-team-status" :class="`state-${getTeamStatusState(team.id)}`">
{{ getTeamStatusLabel(team.id) }}
</div>
<div class="planning-team-meta-grid">
<label class="planning-team-field">
<span>{{ t('teamManagement.teamAgeGroup') }}</span>
<select
:value="team.teamAgeGroup || 'adult'"
class="planning-select"
@change="$emit('update-team-field', team.id, 'teamAgeGroup', $event.target.value)"
>
<option v-for="option in teamAgeGroupOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<label class="planning-team-field">
<span>{{ t('teamManagement.teamGender') }}</span>
<select
:value="team.teamGender || 'open'"
class="planning-select"
@change="$emit('update-team-field', team.id, 'teamGender', $event.target.value)"
>
<option v-for="option in teamGenderOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
</div>
<label class="planning-team-field">
<span>{{ t('teamManagement.plannedLeague') }}</span>
<input
:value="team.plannedLeagueName || ''"
type="text"
class="planning-team-league-input"
:placeholder="t('teamManagement.plannedLeaguePlaceholder')"
@input="$emit('update-team-field', team.id, 'plannedLeagueName', $event.target.value)"
>
</label>
<TeamPlanningLane
:lane-id="team.id"
:title="team.name || t('teamManagement.teamName')"
:subtitle="team.plannedLeagueName || t('teamManagement.noPlannedLeague')"
:members="team.members"
:empty-label="t('teamManagement.lineupEmpty')"
@drop-member="$emit('drop-member-to-team', $event)"
@drag-start="$emit('drag-member', $event)"
@move-member="$emit('move-member-inside-team', $event)"
@remove-member="$emit('remove-member-from-team', $event)"
/>
</article>
</div>
<TeamPlanningLane
lane-id="unassigned"
:title="t('teamManagement.unassignedMembers')"
:subtitle="t('teamManagement.unassignedMembersSubtitle')"
:members="unassignedMembers"
:empty-label="t('teamManagement.allMembersAssigned')"
@drop-member="$emit('drop-member-to-unassigned')"
@drag-start="$emit('drag-member', $event)"
/>
</div>
</section>
</template>
<script>
import TeamPlanningLane from './TeamPlanningLane.vue';
export default {
name: 'TeamPlanningBoard',
components: { TeamPlanningLane },
props: {
t: { type: Function, required: true },
poolMembers: { type: Array, required: true },
selectableMembers: { type: Array, default: () => [] },
plannedTeams: { type: Array, required: true },
teamAgeGroupOptions: { type: Array, default: () => [] },
teamGenderOptions: { type: Array, default: () => [] },
teamSaveStatusMap: { type: Object, default: () => ({}) },
unassignedMembers: { type: Array, required: true },
memberSearchQuery: { type: String, required: true },
markingInterest: { type: Boolean, default: false }
},
data() {
return {
selectedMemberId: '',
memberSortMode: 'firstLast'
};
},
computed: {
sortedSelectableMembers() {
const source = this.selectableMembers.length ? this.selectableMembers : this.poolMembers;
const normalized = [...source];
const collator = new Intl.Collator('de', { sensitivity: 'base' });
const getFirstName = (member) => String(member?.firstName ?? member?.firstname ?? '').trim();
const getLastName = (member) => String(member?.lastName ?? member?.lastname ?? '').trim();
if (this.memberSortMode === 'lastFirst') {
return normalized.sort((a, b) => {
const byLast = collator.compare(getLastName(a), getLastName(b));
if (byLast !== 0) return byLast;
const byFirst = collator.compare(getFirstName(a), getFirstName(b));
if (byFirst !== 0) return byFirst;
return Number(a?.id || 0) - Number(b?.id || 0);
});
}
return normalized.sort((a, b) => {
const byFirst = collator.compare(getFirstName(a), getFirstName(b));
if (byFirst !== 0) return byFirst;
const byLast = collator.compare(getLastName(a), getLastName(b));
if (byLast !== 0) return byLast;
return Number(a?.id || 0) - Number(b?.id || 0);
});
}
},
emits: [
'update:member-search-query',
'add-planning-team',
'drag-member',
'drop-member-to-team',
'drop-member-to-unassigned',
'move-member-inside-team',
'remove-member-from-team',
'update-team-field',
'remove-planning-team',
'mark-member-interested'
],
methods: {
formatMemberLabel(member) {
const firstName = String(member?.firstName ?? member?.firstname ?? '').trim();
const lastName = String(member?.lastName ?? member?.lastname ?? '').trim();
if (this.memberSortMode === 'lastFirst') {
return [lastName, firstName].filter(Boolean).join(', ');
}
return [firstName, lastName].filter(Boolean).join(' ');
},
getTeamStatusState(teamId) {
return this.teamSaveStatusMap?.[String(teamId)]?.state || '';
},
getTeamStatusLabel(teamId) {
const state = this.getTeamStatusState(teamId);
if (state === 'saving') return this.t('common.saving');
if (state === 'saved') return this.t('teamManagement.autoSaved');
if (state === 'error') return this.t('teamManagement.autoSaveError');
return '';
},
markSelectedMemberInterested() {
if (!this.selectedMemberId) return;
this.$emit('mark-member-interested', {
memberId: this.selectedMemberId
});
this.selectedMemberId = '';
}
}
};
</script>
<style scoped>
.team-planning-board {
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
background: var(--background-light);
padding: 0.9rem;
}
.team-planning-head {
display: flex;
justify-content: space-between;
gap: 0.9rem;
align-items: flex-start;
margin-bottom: 0.85rem;
}
.team-planning-head h3 {
margin: 0;
}
.team-planning-head p {
margin: 0.2rem 0 0;
color: var(--text-muted);
}
.team-planning-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.team-planning-grid {
display: grid;
grid-template-columns: minmax(220px, 1fr) minmax(340px, 2fr) minmax(220px, 1fr);
gap: 0.8rem;
}
.team-planning-quick-assign {
display: grid;
grid-template-columns: minmax(190px, 0.8fr) minmax(260px, 1.3fr) auto;
gap: 0.5rem;
margin-bottom: 0.85rem;
}
.planning-select {
border: 1px solid var(--border-color);
border-radius: var(--border-radius-small);
padding: 0.45rem 0.55rem;
background: var(--surface-color);
color: var(--text-color);
}
.team-planning-teams {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 0.7rem;
}
.team-planning-team-card {
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
background: white;
padding: 0.7rem;
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.team-planning-team-head {
display: flex;
gap: 0.45rem;
}
.planning-team-meta-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.planning-team-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.planning-team-field > span {
font-size: 0.82rem;
color: var(--text-muted);
}
.planning-team-status {
font-size: 0.78rem;
color: var(--text-muted);
min-height: 1rem;
}
.planning-team-status.state-error {
color: #b42318;
}
.planning-team-name-input,
.planning-team-league-input {
width: 100%;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-small);
padding: 0.4rem 0.55rem;
}
@media (max-width: 1200px) {
.team-planning-quick-assign {
grid-template-columns: 1fr;
}
.team-planning-grid {
grid-template-columns: 1fr;
}
.planning-team-meta-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,91 @@
<template>
<section class="planning-lane" @dragover.prevent @drop="$emit('drop-member', laneId)">
<div class="planning-lane-header">
<div>
<strong>{{ title }}</strong>
<div class="planning-lane-subtitle">{{ subtitle }}</div>
</div>
<span class="planning-lane-count">{{ members.length }}</span>
</div>
<div class="planning-lane-list">
<TeamPlanningMemberRow
v-for="(member, index) in members"
:key="member.id"
:member="member"
:show-actions="showActions"
:can-move-up="index > 0"
:can-move-down="index < members.length - 1"
@drag-start="$emit('drag-start', $event)"
@move-up="$emit('move-member', { laneId, memberId: $event.id, direction: 'up' })"
@move-down="$emit('move-member', { laneId, memberId: $event.id, direction: 'down' })"
@remove="$emit('remove-member', { laneId, memberId: $event.id })"
/>
<div v-if="!members.length" class="planning-lane-empty">{{ emptyLabel }}</div>
</div>
</section>
</template>
<script>
import TeamPlanningMemberRow from './TeamPlanningMemberRow.vue';
export default {
name: 'TeamPlanningLane',
components: { TeamPlanningMemberRow },
props: {
laneId: { type: [String, Number], required: true },
title: { type: String, required: true },
subtitle: { type: String, default: '' },
members: { type: Array, required: true },
showActions: { type: Boolean, default: true },
emptyLabel: { type: String, required: true }
},
emits: ['drop-member', 'drag-start', 'move-member', 'remove-member']
};
</script>
<style scoped>
.planning-lane {
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
background: var(--background-light);
padding: 0.7rem;
min-height: 180px;
}
.planning-lane-header {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: flex-start;
margin-bottom: 0.65rem;
}
.planning-lane-subtitle {
font-size: 0.82rem;
color: var(--text-muted);
margin-top: 0.2rem;
}
.planning-lane-count {
display: inline-flex;
min-width: 1.8rem;
justify-content: center;
border-radius: 999px;
border: 1px solid var(--border-color);
background: white;
padding: 0.1rem 0.45rem;
font-size: 0.78rem;
}
.planning-lane-list {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.planning-lane-empty {
color: var(--text-muted);
font-size: 0.88rem;
padding: 0.45rem 0.2rem;
}
</style>

View File

@@ -0,0 +1,62 @@
<template>
<div
class="planning-member-row"
:draggable="draggable"
@dragstart="$emit('drag-start', member)"
>
<div class="planning-member-main">
<strong>{{ member.firstName }} {{ member.lastName }}</strong>
<span class="planning-member-meta">
{{ member.memberAgeGroupLabel || '' }} · {{ member.lineupRatingLabel || '' }}
</span>
</div>
<div v-if="showActions" class="planning-member-actions">
<button type="button" class="btn-secondary btn-upload-sm" :disabled="!canMoveUp" @click="$emit('move-up', member)"></button>
<button type="button" class="btn-secondary btn-upload-sm" :disabled="!canMoveDown" @click="$emit('move-down', member)"></button>
<button type="button" class="btn-secondary btn-upload-sm" @click="$emit('remove', member)"></button>
</div>
</div>
</template>
<script>
export default {
name: 'TeamPlanningMemberRow',
props: {
member: { type: Object, required: true },
draggable: { type: Boolean, default: true },
showActions: { type: Boolean, default: false },
canMoveUp: { type: Boolean, default: true },
canMoveDown: { type: Boolean, default: true }
},
emits: ['drag-start', 'move-up', 'move-down', 'remove']
};
</script>
<style scoped>
.planning-member-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
border: 1px solid var(--border-color);
border-radius: 8px;
background: white;
padding: 0.55rem 0.65rem;
}
.planning-member-main {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.planning-member-meta {
color: var(--text-muted);
font-size: 0.82rem;
}
.planning-member-actions {
display: inline-flex;
gap: 0.35rem;
}
</style>

View File

@@ -59,8 +59,10 @@
"weeks": "Wuche",
"months": "Monat",
"years": "Jahr",
"ok": "OK"
"ok": "OK",
"saving": "Speichere..."
},
"unknown": "Unbekannt",
"navigation": {
"home": "Startseite",
"members": "Mitglider",
@@ -480,6 +482,7 @@
"memberFormHandedOver": "Mitgliedsformular ausgehändigt",
"adultReleaseApproved": "Freigabe Erwachsene",
"adultReserveApproved": "Ersatz bei Erwachsenen",
"wantsToPlay": "Will spiele",
"trainingGroups": "Trainingsgruppen",
"noGroupsAssigned": "Keine Gruppen zugeordnet",
"noGroupsAvailable": "Keine Gruppen verfügbar",
@@ -922,6 +925,40 @@
"toTarget": "nach"
},
"teamManagement": {
"title": "Team-Verwaltig",
"subtitle": "Teams uswähle, Konfiguration prüefe und Ligadate a eim Ort pflege.",
"teams": "Teams",
"season": "Saison",
"seasonUnknown": "unbekannt",
"searchTeams": "Team sueche",
"filterAll": "Alle",
"filterConfigured": "Konfiguriert",
"filterNeedsAttention": "Prüefe",
"filterNoLeague": "Ohni Liga",
"createNewTeam": "Neues Team aalege",
"newTeam": "Neues Team",
"editTeam": "Team bearbeite",
"teamName": "Team-Name",
"noLeague": "Kei Spielklass",
"planningTitle": "Mannschaftsplanig",
"planningSubtitle": "Mitglieder uf geplannti Teams verteile, Riähefoug feschtlege und offeni Zuordnige aluege.",
"searchMembers": "Mitglied sueche",
"playersWantToPlay": "Wänd spiele",
"playersPoolSubtitle": "Alli aktive Mitglieder mit Spielinteresse",
"unassignedMembers": "No nid zugeordnet",
"unassignedMembersSubtitle": "Mitglieder ohni Team-Zuordnig",
"allMembersAssigned": "Alli Mitglieder sind aktuell zugeordnet.",
"addPlanningTeam": "Planigs-Team hinzuefüege",
"noMembersInPool": "Kei passende Mitglieder gfunde.",
"noPlannedLeague": "Kei geplannti Spielklass",
"selectMember": "Mitglied uswähle",
"selectTeam": "Team uswähle",
"assignMemberToTeam": "Zum Team hinzuefüege",
"markAsInterested": "Als interessiert markiere",
"autoSaved": "Automatisch gspeicheret",
"autoSaveError": "Automatischs Speichere isch fehlgschlage",
"sortFirstLast": "Sortierig: Vorname Nachname",
"sortLastFirst": "Sortierig: Nachname Vorname",
"lineupProposal": "Mannschaftsmeldung nach QTTR",
"eligibility": "Einsatz",
"eligibilityRegular": "Regulär",

View File

@@ -59,8 +59,10 @@
"months": "Monate",
"years": "Jahre",
"ok": "OK",
"period": "Zeitraum"
"period": "Zeitraum",
"saving": "Speichere..."
},
"unknown": "Unbekannt",
"navigation": {
"home": "Startseite",
"members": "Mitglieder",
@@ -228,6 +230,7 @@
"memberFormHandedOver": "Mitgliedsformular ausgehändigt",
"adultReleaseApproved": "Freigabe Erwachsene",
"adultReserveApproved": "Ersatz bei Erwachsenen",
"wantsToPlay": "Will spielen",
"trainingGroups": "Trainingsgruppen",
"noGroupsAssigned": "Keine Gruppen zugeordnet",
"noGroupsAvailable": "Keine Gruppen verfügbar",
@@ -1432,6 +1435,25 @@
"parseUrlAction": "URL prüfen",
"myTischtennisUrlPlaceholder": "MyTischtennis URL...",
"teams": "Teams",
"planningTitle": "Mannschaftsplanung",
"planningSubtitle": "Mitglieder auf geplante Teams verteilen, Reihenfolge festlegen und offene Zuordnungen sehen.",
"searchMembers": "Mitglied suchen",
"playersWantToPlay": "Wollen spielen",
"playersPoolSubtitle": "Alle aktiven Mitglieder mit Spielinteresse",
"unassignedMembers": "Noch nicht zugeordnet",
"unassignedMembersSubtitle": "Mitglieder ohne Team-Zuordnung",
"allMembersAssigned": "Alle Mitglieder sind aktuell zugeordnet.",
"addPlanningTeam": "Planungs-Team hinzufügen",
"selectMember": "Mitglied auswählen",
"selectTeam": "Team auswählen",
"assignMemberToTeam": "Zu Team hinzufügen",
"markAsInterested": "Als interessiert markieren",
"autoSaved": "Automatisch gespeichert",
"autoSaveError": "Automatisches Speichern fehlgeschlagen",
"sortFirstLast": "Sortierung: Vorname Nachname",
"sortLastFirst": "Sortierung: Nachname Vorname",
"noMembersInPool": "Keine passenden Mitglieder gefunden.",
"noPlannedLeague": "Keine geplante Spielklasse",
"activeTeam": "Aktives Team",
"searchTeams": "Team suchen",
"openInWorkspace": "Zum Bearbeiten öffnen",

View File

@@ -30,7 +30,40 @@
@show-pdf-dialog="showPDFDialog"
/>
<div class="newteam">
<div class="team-management-mode-switcher">
<button type="button" class="workspace-section-button" :class="{ active: activeMainSection === 'overview' }" @click="activeMainSection = 'overview'">
{{ t('teamManagement.teams') }}
</button>
<button type="button" class="workspace-section-button" :class="{ active: activeMainSection === 'planning' }" @click="activeMainSection = 'planning'">
{{ t('teamManagement.planningTitle') }}
</button>
</div>
<TeamPlanningBoard
v-if="activeMainSection === 'planning'"
:t="t"
:pool-members="planningPoolMembers"
:selectable-members="planningSelectableMembers"
:planned-teams="planningTeamsWithMembers"
:team-age-group-options="teamAgeGroupOptions"
:team-gender-options="teamGenderOptions"
:team-save-status-map="planningTeamSaveStatus"
:unassigned-members="planningUnassignedMembers"
:member-search-query="planningMemberSearchQuery"
:marking-interest="markingPlanningInterest"
@update:member-search-query="planningMemberSearchQuery = $event"
@add-planning-team="addPlanningTeam"
@drag-member="onPlanningDragMember"
@drop-member-to-team="onPlanningDropToTeam"
@drop-member-to-unassigned="onPlanningDropToUnassigned"
@move-member-inside-team="onPlanningMoveInsideTeam"
@remove-member-from-team="onPlanningRemoveFromTeam"
@mark-member-interested="onPlanningMarkMemberInterested"
@update-team-field="updatePlanningTeamField"
@remove-planning-team="removePlanningTeam"
/>
<div v-if="activeMainSection === 'overview'" class="newteam">
<div class="toggle-new-team">
<span @click="toggleNewTeam">
<span class="add">{{ teamFormIsOpen ? '-' : '+' }}</span>
@@ -580,6 +613,7 @@ import InfoDialog from '../components/InfoDialog.vue';
import ConfirmDialog from '../components/ConfirmDialog.vue';
import TeamListCard from '../components/team/TeamListCard.vue';
import TeamManagementOverview from '../components/team/TeamManagementOverview.vue';
import TeamPlanningBoard from '../components/team/TeamPlanningBoard.vue';
import { buildInfoConfig, buildConfirmConfig } from '../utils/dialogUtils.js';
import PDFGenerator from '../components/PDFGenerator.js';
@@ -590,7 +624,8 @@ export default {
InfoDialog,
ConfirmDialog,
TeamListCard,
TeamManagementOverview
TeamManagementOverview,
TeamPlanningBoard
},
setup() {
const store = useStore();
@@ -635,8 +670,18 @@ export default {
const parsingDocuments = ref({});
const teamSearchQuery = ref('');
const teamFilter = ref('all');
const activeMainSection = ref('overview');
const activeEditorSection = ref('basic');
const showGlobalJobDetails = ref(false);
const planningMemberSearchQuery = ref('');
const planningDraggingMemberId = ref(null);
const planningAssignments = ref([]);
const planningLocalTeams = ref([]);
const planningTeamSaveStatus = ref({});
const markingPlanningInterest = ref(false);
const planningInterestedMemberIds = ref(new Set());
const planningAutosaveTimers = new Map();
const planningStatusClearTimers = new Map();
// PDF-Dialog Variablen
const showPDFViewer = ref(false);
@@ -696,6 +741,10 @@ export default {
value,
label: value === 'adult' ? t('members.adults') : t(`members.${value.toLowerCase()}`)
})));
const teamGenderOptions = computed(() => ([
{ value: 'open', label: t('teamManagement.teamGenderOpen') },
{ value: 'female', label: t('teamManagement.teamGenderFemale') }
]));
const filteredTeams = computed(() => {
const search = teamSearchQuery.value.trim().toLowerCase();
return teams.value.filter(team => {
@@ -714,6 +763,66 @@ export default {
return true;
});
});
const planningMemberSearchNeedle = computed(() => planningMemberSearchQuery.value.trim().toLowerCase());
const planningMemberKey = (id) => String(id ?? '');
const planningMemberById = computed(() => {
const map = new Map();
(clubMembers.value || []).forEach((member) => {
const ageCode = getMemberAgeGroupCode(member);
map.set(planningMemberKey(member.id), {
...member,
memberAgeGroupLabel: getMemberAgeGroupLabel(ageCode),
lineupRatingLabel: getMemberLineupRatingLabel(member)
});
});
return map;
});
const isMemberInterested = (member) => planningInterestedMemberIds.value.has(planningMemberKey(member?.id));
const planningPoolMembersRaw = computed(() => {
return (clubMembers.value || [])
.filter((member) => {
if (!member?.active || member?.testMembership) return false;
return planningInterestedMemberIds.value.has(planningMemberKey(member?.id));
})
.map((member) => planningMemberById.value.get(planningMemberKey(member.id)))
.filter(Boolean);
});
const planningPoolMembers = computed(() => {
const needle = planningMemberSearchNeedle.value;
return planningPoolMembersRaw.value.filter((member) => {
if (!needle) return true;
return `${member.firstName || ''} ${member.lastName || ''}`.toLowerCase().includes(needle);
});
});
const planningSelectableMembers = computed(() => {
const needle = planningMemberSearchNeedle.value;
return (clubMembers.value || [])
.filter((member) => member?.active && !member?.testMembership && !isMemberInterested(member))
.map((member) => planningMemberById.value.get(planningMemberKey(member.id)))
.filter(Boolean)
.filter((member) => {
if (!needle) return true;
return `${member.firstName || ''} ${member.lastName || ''}`.toLowerCase().includes(needle);
});
});
const planningTeamsWithMembers = computed(() => {
return planningLocalTeams.value.map((team) => {
const members = planningAssignments.value
.filter((entry) => Number(entry.teamId) === Number(team.id))
.sort((a, b) => a.position - b.position)
.map((entry) => planningMemberById.value.get(planningMemberKey(entry.memberId)))
.filter(Boolean);
return {
...team,
members
};
});
});
const planningUnassignedMembers = computed(() => {
const assigned = new Set(planningAssignments.value.map((entry) => Number(entry.memberId)));
return planningPoolMembers.value.filter((member) => !assigned.has(Number(member.id)));
});
const parseLeagueAgeGroupCode = (leagueName) => {
const source = String(leagueName || '');
@@ -1001,6 +1110,333 @@ export default {
});
// Methods
const normalizePlanningAssignments = (assignments) => assignments
.filter((entry) => entry && entry.teamId && entry.memberId)
.map((entry, index) => ({
teamId: Number(entry.teamId),
memberId: Number(entry.memberId),
position: Number(entry.position) || (index + 1)
}));
const normalizePlanningTeamAssignments = () => {
const byTeam = new Map();
planningAssignments.value.forEach((entry) => {
const key = Number(entry.teamId);
if (!byTeam.has(key)) {
byTeam.set(key, []);
}
byTeam.get(key).push(entry);
});
const normalized = [];
byTeam.forEach((entries, teamId) => {
entries
.sort((a, b) => a.position - b.position)
.forEach((entry, idx) => {
normalized.push({
teamId,
memberId: Number(entry.memberId),
position: idx + 1
});
});
});
planningAssignments.value = normalized;
};
const loadPlanningInterestedMemberIds = async () => {
if (!selectedClub.value || !selectedSeasonId.value) {
planningInterestedMemberIds.value = new Set();
return;
}
const half = selectedLineupHalf.value || (isSecondHalf.value ? 'second_half' : 'first_half');
try {
const response = await apiClient.get(`/clubmembers/play-interest/${selectedClub.value}`, {
params: {
seasonId: selectedSeasonId.value,
lineupHalf: half
}
});
const rows = Array.isArray(response.data) ? response.data : [];
planningInterestedMemberIds.value = new Set(
rows
.filter((entry) => entry?.interested !== false)
.map((entry) => planningMemberKey(entry.memberId))
.filter(Boolean)
);
} catch (error) {
console.error('Fehler beim Laden der Spielinteressen:', error);
planningInterestedMemberIds.value = new Set();
}
};
const loadPlanningMembers = async () => {
if (!selectedClub.value) return;
try {
const membersResp = await apiClient.get(`/clubmembers/get/${selectedClub.value}/true`);
clubMembers.value = Array.isArray(membersResp.data) ? membersResp.data : [];
} catch (error) {
console.error('Fehler beim Laden der Planungs-Mitglieder:', error);
clubMembers.value = [];
}
};
const loadPlanningAssignments = async () => {
if (!teams.value.length) {
planningAssignments.value = [];
planningLocalTeams.value = [];
return;
}
const half = selectedLineupHalf.value || (isSecondHalf.value ? 'second_half' : 'first_half');
const responses = await Promise.all(teams.value.map(async (team) => {
try {
const resp = await apiClient.get(`/club-teams/${team.id}/lineup`, { params: { half } });
return {
teamId: team.id,
assignments: Array.isArray(resp.data) ? resp.data : []
};
} catch (error) {
return { teamId: team.id, assignments: [] };
}
}));
planningAssignments.value = normalizePlanningAssignments(
responses.flatMap((entry) => entry.assignments.map((a, idx) => ({
teamId: entry.teamId,
memberId: a.memberId,
position: a.position || (idx + 1)
})))
);
planningLocalTeams.value = teams.value.map((team) => ({
id: Number(team.id),
name: team.name || '',
plannedLeagueName: team.plannedLeagueName || '',
teamGender: team.teamGender || 'open',
teamAgeGroup: team.teamAgeGroup || 'adult',
leagueId: team.leagueId || null
}));
};
const onPlanningDragMember = (member) => {
planningDraggingMemberId.value = Number(member?.id);
};
const setPlanningTeamStatus = (teamId, state) => {
const key = String(teamId);
planningTeamSaveStatus.value = {
...planningTeamSaveStatus.value,
[key]: { state }
};
const existingClearTimer = planningStatusClearTimers.get(key);
if (existingClearTimer) {
clearTimeout(existingClearTimer);
planningStatusClearTimers.delete(key);
}
if (state === 'saved') {
const clearTimer = setTimeout(() => {
const next = { ...planningTeamSaveStatus.value };
delete next[key];
planningTeamSaveStatus.value = next;
planningStatusClearTimers.delete(key);
}, 4500);
planningStatusClearTimers.set(key, clearTimer);
}
};
const getPlanningTeamLineupAssignments = (teamId) => (
planningAssignments.value
.filter((entry) => Number(entry.teamId) === Number(teamId))
.sort((a, b) => a.position - b.position)
.map((entry, idx) => ({
memberId: Number(entry.memberId),
position: idx + 1
}))
);
const persistPlanningTeam = async (teamId) => {
const localTeam = planningLocalTeams.value.find((entry) => Number(entry.id) === Number(teamId));
if (!localTeam || !selectedClub.value || !selectedSeasonId.value) return;
const sourceTeam = teams.value.find((entry) => Number(entry.id) === Number(teamId));
if (!sourceTeam) return;
setPlanningTeamStatus(teamId, 'saving');
try {
await apiClient.put(`/club-teams/${teamId}`, {
name: (localTeam.name || sourceTeam.name || '').trim(),
leagueId: sourceTeam.leagueId || null,
seasonId: sourceTeam.seasonId || selectedSeasonId.value,
teamGender: localTeam.teamGender || sourceTeam.teamGender || 'open',
teamAgeGroup: localTeam.teamAgeGroup || sourceTeam.teamAgeGroup || 'adult',
plannedLeagueName: (localTeam.plannedLeagueName || '').trim() || null
});
await apiClient.put(`/club-teams/${teamId}/lineup`, {
assignments: getPlanningTeamLineupAssignments(teamId),
lineupHalf: selectedLineupHalf.value
});
teams.value = teams.value.map((entry) => (
Number(entry.id) === Number(teamId)
? {
...entry,
name: (localTeam.name || sourceTeam.name || '').trim(),
teamGender: localTeam.teamGender || sourceTeam.teamGender || 'open',
teamAgeGroup: localTeam.teamAgeGroup || sourceTeam.teamAgeGroup || 'adult',
plannedLeagueName: (localTeam.plannedLeagueName || '').trim() || null
}
: entry
));
setPlanningTeamStatus(teamId, 'saved');
} catch (error) {
console.error('Fehler beim automatischen Speichern der Team-Planung:', error);
setPlanningTeamStatus(teamId, 'error');
}
};
const schedulePlanningTeamAutosave = (teamId, delayMs = 700) => {
const key = String(teamId);
const existingTimer = planningAutosaveTimers.get(key);
if (existingTimer) {
clearTimeout(existingTimer);
}
const timer = setTimeout(async () => {
planningAutosaveTimers.delete(key);
await persistPlanningTeam(teamId);
}, delayMs);
planningAutosaveTimers.set(key, timer);
};
const onPlanningDropToTeam = (teamId) => {
const memberId = Number(planningDraggingMemberId.value);
const targetTeamId = Number(teamId);
if (!memberId || !targetTeamId) return;
const previousEntry = planningAssignments.value.find((entry) => Number(entry.memberId) === memberId);
const sourceTeamId = previousEntry ? Number(previousEntry.teamId) : null;
const withoutMember = planningAssignments.value.filter((entry) => Number(entry.memberId) !== memberId);
const targetEntries = withoutMember.filter((entry) => Number(entry.teamId) === targetTeamId);
withoutMember.push({ teamId: targetTeamId, memberId, position: targetEntries.length + 1 });
planningAssignments.value = withoutMember;
normalizePlanningTeamAssignments();
schedulePlanningTeamAutosave(targetTeamId, 300);
if (sourceTeamId && sourceTeamId !== targetTeamId) {
schedulePlanningTeamAutosave(sourceTeamId, 300);
}
planningDraggingMemberId.value = null;
};
const onPlanningDropToUnassigned = () => {
const memberId = Number(planningDraggingMemberId.value);
if (!memberId) return;
const sourceEntry = planningAssignments.value.find((entry) => Number(entry.memberId) === memberId);
const sourceTeamId = sourceEntry ? Number(sourceEntry.teamId) : null;
planningAssignments.value = planningAssignments.value.filter((entry) => Number(entry.memberId) !== memberId);
normalizePlanningTeamAssignments();
if (sourceTeamId) {
schedulePlanningTeamAutosave(sourceTeamId, 300);
}
planningDraggingMemberId.value = null;
};
const onPlanningMoveInsideTeam = ({ laneId, memberId, direction }) => {
const teamId = Number(laneId);
const filtered = [...planningAssignments.value].sort((a, b) => a.position - b.position);
const teamEntries = filtered.filter((entry) => Number(entry.teamId) === teamId);
const index = teamEntries.findIndex((entry) => Number(entry.memberId) === Number(memberId));
if (index < 0) return;
const target = direction === 'up' ? index - 1 : index + 1;
if (target < 0 || target >= teamEntries.length) return;
const [entry] = teamEntries.splice(index, 1);
teamEntries.splice(target, 0, entry);
const others = filtered.filter((entry) => Number(entry.teamId) !== teamId);
planningAssignments.value = [
...others,
...teamEntries.map((entry, idx) => ({ ...entry, position: idx + 1 }))
];
normalizePlanningTeamAssignments();
schedulePlanningTeamAutosave(teamId, 300);
};
const onPlanningRemoveFromTeam = ({ laneId, memberId }) => {
planningAssignments.value = planningAssignments.value.filter((entry) => Number(entry.memberId) !== Number(memberId));
normalizePlanningTeamAssignments();
if (laneId) {
schedulePlanningTeamAutosave(Number(laneId), 300);
}
};
const onPlanningMarkMemberInterested = async ({ memberId }) => {
const normalizedMemberId = planningMemberKey(memberId);
if (!normalizedMemberId || markingPlanningInterest.value) return;
if (!selectedClub.value) {
await showInfo(t('messages.warning'), 'Bitte zuerst einen Verein auswählen.', '', 'warning');
return;
}
if (!selectedSeasonId.value) {
await showInfo(t('messages.warning'), 'Bitte zuerst eine Saison auswählen.', '', 'warning');
return;
}
const member = (clubMembers.value || []).find((entry) => planningMemberKey(entry.id) === normalizedMemberId);
if (!member) {
await showInfo(t('messages.warning'), 'Bitte zuerst ein Mitglied auswählen.', '', 'warning');
return;
}
markingPlanningInterest.value = true;
try {
const half = selectedLineupHalf.value || (isSecondHalf.value ? 'second_half' : 'first_half');
await apiClient.post(`/clubmembers/play-interest/${selectedClub.value}`, {
memberId: member.id,
seasonId: selectedSeasonId.value,
lineupHalf: half,
interested: true
});
await loadPlanningInterestedMemberIds();
} catch (error) {
await showInfo(t('messages.error'), t('members.errorSavingMember'), '', 'error');
} finally {
markingPlanningInterest.value = false;
}
};
const updatePlanningTeamField = (teamId, field, value) => {
planningLocalTeams.value = planningLocalTeams.value.map((team) => (
Number(team.id) === Number(teamId) ? { ...team, [field]: value } : team
));
schedulePlanningTeamAutosave(Number(teamId));
};
const addPlanningTeam = async () => {
if (!selectedClub.value || !selectedSeasonId.value) return;
const nextIndex = planningLocalTeams.value.length + 1;
try {
const payload = {
name: `${t('teamManagement.teamName')} ${nextIndex}`,
leagueId: null,
seasonId: selectedSeasonId.value,
teamGender: 'open',
teamAgeGroup: 'adult',
plannedLeagueName: ''
};
await apiClient.post(`/club-teams/club/${selectedClub.value}`, payload);
await loadTeams();
} catch (error) {
await showInfo(t('messages.error'), t('teamManagement.lineupSaveError'), '', 'error');
}
};
const removePlanningTeam = async (teamId) => {
const team = teams.value.find((entry) => Number(entry.id) === Number(teamId));
if (!team) return;
const confirmed = await showConfirm(t('messages.warning'), t('teamManagement.reallyDeleteTeam', { teamName: team.name || '' }), '', 'warning');
if (!confirmed) return;
try {
await apiClient.delete(`/club-teams/${teamId}`);
planningAssignments.value = planningAssignments.value.filter((entry) => Number(entry.teamId) !== Number(teamId));
await loadTeams();
} catch (error) {
await showInfo(t('messages.error'), t('teamManagement.deleteTeamError'), '', 'error');
}
};
const toggleNewTeam = () => {
teamFormIsOpen.value = !teamFormIsOpen.value;
if (!teamFormIsOpen.value) {
@@ -1025,6 +1461,9 @@ export default {
const loadTeams = async () => {
if (!selectedClub.value || !selectedSeasonId.value) {
teams.value = [];
planningLocalTeams.value = [];
planningAssignments.value = [];
return;
}
@@ -1037,6 +1476,9 @@ export default {
// Aktualisiere Job-Informationen, damit Team-Filterung korrekt funktioniert
await loadSchedulerJobsInfo();
await loadPlanningMembers();
await loadPlanningInterestedMemberIds();
await loadPlanningAssignments();
} catch (error) {
console.error('Fehler beim Laden der Club-Teams:', error);
}
@@ -1388,12 +1830,12 @@ export default {
}
};
const onSeasonChange = (season) => {
const onSeasonChange = async (season) => {
if (season) {
currentSeason.value = season;
selectedSeasonId.value = season.id;
loadTeams();
loadLeagues();
await loadTeams();
await loadLeagues();
}
};
@@ -1445,6 +1887,8 @@ export default {
await loadLeagues();
// Lade Job-Informationen
await loadSchedulerJobsInfo();
await loadPlanningMembers();
await loadPlanningInterestedMemberIds();
// Warte kurz, damit SeasonSelector die Saison setzen kann
// Dann lade Teams, falls eine Saison ausgewählt wurde
@@ -2228,6 +2672,12 @@ export default {
if (teamToEdit.value?.id) {
loadTeamLineup();
}
if (activeMainSection.value === 'planning' && planningLocalTeams.value.length) {
loadPlanningAssignments();
}
if (selectedClub.value && selectedSeasonId.value) {
loadPlanningInterestedMemberIds();
}
});
watch(activeEditorSection, () => {
@@ -2240,6 +2690,10 @@ export default {
onBeforeUnmount(() => {
destroyLineupSortable();
planningAutosaveTimers.forEach((timer) => clearTimeout(timer));
planningStatusClearTimers.forEach((timer) => clearTimeout(timer));
planningAutosaveTimers.clear();
planningStatusClearTimers.clear();
});
// Watch selectedSeasonId to load teams when season changes
@@ -2247,8 +2701,13 @@ export default {
if (newSeasonId && selectedClub.value) {
loadTeams();
loadLeagues();
loadPlanningInterestedMemberIds();
}
});
watch(selectedClub, () => {
loadPlanningMembers();
loadPlanningInterestedMemberIds();
});
const validateTeamDocumentFile = async (file, label) => {
if (!file) {
@@ -2305,6 +2764,7 @@ export default {
teamDocuments,
teamSearchQuery,
teamFilter,
activeMainSection,
activeEditorSection,
showGlobalJobDetails,
filteredTeams,
@@ -2337,6 +2797,7 @@ export default {
totalHalfAppearances,
lineupHalfOptions,
teamAgeGroupOptions,
teamGenderOptions,
effectiveTeamAgeGroup,
effectiveTeamGender,
lineupProposalGroups,
@@ -2344,6 +2805,13 @@ export default {
eligibleLineupMembers,
selectedTeamLineupMembers,
availableLineupMembers,
planningPoolMembers,
planningSelectableMembers,
planningTeamsWithMembers,
planningUnassignedMembers,
planningMemberSearchQuery,
planningTeamSaveStatus,
markingPlanningInterest,
teamLineupValidationMessage,
labelAgeGroup,
labelTeamGender,
@@ -2351,8 +2819,10 @@ export default {
resetToNewTeam,
resetNewTeam,
addNewTeam,
addPlanningTeam,
editTeam,
deleteTeam,
removePlanningTeam,
uploadCodeList,
uploadPinList,
loadTeamDocuments,
@@ -2375,6 +2845,13 @@ export default {
refreshPlayerStats,
loadClubMembers,
loadTeamLineup,
onPlanningDragMember,
onPlanningDropToTeam,
onPlanningDropToUnassigned,
onPlanningMoveInsideTeam,
onPlanningRemoveFromTeam,
onPlanningMarkMemberInterested,
updatePlanningTeamField,
downloadLineupPdf,
addMemberToLineup,
removeMemberFromLineup,
@@ -2524,6 +3001,12 @@ export default {
margin-bottom: 1rem;
}
.team-management-mode-switcher {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.toggle-new-team {
display: flex;
align-items: center;
@@ -2805,7 +3288,9 @@ label.field-needs-attention span:first-of-type,
}
.form-actions button:not(.cancel-action):disabled {
background: var(--text-muted);
background: var(--border-color);
color: var(--text-color);
opacity: 0.8;
cursor: not-allowed;
}
@@ -2864,7 +3349,7 @@ label.field-needs-attention span:first-of-type,
.team-filter-chip {
border: 1px solid var(--border-color);
background: var(--surface-muted);
color: var(--text-color);
color: var(--text-color) !important;
border-radius: 999px;
padding: 0.35rem 0.75rem;
font-size: 0.85rem;
@@ -2875,7 +3360,7 @@ label.field-needs-attention span:first-of-type,
.team-filter-chip.active {
background: rgba(47, 122, 95, 0.12);
border-color: var(--primary-soft);
color: var(--primary-strong);
color: var(--primary-strong) !important;
font-weight: 600;
}