feat(tactics): add functionality for managing tactic plans in official tournaments
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 55s

- 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.
This commit is contained in:
Torsten Schulz (local)
2026-08-14 13:15:26 +02:00
parent 3320884cef
commit f59c7391b8
8 changed files with 519 additions and 16 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,

View File

@@ -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);

View File

@@ -13,7 +13,7 @@ import {
DiaryNote, DiaryTag, MemberDiaryTag, DiaryDateTag, DiaryMemberNote, DiaryMemberTag,
PredefinedActivity, PredefinedActivityImage, DiaryDateActivity, DiaryMemberActivity, Match, League, Team, ClubTeam, ClubTeamMember, TeamDocument, Group,
GroupActivity, Tournament, TournamentGroup, TournamentMatch, TournamentResult,
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, ClickTtAccount, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact, MemberTtrHistory, 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);

View File

@@ -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;