diff --git a/backend/controllers/memberController.js b/backend/controllers/memberController.js
index 7b9222b7..3cf4703c 100644
--- a/backend/controllers/memberController.js
+++ b/backend/controllers/memberController.js
@@ -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,
diff --git a/backend/migrations/20260415_create_member_play_interest.sql b/backend/migrations/20260415_create_member_play_interest.sql
new file mode 100644
index 00000000..2764b0ae
--- /dev/null
+++ b/backend/migrations/20260415_create_member_play_interest.sql
@@ -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;
diff --git a/backend/models/Member.js b/backend/models/Member.js
index 0a4799da..3cf48e2a 100644
--- a/backend/models/Member.js
+++ b/backend/models/Member.js
@@ -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,
diff --git a/backend/models/MemberPlayInterest.js b/backend/models/MemberPlayInterest.js
new file mode 100644
index 00000000..55bc8221
--- /dev/null
+++ b/backend/models/MemberPlayInterest.js
@@ -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;
diff --git a/backend/models/index.js b/backend/models/index.js
index 0b8748d3..7bf85a51 100644
--- a/backend/models/index.js
+++ b/backend/models/index.js
@@ -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,
diff --git a/backend/routes/memberRoutes.js b/backend/routes/memberRoutes.js
index 97cbdba5..98ea50c3 100644
--- a/backend/routes/memberRoutes.js
+++ b/backend/routes/memberRoutes.js
@@ -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);
diff --git a/backend/server.js b/backend/server.js
index 93926681..7366dd1d 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -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);
diff --git a/backend/services/memberService.js b/backend/services/memberService.js
index ace87402..c3938a2b 100644
--- a/backend/services/memberService.js
+++ b/backend/services/memberService.js
@@ -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);
diff --git a/frontend/src/components/team/TeamManagementOverview.vue b/frontend/src/components/team/TeamManagementOverview.vue
index 66749859..e0834904 100644
--- a/frontend/src/components/team/TeamManagementOverview.vue
+++ b/frontend/src/components/team/TeamManagementOverview.vue
@@ -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 {
diff --git a/frontend/src/components/team/TeamPlanningBoard.vue b/frontend/src/components/team/TeamPlanningBoard.vue
new file mode 100644
index 00000000..b71f55d8
--- /dev/null
+++ b/frontend/src/components/team/TeamPlanningBoard.vue
@@ -0,0 +1,346 @@
+
+
+
+
+
{{ t('teamManagement.planningTitle') }}
+
{{ t('teamManagement.planningSubtitle') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/team/TeamPlanningLane.vue b/frontend/src/components/team/TeamPlanningLane.vue
new file mode 100644
index 00000000..d866235c
--- /dev/null
+++ b/frontend/src/components/team/TeamPlanningLane.vue
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/team/TeamPlanningMemberRow.vue b/frontend/src/components/team/TeamPlanningMemberRow.vue
new file mode 100644
index 00000000..3309c610
--- /dev/null
+++ b/frontend/src/components/team/TeamPlanningMemberRow.vue
@@ -0,0 +1,62 @@
+
+
+
+ {{ member.firstName }} {{ member.lastName }}
+
+ {{ member.memberAgeGroupLabel || '–' }} · {{ member.lineupRatingLabel || '–' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/i18n/locales/de-CH.json b/frontend/src/i18n/locales/de-CH.json
index b974d831..e456d360 100644
--- a/frontend/src/i18n/locales/de-CH.json
+++ b/frontend/src/i18n/locales/de-CH.json
@@ -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",
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index fb038b62..273d36ed 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -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",
diff --git a/frontend/src/views/TeamManagementView.vue b/frontend/src/views/TeamManagementView.vue
index 9257a612..23d02bb7 100644
--- a/frontend/src/views/TeamManagementView.vue
+++ b/frontend/src/views/TeamManagementView.vue
@@ -30,7 +30,40 @@
@show-pdf-dialog="showPDFDialog"
/>
-
+
+
+
+
+
+
+
+
{{ teamFormIsOpen ? '-' : '+' }}
@@ -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;
}