feat(MemberPlayInterest): implement play interest management for members
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 38s
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:
@@ -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,
|
||||
|
||||
16
backend/migrations/20260415_create_member_play_interest.sql
Normal file
16
backend/migrations/20260415_create_member_play_interest.sql
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
44
backend/models/MemberPlayInterest.js
Normal file
44
backend/models/MemberPlayInterest.js
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
346
frontend/src/components/team/TeamPlanningBoard.vue
Normal file
346
frontend/src/components/team/TeamPlanningBoard.vue
Normal 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>
|
||||
91
frontend/src/components/team/TeamPlanningLane.vue
Normal file
91
frontend/src/components/team/TeamPlanningLane.vue
Normal 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>
|
||||
62
frontend/src/components/team/TeamPlanningMemberRow.vue
Normal file
62
frontend/src/components/team/TeamPlanningMemberRow.vue
Normal 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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user