From f59c7391b8be82765c6317588b078647619a6ae1 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 14 Aug 2026 13:15:26 +0200 Subject: [PATCH] feat(tactics): add functionality for managing tactic plans in official tournaments - Implemented routes for listing, saving, updating, and deleting tactic plans in officialTournamentRoutes.js. - Created OfficialTournamentTacticPlan model to handle tactic plan data. - Developed OfficialTournamentService methods for tactic plan operations including validation and error handling. - Enhanced OfficialTournaments.vue to include a new tactics tab with UI for managing tactic plans. - Added TacticBoard component for visualizing and editing tactic paths. - Updated server.js to synchronize the new tactic plan model. --- .../officialTournamentController.js | 29 ++ .../models/OfficialTournamentTacticPlan.js | 23 ++ backend/models/index.js | 6 + backend/routes/officialTournamentRoutes.js | 5 + backend/server.js | 3 +- backend/services/officialTournamentService.js | 44 +++ .../src/components/tournament/TacticBoard.vue | 256 ++++++++++++++++++ frontend/src/views/OfficialTournaments.vue | 169 +++++++++++- 8 files changed, 519 insertions(+), 16 deletions(-) create mode 100644 backend/models/OfficialTournamentTacticPlan.js create mode 100644 frontend/src/components/tournament/TacticBoard.vue diff --git a/backend/controllers/officialTournamentController.js b/backend/controllers/officialTournamentController.js index 2eaa7884..679642e4 100755 --- a/backend/controllers/officialTournamentController.js +++ b/backend/controllers/officialTournamentController.js @@ -17,6 +17,35 @@ export const updateOfficialTournament = async (req, res) => { } }; +export const listTacticPlans = async (req, res) => { + try { + const { authcode: userToken } = req.headers; const { clubId, tournamentId } = req.params; + await checkAccess(userToken, clubId); + const plans = await officialTournamentService.listTacticPlans(clubId, tournamentId); + if (!plans) return res.status(404).json({ error: 'not found' }); + res.status(200).json(plans); + } catch (e) { res.status(e.status || 500).json({ error: e.message || 'Failed to list tactic plans' }); } +}; + +export const saveTacticPlan = async (req, res) => { + try { + const { authcode: userToken } = req.headers; const { clubId, tournamentId, planId } = req.params; + await checkAccess(userToken, clubId); + const plan = await officialTournamentService.saveTacticPlan(clubId, tournamentId, planId, req.body); + res.status(planId ? 200 : 201).json(plan); + } catch (e) { res.status(e.status || 500).json({ error: e.message || 'Failed to save tactic plan' }); } +}; + +export const deleteTacticPlan = async (req, res) => { + try { + const { authcode: userToken } = req.headers; const { clubId, tournamentId, planId } = req.params; + await checkAccess(userToken, clubId); + const deleted = await officialTournamentService.deleteTacticPlan(clubId, tournamentId, planId); + if (!deleted) return res.status(404).json({ error: 'not found' }); + res.status(204).send(); + } catch (e) { res.status(e.status || 500).json({ error: e.message || 'Failed to delete tactic plan' }); } +}; + export const uploadTournamentPdf = async (req, res) => { try { const { authcode: userToken } = req.headers; diff --git a/backend/models/OfficialTournamentTacticPlan.js b/backend/models/OfficialTournamentTacticPlan.js new file mode 100644 index 00000000..c41da4a3 --- /dev/null +++ b/backend/models/OfficialTournamentTacticPlan.js @@ -0,0 +1,23 @@ +import { DataTypes } from 'sequelize'; +import sequelize from '../database.js'; + +const OfficialTournamentTacticPlan = sequelize.define('OfficialTournamentTacticPlan', { + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + tournamentId: { type: DataTypes.INTEGER, allowNull: false }, + competitionId: { type: DataTypes.INTEGER, allowNull: false }, + memberId: { type: DataTypes.INTEGER, allowNull: false }, + round: { type: DataTypes.STRING, allowNull: true }, + opponentName: { type: DataTypes.STRING, allowNull: true }, + ownStrengths: { type: DataTypes.TEXT, allowNull: true }, + ownWeaknesses: { type: DataTypes.TEXT, allowNull: true }, + opponentStrengths: { type: DataTypes.TEXT, allowNull: true }, + opponentWeaknesses: { type: DataTypes.TEXT, allowNull: true }, + drawingData: { type: DataTypes.TEXT('medium'), allowNull: true }, +}, { + tableName: 'official_tournament_tactic_plans', + timestamps: true, + underscored: true, + indexes: [{ fields: ['tournament_id'] }, { fields: ['competition_id', 'member_id'] }], +}); + +export default OfficialTournamentTacticPlan; diff --git a/backend/models/index.js b/backend/models/index.js index caa32d53..3d6ba0ab 100755 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -41,6 +41,7 @@ import UserToken from './UserToken.js'; import OfficialTournament from './OfficialTournament.js'; import OfficialCompetition from './OfficialCompetition.js'; import OfficialCompetitionMember from './OfficialCompetitionMember.js'; +import OfficialTournamentTacticPlan from './OfficialTournamentTacticPlan.js'; import MyTischtennis from './MyTischtennis.js'; import MyTischtennisUpdateHistory from './MyTischtennisUpdateHistory.js'; import MyTischtennisFetchLog from './MyTischtennisFetchLog.js'; @@ -108,6 +109,10 @@ OfficialTournament.hasMany(OfficialCompetitionMember, { foreignKey: 'tournamentI OfficialCompetitionMember.belongsTo(OfficialTournament, { foreignKey: 'tournamentId', as: 'tournament' }); Member.hasMany(OfficialCompetitionMember, { foreignKey: 'memberId', as: 'officialCompetitionEntries' }); OfficialCompetitionMember.belongsTo(Member, { foreignKey: 'memberId', as: 'member' }); +OfficialTournament.hasMany(OfficialTournamentTacticPlan, { foreignKey: 'tournamentId', as: 'tacticPlans' }); +OfficialTournamentTacticPlan.belongsTo(OfficialTournament, { foreignKey: 'tournamentId', as: 'tournament' }); +OfficialTournamentTacticPlan.belongsTo(OfficialCompetition, { foreignKey: 'competitionId', as: 'competition' }); +OfficialTournamentTacticPlan.belongsTo(Member, { foreignKey: 'memberId', as: 'member' }); User.hasMany(Log, { foreignKey: 'userId' }); Log.belongsTo(User, { foreignKey: 'userId' }); @@ -665,6 +670,7 @@ export { OfficialTournament, OfficialCompetition, OfficialCompetitionMember, + OfficialTournamentTacticPlan, MyTischtennis, MyTischtennisUpdateHistory, MyTischtennisFetchLog, diff --git a/backend/routes/officialTournamentRoutes.js b/backend/routes/officialTournamentRoutes.js index b033f8bb..787c584a 100755 --- a/backend/routes/officialTournamentRoutes.js +++ b/backend/routes/officialTournamentRoutes.js @@ -11,6 +11,7 @@ import { listClubParticipations, updateParticipantStatus, autoRegisterOfficialTournamentParticipants + , listTacticPlans, saveTacticPlan, deleteTacticPlan } from '../controllers/officialTournamentController.js'; import { fetchTournamentSuggestions, listTournamentSuggestions, updateTournamentSuggestion } from '../controllers/tournamentSuggestionController.js'; @@ -25,6 +26,10 @@ router.post('/:clubId/suggestions/fetch', fetchTournamentSuggestions); router.patch('/:clubId/suggestions/:id', updateTournamentSuggestion); router.get('/:clubId/participations/summary', listClubParticipations); router.post('/:clubId/upload', upload.single('pdf'), uploadTournamentPdf); +router.get('/:clubId/:tournamentId/tactics', listTacticPlans); +router.post('/:clubId/:tournamentId/tactics', saveTacticPlan); +router.patch('/:clubId/:tournamentId/tactics/:planId', saveTacticPlan); +router.delete('/:clubId/:tournamentId/tactics/:planId', deleteTacticPlan); router.get('/:clubId/:id', getParsedTournament); router.patch('/:clubId/:id', updateOfficialTournament); router.delete('/:clubId/:id', deleteOfficialTournament); diff --git a/backend/server.js b/backend/server.js index 767818c0..800e5572 100755 --- 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, MemberPlayInterest, + TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, OfficialTournamentTacticPlan, MyTischtennis, ClickTtAccount, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact, MemberTtrHistory, MemberPlayInterest, MemberOrder, MemberOrderHistory, MemberGroupPhoto, BillingTemplate, BillingTemplateField, BillingRun, BillingDocument, BillingDocumentValue, BillingUserSetting, FriendlyMatch, TrainingCancellation , FriendlyMatchShared, FriendlyMatchInvitation , CalendarEvent, ClubVenue, ClubRequest, ClubRequestNote, ClubSepaMandate, ClubPaymentClaim, ClubAccount, ClubAccountTransaction, ClubInvoiceParty, ClubInvoice, ClubInvoiceItem, ClubRole, ClubUserRole, ClubCommunicationThread, ClubCommunicationMessage, ClubCommunicationRecipient, ClubCommunicationDeliveryLog, ClubCommunicationTemplate, ClubDistributionGroup, ClubDistributionGroupMember, MemberProfileChangeRequest, MemberEventResponse, NotificationEvent, NotificationRecipient, TournamentSuggestion @@ -630,6 +630,7 @@ app.use((err, req, res, next) => { await safeSync(OfficialTournament); await safeSync(OfficialCompetition); await safeSync(OfficialCompetitionMember); + await safeSync(OfficialTournamentTacticPlan); await safeSync(Season); await safeSync(League); await safeSync(Team); diff --git a/backend/services/officialTournamentService.js b/backend/services/officialTournamentService.js index 44f17ba2..86c695d0 100755 --- a/backend/services/officialTournamentService.js +++ b/backend/services/officialTournamentService.js @@ -6,10 +6,53 @@ import { Op } from 'sequelize'; import OfficialTournament from '../models/OfficialTournament.js'; import OfficialCompetition from '../models/OfficialCompetition.js'; import OfficialCompetitionMember from '../models/OfficialCompetitionMember.js'; +import OfficialTournamentTacticPlan from '../models/OfficialTournamentTacticPlan.js'; import Member from '../models/Member.js'; import OfficialTournamentParserService from './officialTournamentParserService.js'; class OfficialTournamentService { + async listTacticPlans(clubId, tournamentId) { + const tournament = await OfficialTournament.findOne({ where: { id: tournamentId, clubId } }); + if (!tournament) return null; + return OfficialTournamentTacticPlan.findAll({ + where: { tournamentId }, + include: [ + { model: OfficialCompetition, as: 'competition', attributes: ['id', 'ageClassCompetition'] }, + { model: Member, as: 'member', attributes: ['id', 'firstName', 'lastName'] }, + ], + order: [['updatedAt', 'DESC']], + }); + } + + async saveTacticPlan(clubId, tournamentId, planId, payload) { + const { competitionId, memberId } = payload; + if (!competitionId || !memberId) { + const error = new Error('Bitte Spieler/in und Konkurrenz auswählen.'); error.status = 400; throw error; + } + const tournament = await OfficialTournament.findOne({ where: { id: tournamentId, clubId } }); + if (!tournament) { const error = new Error('Turnier nicht gefunden.'); error.status = 404; throw error; } + const competition = await OfficialCompetition.findOne({ where: { id: competitionId, tournamentId } }); + if (!competition) { const error = new Error('Die Konkurrenz gehört nicht zu diesem Turnier.'); error.status = 400; throw error; } + const entry = await OfficialCompetitionMember.findOne({ where: { tournamentId, competitionId, memberId } }); + if (!entry || (!entry.registered && !entry.participated)) { + const error = new Error('Taktikpläne sind nur für angemeldete oder teilnehmende Spieler/innen möglich.'); error.status = 400; throw error; + } + const fields = ['competitionId', 'memberId', 'round', 'opponentName', 'ownStrengths', 'ownWeaknesses', 'opponentStrengths', 'opponentWeaknesses', 'drawingData']; + const values = Object.fromEntries(fields.map((key) => [key, payload[key] ?? null])); + if (planId) { + const plan = await OfficialTournamentTacticPlan.findOne({ where: { id: planId, tournamentId } }); + if (!plan) { const error = new Error('Matchplan nicht gefunden.'); error.status = 404; throw error; } + await plan.update(values); + return plan; + } + return OfficialTournamentTacticPlan.create({ tournamentId, ...values }); + } + + async deleteTacticPlan(clubId, tournamentId, planId) { + const tournament = await OfficialTournament.findOne({ where: { id: tournamentId, clubId } }); + if (!tournament) return null; + return OfficialTournamentTacticPlan.destroy({ where: { id: planId, tournamentId } }); + } async uploadTournamentPdf(clubId, pdfBuffer) { const data = await pdfParse(pdfBuffer); const parsed = OfficialTournamentParserService.parseTournamentText(data.text); @@ -306,6 +349,7 @@ class OfficialTournamentService { async deleteOfficialTournament(clubId, id) { const t = await OfficialTournament.findOne({ where: { id, clubId } }); if (!t) return false; + await OfficialTournamentTacticPlan.destroy({ where: { tournamentId: id } }); await OfficialCompetition.destroy({ where: { tournamentId: id } }); await OfficialTournament.destroy({ where: { id } }); return true; diff --git a/frontend/src/components/tournament/TacticBoard.vue b/frontend/src/components/tournament/TacticBoard.vue new file mode 100644 index 00000000..5eb5ff1e --- /dev/null +++ b/frontend/src/components/tournament/TacticBoard.vue @@ -0,0 +1,256 @@ + + + + + diff --git a/frontend/src/views/OfficialTournaments.vue b/frontend/src/views/OfficialTournaments.vue index 3f7d3ff5..5b00930f 100755 --- a/frontend/src/views/OfficialTournaments.vue +++ b/frontend/src/views/OfficialTournaments.vue @@ -39,7 +39,7 @@ {{ filteredTournamentList.length }} / {{ list.length || 0 }} -