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;

View File

@@ -0,0 +1,256 @@
<template>
<section class="tactic-board" aria-label="Taktikboard">
<div class="board-toolbar">
<div>
<strong>Ballwege planen</strong>
<span class="board-hint" aria-live="polite">{{ selectedPointId ? 'Startpunkt wählen, dann Zielpunkt' : 'Punkt antippen, um einen Ballweg zu starten' }}</span>
</div>
<div class="toolbar-actions">
<button type="button" class="btn-secondary" :disabled="!arrows.length" @click="undo">Letzten Pfeil entfernen</button>
<button type="button" class="btn-secondary" :disabled="!arrows.length && !selectedPointId" @click="clear">Leeren</button>
</div>
</div>
<p v-if="legacyImage" class="legacy-note">Ältere Freihand-Skizze wird im Hintergrund erhalten. Neue Ballwege werden darüber gespeichert.</p>
<div class="court-wrap" :class="{ 'has-legacy-image': legacyImage }">
<div v-if="legacyImage" class="legacy-overlay" :style="{ backgroundImage: `url(${legacyImage})` }" aria-hidden="true"></div>
<div class="side-label side-label-own" aria-hidden="true">Eigene Seite</div>
<div class="side-label side-label-opponent" aria-hidden="true">Gegnerische Seite</div>
<span class="service-line service-line-own" aria-hidden="true"></span>
<span class="service-line service-line-opponent" aria-hidden="true"></span>
<div class="net" aria-hidden="true"><span>Netz</span></div>
<svg class="arrow-layer" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
<g v-for="arrow in displayArrows" :key="arrow.key" class="rally-path">
<path class="rally-line" :d="arrow.path" :stroke="arrow.color" pathLength="100" stroke-dasharray="100" stroke-dashoffset="100">
<animate attributeName="stroke-dashoffset" :dur="arrow.cycle" :keyTimes="arrow.lineKeyTimes" values="100;100;0;0" repeatCount="indefinite" />
</path>
<polygon class="rally-arrowhead" :points="arrow.arrowheadPoints" :fill="arrow.color" opacity="0">
<animate attributeName="opacity" :dur="arrow.cycle" :keyTimes="arrow.headKeyTimes" values="0;0;1;1" repeatCount="indefinite" />
</polygon>
</g>
</svg>
<button
v-for="point in points"
:key="point.id"
type="button"
:class="['target-dot', { selected: selectedPointId === point.id }]"
:style="{ left: `${point.x}%`, top: `${point.y}%` }"
:aria-label="point.ariaLabel"
:aria-pressed="selectedPointId === point.id"
@click="selectPoint(point)"
><span class="dot-core"></span><span class="target-label">{{ point.shortLabel }}</span></button>
</div>
<p class="board-legend"><span><i class="legend-dot"></i> Start/Ziel</span><span><i class="legend-arrow"></i> Ballfolge: Gelb Rot</span><span>kurz · halblang · lang</span></p>
</section>
</template>
<script>
const POSITIONS = [
{ key: 'fh', label: 'Vorhand', short: 'VH', y: 22 },
{ key: 'middle', label: 'Mitte', short: 'M', y: 50 },
{ key: 'rh', label: 'Rückhand', short: 'RH', y: 78 },
];
const DEPTHS = {
own: [
{ key: 'long', label: 'lang', x: 14 },
{ key: 'half-long', label: 'halblang', x: 31 },
{ key: 'short', label: 'kurz', x: 44 },
],
opponent: [
{ key: 'short', label: 'kurz', x: 56 },
{ key: 'half-long', label: 'halblang', x: 69 },
{ key: 'long', label: 'lang', x: 86 },
],
};
function pointList() {
return ['own', 'opponent'].flatMap((side) => DEPTHS[side].flatMap((depth) => POSITIONS.map((position) => {
// The far side is mirrored, so forehand and backhand remain true to each player.
const y = side === 'own' ? position.y : 100 - position.y;
const sideLabel = side === 'own' ? 'eigene Seite' : 'gegnerische Seite';
return {
id: `${side}-${depth.key}-${position.key}`,
x: depth.x,
y,
shortLabel: `${depth.label === 'halblang' ? 'halb' : depth.label} ${position.short}`,
ariaLabel: `${sideLabel}, ${depth.label}, ${position.label}`,
};
})));
}
const POINTS = pointList();
const POINT_BY_ID = Object.fromEntries(POINTS.map((point) => [point.id, point]));
const RALLY_COLORS = ['#ffe04b', '#ffbe35', '#fb932f', '#ef6e32', '#d94a3a'];
const OFFSET_SPACING = 1.9;
const DRAW_LEAD_IN = 0.2;
const DRAW_DURATION = 0.68;
const DRAW_STEP = 0.94;
const LOOP_PAUSE = 1.25;
function validPoint(value) {
if (!value || !POINT_BY_ID[value.id]) return null;
const point = POINT_BY_ID[value.id];
return { id: point.id, x: point.x, y: point.y };
}
function decode(value) {
const empty = { arrows: [], legacyImage: '' };
if (!value || typeof value !== 'string') return empty;
if (value.startsWith('data:image/')) return { ...empty, legacyImage: value };
try {
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== 'object') return empty;
return {
arrows: Array.isArray(parsed.arrows) ? parsed.arrows.map((arrow) => {
const from = validPoint(arrow && arrow.from);
const to = validPoint(arrow && arrow.to);
return from && to ? { from, to } : null;
}).filter(Boolean) : [],
legacyImage: typeof parsed.legacyImage === 'string' && parsed.legacyImage.startsWith('data:image/') ? parsed.legacyImage : '',
};
} catch (_) {
return empty;
}
}
export default {
name: 'TacticBoard',
props: { modelValue: { type: String, default: '' } },
data: () => ({ points: POINTS, arrows: [], selectedPointId: '', legacyImage: '', lastEmittedValue: null }),
watch: {
modelValue: {
immediate: true,
handler(value) {
// Parent updates caused by our own emit must retain the destination as the next start point.
if (value === this.lastEmittedValue) return;
const state = decode(value);
this.arrows = state.arrows;
this.legacyImage = state.legacyImage;
this.selectedPointId = '';
},
},
},
computed: {
// Offsets are display-only. The compact, normalized arrow data remains unchanged for persistence.
displayArrows() {
const cycleSeconds = DRAW_LEAD_IN + Math.max(1, this.arrows.length) * DRAW_STEP + LOOP_PAUSE;
const groups = new Map();
this.arrows.forEach((arrow, index) => {
const groupKey = [arrow.from.id, arrow.to.id].sort().join('|');
if (!groups.has(groupKey)) groups.set(groupKey, []);
groups.get(groupKey).push(index);
});
return this.arrows.map((arrow, index) => {
const groupKey = [arrow.from.id, arrow.to.id].sort().join('|');
const group = groups.get(groupKey);
const groupIndex = group.indexOf(index);
const offset = (groupIndex - (group.length - 1) / 2) * OFFSET_SPACING;
const endpoints = this.offsetEndpoints(arrow.from, arrow.to, offset);
const drawStart = DRAW_LEAD_IN + index * DRAW_STEP;
const drawEnd = drawStart + DRAW_DURATION;
const headStart = Math.min(drawEnd + 0.035, cycleSeconds);
return {
...arrow,
key: `${arrow.from.id}-${arrow.to.id}-${index}`,
color: RALLY_COLORS[index % RALLY_COLORS.length],
path: `M ${endpoints.from.x} ${endpoints.from.y} L ${endpoints.to.x} ${endpoints.to.y}`,
arrowheadPoints: this.arrowheadPoints(endpoints.from, endpoints.to),
cycle: `${cycleSeconds}s`,
lineKeyTimes: `0;${(drawStart / cycleSeconds).toFixed(4)};${(drawEnd / cycleSeconds).toFixed(4)};1`,
headKeyTimes: `0;${(drawEnd / cycleSeconds).toFixed(4)};${(headStart / cycleSeconds).toFixed(4)};1`,
};
});
},
},
methods: {
offsetEndpoints(from, to, offset) {
if (!offset || (from.x === to.x && from.y === to.y)) return { from, to };
// Use a canonical direction for both A→B and B→A. That keeps return balls on
// parallel tracks instead of mirroring them back onto the outgoing route.
const [first, second] = [from, to].sort((a, b) => a.id.localeCompare(b.id));
const dx = second.x - first.x;
const dy = second.y - first.y;
// The court is rendered at 2:1, so calculate the normal in its visual aspect ratio.
const visualLength = Math.hypot(dx * 2, dy);
if (!visualLength) return { from, to };
const shiftX = (-dy / (visualLength * 2)) * offset;
const shiftY = ((dx * 2) / visualLength) * offset;
return {
from: { x: from.x + shiftX, y: from.y + shiftY },
to: { x: to.x + shiftX, y: to.y + shiftY },
};
},
arrowheadPoints(from, to) {
const dx = to.x - from.x;
const dy = to.y - from.y;
const visualLength = Math.hypot(dx * 2, dy);
if (!visualLength) return `${to.x},${to.y}`;
// The SVG is displayed at a 2:1 aspect ratio. Convert the visual arrowhead
// dimensions back into viewBox coordinates so it stays proportional to the line.
const headLength = 3.15;
const headWidth = 2.35;
const baseX = to.x - (dx / visualLength) * (headLength / 2);
const baseY = to.y - (dy / visualLength) * headLength;
const normalX = (-dy / visualLength) * (headWidth / 2);
const normalY = ((dx * 2) / visualLength) * headWidth;
return `${to.x},${to.y} ${baseX + normalX},${baseY + normalY} ${baseX - normalX},${baseY - normalY}`;
},
selectPoint(point) {
if (!this.selectedPointId) {
this.selectedPointId = point.id;
return;
}
const from = POINT_BY_ID[this.selectedPointId];
this.arrows.push({ from: validPoint(from), to: validPoint(point) });
this.selectedPointId = point.id;
this.emitValue();
},
undo() {
if (!this.arrows.length) return;
this.arrows.pop();
this.selectedPointId = '';
this.emitValue();
},
clear() {
if (!this.arrows.length && !this.selectedPointId) return;
this.arrows = [];
this.selectedPointId = '';
// A pre-existing image is deliberately retained for backward compatibility.
this.emitValue();
},
emitValue() {
const payload = {
version: 1,
arrows: this.arrows.map((arrow) => ({ from: validPoint(arrow.from), to: validPoint(arrow.to) })),
};
if (this.legacyImage) payload.legacyImage = this.legacyImage;
this.lastEmittedValue = JSON.stringify(payload);
this.$emit('update:modelValue', this.lastEmittedValue);
},
},
};
</script>
<style scoped>
.tactic-board { border:1px solid #b9cccb; background:#eff5f4; padding:.75rem; }
.board-toolbar { display:flex; align-items:flex-start; justify-content:space-between; gap:.8rem; margin-bottom:.55rem; color:#173c3a; }
.board-toolbar strong,.board-hint { display:block; }.board-hint { color:#5c706d; font-size:.8rem; font-weight:400; margin-top:.13rem; }
.toolbar-actions { display:flex; flex-wrap:wrap; justify-content:flex-end; gap:.38rem; }.toolbar-actions button:disabled { cursor:not-allowed; opacity:.52; }
.legacy-note { margin:0 0 .5rem; color:#6f531d; font-size:.78rem; }
.court-wrap { position:relative; box-sizing:border-box; width:100%; max-width:780px; aspect-ratio:2 / 1; height:auto; margin-inline:auto; overflow:hidden; border:4px solid #f7faf5; background:#145b50; box-shadow:inset 0 0 0 1px rgba(6,42,36,.58), 0 1px 3px rgba(18,51,46,.14); isolation:isolate; }
.court-wrap::before { content:''; position:absolute; z-index:1; inset:11px; border:1.5px solid rgba(255,255,255,.96); pointer-events:none; }
.service-line { position:absolute; z-index:1; top:50%; width:calc(50% - 13px); border-top:1px solid rgba(255,255,255,.78); pointer-events:none; }.service-line-own { left:11px; }.service-line-opponent { right:11px; }
.legacy-overlay { position:absolute; z-index:0; inset:0; background-position:center; background-size:100% 100%; opacity:.3; filter:saturate(.74) contrast(.9); pointer-events:none; }
.side-label { position:absolute; z-index:2; top:10px; color:rgba(255,255,255,.76); font-size:.67rem; font-weight:700; letter-spacing:.05em; text-transform:uppercase; pointer-events:none; }.side-label-own { left:15px; }.side-label-opponent { right:15px; }
.net { position:absolute; z-index:4; top:0; bottom:0; left:50%; border-left:3px solid #132f31; box-shadow:1px 0 0 rgba(255,255,255,.66); pointer-events:none; }.net span { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%) rotate(-90deg); padding:.12rem .22rem; color:#d8e5e1; background:#173f3c; font-size:.62rem; font-weight:700; text-transform:uppercase; letter-spacing:.06em; }
.arrow-layer { position:absolute; z-index:3; inset:0; width:100%; height:100%; pointer-events:none; overflow:visible; }.rally-line { fill:none; stroke-width:1.05; stroke-linecap:round; filter:drop-shadow(0 1px 1px rgba(0,0,0,.32)); }.rally-arrowhead { filter:drop-shadow(0 1px 1px rgba(0,0,0,.32)); }
.target-dot { position:absolute; z-index:5; display:grid; place-items:center; width:2.35rem; height:2.35rem; padding:0; transform:translate(-50%,-50%); border:0; border-radius:50%; background:transparent; color:#eff8f3; font:700 .55rem/1 inherit; cursor:pointer; touch-action:manipulation; }.dot-core { position:absolute; width:.8rem; height:.8rem; border:2px solid #fff; border-radius:50%; background:#1b7667; box-shadow:0 1px 2px rgba(0,0,0,.45); transition:transform .12s ease, background .12s ease; }.target-label { position:absolute; top:calc(100% - .14rem); padding:.05rem .12rem; border-radius:2px; background:rgba(8,46,40,.72); white-space:nowrap; opacity:.9; transform:translateY(0); font-size:.47rem; pointer-events:none; }.target-dot:hover .dot-core,.target-dot:focus-visible .dot-core { transform:scale(1.3); background:#78d6bd; }.target-dot:focus-visible { outline:2px solid #ffd166; outline-offset:2px; }.target-dot.selected .dot-core { transform:scale(1.5); background:#ffc75f; border-color:#fff7df; box-shadow:0 0 0 3px rgba(255,199,95,.3); }
.board-legend { display:flex; flex-wrap:wrap; gap:.8rem; margin:.45rem 0 0; color:#536b67; font-size:.76rem; }.board-legend span { display:inline-flex; align-items:center; gap:.25rem; }.legend-dot { width:.55rem; height:.55rem; border:1px solid #fff; border-radius:50%; background:#1b7667; box-shadow:0 0 0 1px #29675c; }.legend-arrow { color:#bd7916; font-size:1rem; font-style:normal; font-weight:700; line-height:.7; }
@media (max-width:600px) { .board-toolbar { flex-direction:column; }.toolbar-actions { justify-content:flex-start; }.target-dot { width:2.15rem; height:2.15rem; }.side-label { font-size:.59rem; } }
@media (prefers-reduced-motion:reduce) { .rally-line { stroke-dashoffset:0 !important; }.rally-arrowhead { opacity:1 !important; }.rally-path animate { display:none; } }
</style>

View File

@@ -39,7 +39,7 @@
</label>
<span class="toolbar-meta">{{ filteredTournamentList.length }} / {{ list.length || 0 }}</span>
</div>
<ul v-if="filteredTournamentList.length > 0" class="event-list">
<ul v-if="filteredTournamentList.length > 0" class="event-list tournament-list">
<li
v-for="t in filteredTournamentList"
:key="t.id"
@@ -58,15 +58,16 @@
@keydown.esc="cancelTitleEdit"
/>
</template>
<a
v-else
href="#"
class="event-title"
@click.prevent="uploadedId = String(t.id); reload();"
>
{{ t.title || ($t('officialTournaments.tournament') + ' #' + t.id) }}
<div v-else class="event-info">
<a
href="#"
class="event-title"
@click.prevent="uploadedId = String(t.id); reload();"
>
{{ t.title || ($t('officialTournaments.tournament') + ' #' + t.id) }}
</a>
<span v-if="editingTournamentId !== t.id && (t.termin || t.eventDate)" class="event-date"> {{ t.termin || t.eventDate }}</span>
<span v-if="t.termin || t.eventDate" class="event-date">{{ t.termin || t.eventDate }}</span>
</div>
<span v-if="editingTournamentId !== t.id" :class="['list-status-badge', tournamentListStatus(t).className]">
{{ tournamentListStatus(t).label }}
</span>
@@ -225,6 +226,7 @@
<button :class="['tab', activeTab==='overview' ? 'active' : '']" @click="activeTab='overview'" :title="$t('officialTournaments.showParticipants')">Übersicht</button>
<button :class="['tab', activeTab==='competitions' ? 'active' : '']" @click="activeTab='competitions'" :title="$t('officialTournaments.showCompetitions')">{{ $t('officialTournaments.competitions') }}</button>
<button :class="['tab', activeTab==='results' ? 'active' : '']" @click="activeTab='results'" :title="$t('officialTournaments.showResults')">{{ $t('officialTournaments.results') }}</button>
<button :class="['tab', activeTab==='tactics' ? 'active' : '']" @click="openTactics">Taktik</button>
</div>
<div v-if="activeTab==='overview'">
<h3>Teilnehmer</h3>
@@ -425,7 +427,7 @@
</tbody>
</table>
</div>
<div v-else>
<div v-else-if="activeTab==='results'">
<h3>{{ $t('officialTournaments.results') }}</h3>
<table>
<thead>
@@ -478,6 +480,39 @@
</tbody>
</table>
</div>
<div v-else class="tactics-tab">
<div class="tactics-header">
<div><h3>Matchpläne</h3><p>Vorbereitung für einzelne Begegnungen: Gegner einschätzen, eigene Stärken nutzen und den Plan am Tisch festhalten.</p></div>
<button class="btn-primary" @click="newTacticPlan">Neuen Matchplan</button>
</div>
<p v-if="tacticsError" class="tactics-error">{{ tacticsError }}</p>
<div v-if="tacticsLoading" class="empty-state">Matchpläne werden geladen </div>
<div v-else-if="!tacticPlans.length && !tacticPlan" class="empty-state tactic-empty"><strong>Noch kein Matchplan.</strong><span>Markiere Spieler/innen zuerst in der Übersicht als Angemeldet oder Teilgenommen, dann kannst du hier einen Plan anlegen.</span></div>
<div v-else class="tactics-workspace">
<aside class="tactic-list" aria-label="Gespeicherte Matchpläne">
<button v-for="plan in tacticPlans" :key="plan.id" type="button" :class="['tactic-list-item', { active: tacticPlan && String(tacticPlan.id) === String(plan.id) }]" @click="selectTacticPlan(plan)">
<strong>{{ tacticPlanLabel(plan) }}</strong><span>{{ plan.round || 'Runde offen' }} · {{ plan.opponentName || 'Gegner/in offen' }}</span>
</button>
<p v-if="!tacticPlans.length" class="tactic-list-empty">Der neue Matchplan ist noch nicht gespeichert.</p>
</aside>
<section v-if="tacticPlan" class="tactic-editor">
<div class="editor-heading"><h4>{{ tacticPlan.id ? 'Matchplan bearbeiten' : 'Neuer Matchplan' }}</h4><button v-if="tacticPlan.id" class="btn-secondary danger-button" @click="deleteTacticPlan">Löschen</button></div>
<div class="tactic-form-grid">
<label>Eigene/r Spieler/in & Konkurrenz *<select v-model="tacticPlan.selection" @change="applyTacticSelection"><option value="">Bitte auswählen</option><option v-for="option in tacticEligibleOptions" :key="option.value" :value="option.value">{{ option.label }}</option></select></label>
<label>Runde<input v-model.trim="tacticPlan.round" type="text" placeholder="z. B. Gruppe A · Spiel 2" /></label>
<label>Gegner/in<input v-model.trim="tacticPlan.opponentName" type="text" placeholder="Name des Gegners / der Gegnerin" /></label>
</div>
<div class="tactic-notes-grid">
<label class="own-note">Eigene Stärken<textarea v-model="tacticPlan.ownStrengths" placeholder="Was soll konsequent eingesetzt werden?"></textarea></label>
<label class="own-note">Eigene Schwächen<textarea v-model="tacticPlan.ownWeaknesses" placeholder="Worauf achten wir besonders?"></textarea></label>
<label class="opponent-note">Stärken Gegner/in<textarea v-model="tacticPlan.opponentStrengths" placeholder="Gefährliche Aufschläge, Muster, Lieblingsseiten …"></textarea></label>
<label class="opponent-note">Schwächen Gegner/in<textarea v-model="tacticPlan.opponentWeaknesses" placeholder="Wo lässt sich Druck erzeugen?"></textarea></label>
</div>
<TacticBoard :key="tacticPlan.id || 'new-tactic-plan'" v-model="tacticPlan.drawingData" />
<div class="editor-actions"><span v-if="tacticPlanDirty" class="dirty-note">Ungespeicherte Änderungen</span><button class="btn-primary" :disabled="tacticSaving" @click="saveTacticPlan">{{ tacticSaving ? 'Speichert ' : 'Speichern' }}</button></div>
</section>
</div>
</div>
</div>
<div v-else class="workspace-detail empty-workspace">
<div class="empty-workspace-card">
@@ -535,13 +570,15 @@ import PDFGenerator from '../components/PDFGenerator.js';
import BaseDialog from '../components/BaseDialog.vue';
import MemberSelectionDialog from '../components/MemberSelectionDialog.vue';
import TacticBoard from '../components/tournament/TacticBoard.vue';
export default {
name: 'OfficialTournaments',
components: {
InfoDialog,
ConfirmDialog,
BaseDialog,
MemberSelectionDialog
MemberSelectionDialog,
TacticBoard
},
data() {
return {
@@ -586,6 +623,7 @@ export default {
editingTitle: '',
autoRegistering: false,
suggestions: [], fetchingSuggestions: false, suggestionError: '',
tacticPlans: [], tacticPlan: null, tacticPlansSnapshot: '', tacticsLoading: false, tacticSaving: false, tacticsError: '', tacticTournamentId: null, tacticLoadToken: 0, reloadToken: 0,
};
},
computed: {
@@ -966,6 +1004,17 @@ export default {
return Object.values(this.participationMap || {}).filter((entry) =>
entry?.wants && !entry?.registered && !entry?.participated
).length;
},
tacticEligibleOptions() {
const competitions = Object.fromEntries(((this.parsed?.parsedData?.competitions) || []).map((competition) => [String(competition.id), competition]));
return Object.entries(this.participationMap || {}).filter(([, status]) => status?.registered || status?.participated).map(([key]) => {
const [competitionId, memberId] = key.split('-');
const competition = competitions[competitionId] || {};
return { value: key, label: `${this.memberNameById(memberId)} ${competition.ageClassCompetition || competition.altersklasseWettbewerb || 'Konkurrenz'}` };
}).sort((a, b) => this.collator.compare(a.label, b.label));
},
tacticPlanDirty() {
return !!this.tacticPlan && JSON.stringify(this.tacticPlan) !== this.tacticPlansSnapshot;
}
},
methods: {
@@ -1373,12 +1422,88 @@ export default {
},
async reload() {
if (!this.uploadedId) return;
const t = await apiClient.get(`/official-tournaments/${this.currentClub}/${this.uploadedId}`);
const tournamentId = String(this.uploadedId);
const reloadToken = ++this.reloadToken;
if (this.tacticTournamentId !== tournamentId) this.resetTacticsForTournament(tournamentId);
const t = await apiClient.get(`/official-tournaments/${this.currentClub}/${tournamentId}`);
if (reloadToken !== this.reloadToken || String(this.uploadedId) !== tournamentId) return;
this.parsed = t.data;
this.buildParticipationMap(t.data && t.data.participation ? t.data.participation : []);
// Mitglieder laden (alle aktiv)
const m = await apiClient.get(`/clubmembers/get/${this.currentClub}/true`);
if (reloadToken !== this.reloadToken || String(this.uploadedId) !== tournamentId) return;
this.members = m.data;
if (this.activeTab === 'tactics') await this.loadTacticPlans(tournamentId);
},
tacticPlanLabel(plan) {
const member = plan.member ? `${plan.member.firstName || ''} ${plan.member.lastName || ''}`.trim() : this.memberNameById(plan.memberId);
const competition = plan.competition?.ageClassCompetition || this.tacticEligibleOptions.find((option) => option.value === `${plan.competitionId}-${plan.memberId}`)?.label?.split(' ')[1] || 'Konkurrenz';
return `${member} ${competition}`;
},
normalizeTacticPlan(plan) {
return { ...plan, selection: plan ? `${plan.competitionId}-${plan.memberId}` : '', round: plan?.round || '', opponentName: plan?.opponentName || '', ownStrengths: plan?.ownStrengths || '', ownWeaknesses: plan?.ownWeaknesses || '', opponentStrengths: plan?.opponentStrengths || '', opponentWeaknesses: plan?.opponentWeaknesses || '', drawingData: plan?.drawingData || this.emptyTacticDrawingData() };
},
emptyTacticDrawingData() { return JSON.stringify({ version: 1, arrows: [] }); },
resetTacticsForTournament(tournamentId) {
this.tacticLoadToken += 1;
this.tacticTournamentId = tournamentId;
this.tacticPlans = [];
this.tacticPlan = null;
this.tacticPlansSnapshot = '';
this.tacticsLoading = false;
this.tacticSaving = false;
this.tacticsError = '';
},
rememberTacticSnapshot() { this.tacticPlansSnapshot = JSON.stringify(this.tacticPlan); },
async openTactics() { this.activeTab = 'tactics'; await this.loadTacticPlans(); },
async loadTacticPlans(requestedTournamentId = String(this.uploadedId || '')) {
if (!requestedTournamentId) return;
if (this.tacticTournamentId !== requestedTournamentId) this.resetTacticsForTournament(requestedTournamentId);
const loadToken = ++this.tacticLoadToken;
this.tacticsLoading = true; this.tacticsError = '';
try {
const response = await apiClient.get(`/official-tournaments/${this.currentClub}/${requestedTournamentId}/tactics`);
if (loadToken !== this.tacticLoadToken || String(this.uploadedId) !== requestedTournamentId || this.tacticTournamentId !== requestedTournamentId) return;
this.tacticPlans = Array.isArray(response.data) ? response.data : [];
}
catch (error) {
if (loadToken === this.tacticLoadToken && String(this.uploadedId) === requestedTournamentId) this.tacticsError = getSafeErrorMessage(error, 'Matchpläne konnten nicht geladen werden.');
}
finally {
if (loadToken === this.tacticLoadToken && this.tacticTournamentId === requestedTournamentId) this.tacticsLoading = false;
}
},
async newTacticPlan() {
if (!this.tacticEligibleOptions.length) { this.tacticsError = 'Es gibt noch keine angemeldeten oder teilnehmenden Spieler/innen.'; return; }
if (this.tacticPlanDirty && !await this.showConfirm('Änderungen verwerfen?', 'Die ungespeicherten Änderungen dieses Matchplans gehen verloren.', '', 'warning')) return;
this.tacticPlan = this.normalizeTacticPlan(null); this.rememberTacticSnapshot();
},
async selectTacticPlan(plan) {
if (this.tacticPlanDirty && !await this.showConfirm('Änderungen verwerfen?', 'Die ungespeicherten Änderungen dieses Matchplans gehen verloren.', '', 'warning')) return;
this.tacticPlan = this.normalizeTacticPlan(plan); this.rememberTacticSnapshot();
},
applyTacticSelection() {
const [competitionId, memberId] = String(this.tacticPlan.selection || '').split('-'); this.tacticPlan.competitionId = competitionId || null; this.tacticPlan.memberId = memberId || null;
},
async saveTacticPlan() {
const tournamentId = String(this.uploadedId || '');
if (!tournamentId || this.tacticTournamentId !== tournamentId) return;
this.applyTacticSelection(); this.tacticsError = '';
if (!this.tacticPlan.competitionId || !this.tacticPlan.memberId) { this.tacticsError = 'Bitte Spieler/in und Konkurrenz auswählen.'; return; }
this.tacticSaving = true;
try {
const payload = { ...this.tacticPlan }; delete payload.id; delete payload.selection; delete payload.member; delete payload.competition; delete payload.createdAt; delete payload.updatedAt;
const url = `/official-tournaments/${this.currentClub}/${tournamentId}/tactics${this.tacticPlan.id ? `/${this.tacticPlan.id}` : ''}`;
const response = this.tacticPlan.id ? await apiClient.patch(url, payload) : await apiClient.post(url, payload);
if (String(this.uploadedId) !== tournamentId || this.tacticTournamentId !== tournamentId) return;
this.tacticPlan = this.normalizeTacticPlan(response.data); await this.loadTacticPlans(tournamentId); this.rememberTacticSnapshot();
} catch (error) { this.tacticsError = getSafeErrorMessage(error, 'Matchplan konnte nicht gespeichert werden.'); }
finally { this.tacticSaving = false; }
},
async deleteTacticPlan() {
if (!await this.showConfirm('Matchplan löschen', 'Diesen Matchplan wirklich dauerhaft löschen?', '', 'danger')) return;
try { await apiClient.delete(`/official-tournaments/${this.currentClub}/${this.uploadedId}/tactics/${this.tacticPlan.id}`); this.tacticPlan = null; this.tacticPlansSnapshot = ''; await this.loadTacticPlans(); }
catch (error) { this.tacticsError = getSafeErrorMessage(error, 'Matchplan konnte nicht gelöscht werden.'); }
},
async loadList() {
try {
@@ -1927,7 +2052,12 @@ export default {
<style scoped>
.official-tournaments { display: flex; flex-direction: column; gap: 0.75rem; }
.tactics-header { display:flex; align-items:flex-start; justify-content:space-between; gap:1rem; margin:.4rem 0 1rem; }
.tactics-header h3,.editor-heading h4 { margin:0; color:#17366d; }.tactics-header p { margin:.3rem 0 0; color:#64748b; max-width:720px; }
.tactics-workspace { display:grid; grid-template-columns:minmax(205px, .38fr) minmax(0, 1fr); border:1px solid #cbd9e8; background:#fff; min-height:420px; }.tactic-list { padding:.55rem; background:#eef3f8; border-right:1px solid #cbd9e8; display:flex; flex-direction:column; gap:.35rem; }.tactic-list-item { appearance:none; text-align:left; background:#fff; border:1px solid #d5dfe9; padding:.6rem; cursor:pointer; color:#203247; }.tactic-list-item strong,.tactic-list-item span { display:block; }.tactic-list-item span { color:#667789; font-size:.82rem; margin-top:.2rem; }.tactic-list-item.active { border-left:4px solid #d79727; background:#fff9ed; }.tactic-editor { padding:1rem; }.editor-heading,.editor-actions { display:flex; justify-content:space-between; align-items:center; gap:.75rem; margin-bottom:.8rem; }.tactic-form-grid,.tactic-notes-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.75rem; margin-bottom:.85rem; }.tactic-form-grid label,.tactic-notes-grid label { color:#26394a; font-weight:600; font-size:.88rem; }.tactic-form-grid input,.tactic-form-grid select,.tactic-notes-grid textarea { display:block; box-sizing:border-box; width:100%; margin-top:.28rem; border:1px solid #b9c8d7; padding:.52rem; font:inherit; background:#fff; }.tactic-notes-grid textarea { min-height:82px; resize:vertical; }.own-note textarea { border-left:4px solid #d79727; }.opponent-note textarea { border-left:4px solid #dc7865; }.tactics-error { color:#a43d30; background:#fff0eb; border-left:3px solid #dc7865; padding:.6rem; }.dirty-note { color:#9a6511; font-size:.88rem; }.danger-button { color:#9b3326; }.tactic-list-empty { color:#65758a; font-size:.88rem; padding:.3rem; }.tactic-empty { display:flex; flex-direction:column; gap:.35rem; text-align:left; }
@media (max-width:760px) { .tactics-header { flex-direction:column; }.tactics-workspace { grid-template-columns:1fr; }.tactic-list { border-right:0; border-bottom:1px solid #cbd9e8; }.tactic-form-grid,.tactic-notes-grid { grid-template-columns:1fr; } }
.workspace-admin { display: grid; grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); gap: 1rem; align-items: start; }
.workspace-admin > .admin-panel:nth-child(3) { grid-column: 1 / -1; }
.admin-panel { background: #fff; border: 1px solid #dbe3f0; border-radius: 12px; padding: .9rem 1rem; box-shadow: 0 4px 18px rgba(15, 35, 95, 0.05); }
.history-panel { margin-top: .25rem; }
.history-toggle { display: flex; justify-content: space-between; gap: 1rem; align-items: center; margin-bottom: .35rem; }
@@ -1939,14 +2069,19 @@ export default {
.toolbar-checkbox { display: inline-flex; align-items: center; gap: .35rem; color: #556070; font-size: .9rem; white-space: nowrap; }
.toolbar-meta { color: #64748b; font-size: .88rem; white-space: nowrap; }
.event-list { list-style: none; padding: 0; margin: 0; }
.event-item { display: flex; align-items: center; gap: 0.4rem; padding: .3rem .4rem; border-radius: 8px; }
.event-item { display: flex; align-items: center; gap: 0.4rem; min-width: 0; padding: .3rem .4rem; border-radius: 8px; }
.event-item.selected { background: #eef4ff; border: 1px solid #d1defd; }
.event-item.is-past { color: #8c96a5; }
.suggestion-main { display: flex; flex: 1 1 auto; min-width: 0; flex-direction: column; }.suggestion-main span { color: #64748b; font-size: .85rem; }.suggestion-error { color: #b42318; font-size: .9rem; }
.event-item.is-past .event-title,
.event-item.is-past .event-date { color: #8c96a5; }
.event-title { flex: 1; }
.event-date { flex-shrink: 0; }
.tournament-list .event-item { display: grid; grid-template-columns: minmax(0, 1fr) auto auto auto; gap: .4rem; }
.event-info { min-width: 0; display: flex; flex-direction: column; gap: .08rem; }
.event-title { min-width: 0; overflow-wrap: anywhere; }
.event-date { color: #64748b; font-size: .85rem; overflow-wrap: anywhere; }
.tournament-list .btn-icon,
.tournament-list .btn-secondary,
.tournament-list .list-status-badge { flex: 0 0 auto; white-space: nowrap; }
.list-status-badge { display: inline-flex; align-items: center; padding: .16rem .45rem; border-radius: 999px; font-size: .78rem; font-weight: 600; border: 1px solid transparent; white-space: nowrap; }
.list-status-badge.is-active { background: #e8f1ff; color: #2453a6; border-color: #bfd2ff; }
.list-status-badge.is-upcoming { background: #e6f6ea; color: #1e6b34; border-color: #b9e2c4; }
@@ -2187,6 +2322,10 @@ th, td { border-bottom: 1px solid var(--border-color); padding: 0.5rem; text-ali
grid-template-columns: 1fr;
}
.workspace-admin > .admin-panel:nth-child(3) {
grid-column: auto;
}
.history-toggle {
flex-direction: column;
align-items: stretch;