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 @@
+
+ Ältere Freihand-Skizze wird im Hintergrund erhalten. Neue Ballwege werden darüber gespeichert. Start/Ziel→ Ballfolge: Gelb → Rotkurz · halblang · lang
Vorbereitung für einzelne Begegnungen: Gegner einschätzen, eigene Stärken nutzen und den Plan am Tisch festhalten.
{{ tacticsError }}
+