From 2dff5221e350a2471870ab0922a276c6da1d0532 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 15 Apr 2026 10:48:10 +0200 Subject: [PATCH] feat(MemberPlayInterest): implement play interest management for members - 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. --- backend/controllers/memberController.js | 39 ++ .../20260415_create_member_play_interest.sql | 16 + backend/models/Member.js | 3 +- backend/models/MemberPlayInterest.js | 44 ++ backend/models/index.js | 6 + backend/routes/memberRoutes.js | 4 + backend/server.js | 3 +- backend/services/memberService.js | 59 +++ .../team/TeamManagementOverview.vue | 3 +- .../src/components/team/TeamPlanningBoard.vue | 346 ++++++++++++ .../src/components/team/TeamPlanningLane.vue | 91 ++++ .../components/team/TeamPlanningMemberRow.vue | 62 +++ frontend/src/i18n/locales/de-CH.json | 39 +- frontend/src/i18n/locales/de.json | 24 +- frontend/src/views/TeamManagementView.vue | 501 +++++++++++++++++- 15 files changed, 1226 insertions(+), 14 deletions(-) create mode 100644 backend/migrations/20260415_create_member_play_interest.sql create mode 100644 backend/models/MemberPlayInterest.js create mode 100644 frontend/src/components/team/TeamPlanningBoard.vue create mode 100644 frontend/src/components/team/TeamPlanningLane.vue create mode 100644 frontend/src/components/team/TeamPlanningMemberRow.vue 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 @@ + + + + + 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 @@ + + + + + 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; }