From 18ba120927c9b010d005bb05dfd7984c883e6e5d Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 19 Aug 2026 14:02:12 +0200 Subject: [PATCH] feat(kaisertisch): implement Kaisertisch tournament functionality with model, routes, and controller --- backend/controllers/kaisertischController.js | 59 +++++++++ ...0260819_create_kaisertisch_tournaments.sql | 11 ++ backend/models/KaisertischTournament.js | 18 +++ backend/models/index.js | 6 + backend/routes/kaisertischRoutes.js | 10 ++ backend/server.js | 2 + frontend/src/views/KaisertischView.vue | 112 +++++++++++++----- 7 files changed, 191 insertions(+), 27 deletions(-) create mode 100644 backend/controllers/kaisertischController.js create mode 100644 backend/migrations/20260819_create_kaisertisch_tournaments.sql create mode 100644 backend/models/KaisertischTournament.js create mode 100644 backend/routes/kaisertischRoutes.js diff --git a/backend/controllers/kaisertischController.js b/backend/controllers/kaisertischController.js new file mode 100644 index 00000000..8acc9ac0 --- /dev/null +++ b/backend/controllers/kaisertischController.js @@ -0,0 +1,59 @@ +import { DiaryDate, KaisertischTournament } from '../models/index.js'; +import HttpError from '../exceptions/HttpError.js'; + +const normalizeState = (state) => { + if (!state || typeof state !== 'object' || Array.isArray(state)) throw new HttpError('Ungültiger Kaisertisch-Stand', 400); + const tableCount = Math.min(30, Math.max(1, Number(state.tableCount) || 4)); + const tables = Array.isArray(state.tables) ? state.tables.slice(0, tableCount).map((table) => ({ left: table?.left || null, right: table?.right || null })) : []; + return { + tableCount, + tables: tables.length === tableCount ? tables : Array.from({ length: tableCount }, () => ({ left: null, right: null })), + history: Array.isArray(state.history) ? state.history.slice(0, 500) : [], + round: Math.max(0, Number(state.round) || 0), + pendingWinners: state.pendingWinners && typeof state.pendingWinners === 'object' ? state.pendingWinners : {}, + }; +}; + +export const listKaisertischTournaments = async (req, res) => { + try { + const tournaments = await KaisertischTournament.findAll({ + where: { clubId: req.params.clubId }, + include: [{ model: DiaryDate, as: 'diaryDate', attributes: ['id', 'date'] }], + order: [[{ model: DiaryDate, as: 'diaryDate' }, 'date', 'DESC'], ['updatedAt', 'DESC']], + }); + res.json(tournaments.map((tournament) => ({ + diaryDateId: tournament.diaryDateId, + date: tournament.diaryDate?.date || null, + roundCount: Array.isArray(tournament.state?.history) ? tournament.state.history.length : 0, + updatedAt: tournament.updatedAt, + }))); + } catch (error) { + console.error('[listKaisertischTournaments]', error); + res.status(error.statusCode || 500).json({ error: error.message || 'Kaisertisch-Turniere konnten nicht geladen werden.' }); + } +}; + +export const getKaisertischTournament = async (req, res) => { + try { + const tournament = await KaisertischTournament.findOne({ where: { clubId: req.params.clubId, diaryDateId: req.params.dateId } }); + if (!tournament) return res.status(404).json({ error: 'Kein Kaisertisch-Turnier für diesen Trainingstag gefunden.' }); + res.json({ diaryDateId: tournament.diaryDateId, state: tournament.state, updatedAt: tournament.updatedAt }); + } catch (error) { + console.error('[getKaisertischTournament]', error); + res.status(error.statusCode || 500).json({ error: error.message || 'Kaisertisch-Turnier konnte nicht geladen werden.' }); + } +}; + +export const saveKaisertischTournament = async (req, res) => { + try { + const { clubId, dateId } = req.params; + const diaryDate = await DiaryDate.findOne({ where: { id: dateId, clubId } }); + if (!diaryDate) throw new HttpError('Trainingstag nicht gefunden.', 404); + const state = normalizeState(req.body?.state); + const [tournament] = await KaisertischTournament.upsert({ clubId, diaryDateId: dateId, state }); + res.status(200).json({ diaryDateId: Number(dateId), state: tournament?.state || state }); + } catch (error) { + console.error('[saveKaisertischTournament]', error); + res.status(error.statusCode || 500).json({ error: error.message || 'Kaisertisch-Turnier konnte nicht gespeichert werden.' }); + } +}; diff --git a/backend/migrations/20260819_create_kaisertisch_tournaments.sql b/backend/migrations/20260819_create_kaisertisch_tournaments.sql new file mode 100644 index 00000000..6acd03e8 --- /dev/null +++ b/backend/migrations/20260819_create_kaisertisch_tournaments.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS kaisertisch_tournaments ( + id INT AUTO_INCREMENT PRIMARY KEY, + club_id INT NOT NULL, + diary_date_id INT NOT NULL, + state JSON NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uniq_kaisertisch_tournament_club_date (club_id, diary_date_id), + CONSTRAINT fk_kaisertisch_tournament_club FOREIGN KEY (club_id) REFERENCES clubs(id) ON DELETE CASCADE, + CONSTRAINT fk_kaisertisch_tournament_date FOREIGN KEY (diary_date_id) REFERENCES diary_dates(id) ON DELETE CASCADE +); diff --git a/backend/models/KaisertischTournament.js b/backend/models/KaisertischTournament.js new file mode 100644 index 00000000..212b738a --- /dev/null +++ b/backend/models/KaisertischTournament.js @@ -0,0 +1,18 @@ +import { DataTypes } from 'sequelize'; +import sequelize from '../database.js'; +import Club from './Club.js'; +import DiaryDate from './DiaryDates.js'; + +const KaisertischTournament = sequelize.define('KaisertischTournament', { + id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, + clubId: { type: DataTypes.INTEGER, allowNull: false, field: 'club_id', references: { model: Club, key: 'id' }, onDelete: 'CASCADE' }, + diaryDateId: { type: DataTypes.INTEGER, allowNull: false, field: 'diary_date_id', references: { model: DiaryDate, key: 'id' }, onDelete: 'CASCADE' }, + state: { type: DataTypes.JSON, allowNull: false }, +}, { + tableName: 'kaisertisch_tournaments', + underscored: true, + timestamps: true, + indexes: [{ unique: true, fields: ['club_id', 'diary_date_id'] }], +}); + +export default KaisertischTournament; diff --git a/backend/models/index.js b/backend/models/index.js index 3d6ba0ab..abcb2200 100755 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -98,6 +98,7 @@ import MemberProfileChangeRequest from './MemberProfileChangeRequest.js'; import MemberEventResponse from './MemberEventResponse.js'; import NotificationEvent from './NotificationEvent.js'; import NotificationRecipient from './NotificationRecipient.js'; +import KaisertischTournament from './KaisertischTournament.js'; import TournamentSuggestion from './TournamentSuggestion.js'; // Official tournaments relations OfficialTournament.hasMany(OfficialCompetition, { foreignKey: 'tournamentId', as: 'competitions' }); @@ -131,6 +132,10 @@ TournamentSuggestion.belongsTo(Club, { foreignKey: 'clubId', as: 'club' }); DiaryDate.belongsTo(Club, { foreignKey: 'clubId' }); Club.hasMany(DiaryDate, { foreignKey: 'clubId' }); +DiaryDate.hasOne(KaisertischTournament, { foreignKey: 'diaryDateId', as: 'kaisertischTournament' }); +KaisertischTournament.belongsTo(DiaryDate, { foreignKey: 'diaryDateId', as: 'diaryDate' }); +Club.hasMany(KaisertischTournament, { foreignKey: 'clubId', as: 'kaisertischTournaments' }); +KaisertischTournament.belongsTo(Club, { foreignKey: 'clubId', as: 'club' }); DiaryDate.belongsToMany(Member, { through: Participant, as: 'participants', foreignKey: 'diaryDateId' }); Member.belongsToMany(DiaryDate, { through: Participant, as: 'diaryDates', foreignKey: 'memberId' }); @@ -726,6 +731,7 @@ export { MemberEventResponse, NotificationEvent, NotificationRecipient, + KaisertischTournament, TournamentSuggestion, ClubDistributionGroupMember, }; diff --git a/backend/routes/kaisertischRoutes.js b/backend/routes/kaisertischRoutes.js new file mode 100644 index 00000000..0cbc21de --- /dev/null +++ b/backend/routes/kaisertischRoutes.js @@ -0,0 +1,10 @@ +import express from 'express'; +import { authenticate } from '../middleware/authMiddleware.js'; +import { authorize } from '../middleware/authorizationMiddleware.js'; +import { getKaisertischTournament, listKaisertischTournaments, saveKaisertischTournament } from '../controllers/kaisertischController.js'; + +const router = express.Router(); +router.get('/:clubId', authenticate, authorize('diary', 'read'), listKaisertischTournaments); +router.get('/:clubId/:dateId', authenticate, authorize('diary', 'read'), getKaisertischTournament); +router.put('/:clubId/:dateId', authenticate, authorize('diary', 'write'), saveKaisertischTournament); +export default router; diff --git a/backend/server.js b/backend/server.js index 800e5572..534a4932 100755 --- a/backend/server.js +++ b/backend/server.js @@ -21,6 +21,7 @@ import { import authRoutes from './routes/authRoutes.js'; import clubRoutes from './routes/clubRoutes.js'; import diaryRoutes from './routes/diaryRoutes.js'; +import kaisertischRoutes from './routes/kaisertischRoutes.js'; import memberRoutes from './routes/memberRoutes.js'; import participantRoutes from './routes/participantRoutes.js'; import activityRoutes from './routes/activityRoutes.js'; @@ -339,6 +340,7 @@ app.use('/api/auth', authRoutes); app.use('/api/clubs', clubRoutes); app.use('/api/clubmembers', memberRoutes); app.use('/api/diary', diaryRoutes); +app.use('/api/kaisertisch', kaisertischRoutes); app.use('/api/participants', participantRoutes); app.use('/api/activities', activityRoutes); app.use('/api/membernotes', memberNoteRoutes); diff --git a/frontend/src/views/KaisertischView.vue b/frontend/src/views/KaisertischView.vue index fc9faaef..cee8bb74 100644 --- a/frontend/src/views/KaisertischView.vue +++ b/frontend/src/views/KaisertischView.vue @@ -6,9 +6,9 @@

Kaisertisch

Ein Sieg, ein Klick – und der nächste Durchgang ist klar.

-
+
- {{ occupiedSlots === tableCount * 2 ? `Runde ${round + 1} läuft` : `${occupiedSlots} von ${tableCount * 2} Plätzen belegt` }} + {{ isReadOnly ? 'Archiv · Nur ansehen' : (occupiedSlots === tableCount * 2 ? `Runde ${round + 1} läuft` : `${occupiedSlots} von ${tableCount * 2} Plätzen belegt`) }}
@@ -21,7 +21,8 @@ {{ history.length ? `${history.length} Runden` : 'Noch keine Runde' }}
-
+
ArchivansichtDieses Turnier ist schreibgeschützt. Wähle „Aktuelles Turnier“, um weiterzuspielen.
+
{{ pendingWinnerCount }} von {{ activeTableIndexes.length }} Ergebnissen erfasst Unvollständige Tische zuerst vollständig besetzen. @@ -44,7 +45,7 @@
+ {{ tableCount }} {{ tableCount === 1 ? 'Tisch' : 'Tische' }} - +
- - + +

Zwei besetzte Plätze nacheinander antippen.

{{ selectedSeatLabel }} ausgewählt. Nun einen Namen antippen.

@@ -89,22 +90,41 @@

ANWESEND

Teilnehmerpool {{ availablePlayers.length }}

Kein Trainingstag vorhanden. Die Demo-Aufstellung funktioniert trotzdem.
- +

{{ members.length ? 'Alle geladenen Teilnehmenden sind gesetzt.' : 'Für diesen Trainingstag sind keine anwesenden Teilnehmenden gemeldet.' }}

-

VERLAUF

Rundenhistorie

+

VERLAUF

Rundenhistorie

+ +

Abgeschlossene Turniere erscheinen hier automatisch.

Zuletzt: {{ history[0].summary }}

Noch kein Ergebnis erfasst.

  1. Runde {{ entry.round }}{{ entry.summary }}
+
+

Spielerwege

+
+ {{ player.name }} + + + +
+
@@ -131,11 +151,14 @@ const DEMO_PLAYERS = ['Mia Weber', 'Luca Schneider', 'Nora Fischer', 'Jonas Beck export default { name: 'KaisertischView', data() { - return { loading: true, error: '', allMembers: [], members: [], trainingDates: [], selectedDateId: '', tableCount: 4, tables: [], history: [], round: 0, selectedSeat: null, swapMode: false, swapSelection: [], pendingWinners: {}, showRoundConfirm: false, participantRequestId: 0 }; + return { loading: true, error: '', allMembers: [], members: [], trainingDates: [], selectedDateId: '', currentSessionDateId: '', selectedTournamentDateId: 'current', tournamentHistory: [], tableCount: 4, tables: [], history: [], round: 0, selectedSeat: null, swapMode: false, swapSelection: [], pendingWinners: {}, showRoundConfirm: false, participantRequestId: 0, persistenceTimer: null }; }, computed: { ...mapGetters(['currentClub', 'isAuthenticated']), storageKey() { return `kaisertisch-session:${this.currentClub || 'none'}`; }, + sessionStorageKey() { return `${this.storageKey}:${this.currentSessionDateId || 'current'}`; }, + isReadOnly() { return this.selectedTournamentDateId !== 'current'; }, + archivedTournaments() { return this.tournamentHistory.filter(tournament => String(tournament.diaryDateId) !== String(this.currentSessionDateId)); }, occupiedSlots() { return this.tables.reduce((count, table) => count + Number(Boolean(table.left)) + Number(Boolean(table.right)), 0); }, seatedIds() { return new Set(this.tables.flatMap(table => [table.left, table.right]).filter(Boolean)); }, availablePlayers() { return this.members.filter(member => !this.seatedIds.has(member.id)); }, @@ -155,36 +178,68 @@ export default { return { tableIndex, label: this.tableLabel(tableIndex), players: players.map(id => this.playerName(id)).join(' · ') }; }); }, + playerPaths() { + const snapshots = this.history.slice().reverse().map(entry => entry?.before).filter(Array.isArray); + if (!snapshots.length || !Array.isArray(this.tables)) return []; + const pathsByPlayer = new Map(); + + [...snapshots, this.tables].forEach(state => { + const seenInState = new Set(); + state.forEach((table, tableIndex) => { + if (!table || typeof table !== 'object') return; + ['left', 'right'].forEach(side => { + const playerId = table[side]; + if (playerId === null || playerId === undefined || playerId === '') return; + const id = String(playerId); + if (seenInState.has(id)) return; + seenInState.add(id); + const path = pathsByPlayer.get(id) || []; + const label = this.tableLabel(tableIndex); + if (path[path.length - 1] !== label) path.push(label); + pathsByPlayer.set(id, path); + }); + }); + }); + + return [...pathsByPlayer.entries()].map(([id, path]) => ({ id, name: this.playerName(id), path })); + }, }, watch: { - tables: { deep: true, handler() { this.persist(); } }, tableCount() { this.persist(); }, history: { deep: true, handler() { this.persist(); } }, pendingWinners: { deep: true, handler() { this.persist(); } }, selectedDateId() { this.persist(); }, + tables: { deep: true, handler() { this.persist(); } }, tableCount() { this.persist(); }, history: { deep: true, handler() { this.persist(); } }, pendingWinners: { deep: true, handler() { this.persist(); } }, currentClub() { this.loadData(); }, '$route.query.dateId': async function(dateId) { if (!dateId || !this.trainingDates.some(date => String(date.id) === String(dateId))) return; - this.selectedDateId = String(dateId); - await this.loadParticipantsForDate(); + await this.changeTrainingDate(String(dateId)); }, }, methods: { makeTables(count = this.tableCount) { return Array.from({ length: count }, () => ({ left: null, right: null })); }, - playerName(id) { const member = this.members.find(item => String(item.id) === String(id)); return member?.name || `${member?.firstName || ''} ${member?.lastName || ''}`.trim() || String(id); }, + playerName(id) { const member = this.members.find(item => String(item.id) === String(id)) || this.allMembers.find(item => String(item.id) === String(id)); return member?.name || `${member?.firstName || ''} ${member?.lastName || ''}`.trim() || String(id); }, formatMember(member) { return { ...member, name: `${member.firstName || member.firstname || ''} ${member.lastName || member.lastname || ''}`.trim() || member.name || 'Unbekannt' }; }, formatDate(value) { return value ? new Intl.DateTimeFormat('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' }).format(new Date(`${value}T12:00:00`)) : 'Unbekanntes Datum'; }, - restore() { try { const saved = JSON.parse(safeLocalStorage.getItem(this.storageKey) || 'null'); if (!saved) return false; this.tableCount = Math.min(30, Math.max(1, Number(saved.tableCount) || 4)); this.tables = Array.isArray(saved.tables) && saved.tables.length === this.tableCount ? saved.tables : this.makeTables(); this.history = Array.isArray(saved.history) ? saved.history : []; this.round = Number(saved.round) || this.history.length; this.selectedDateId = saved.selectedDateId || ''; this.pendingWinners = saved.pendingWinners && typeof saved.pendingWinners === 'object' ? saved.pendingWinners : {}; return true; } catch (_) { return false; } }, - persist() { if (!this.currentClub || !this.tables.length) return; safeLocalStorage.setItem(this.storageKey, JSON.stringify({ tableCount: this.tableCount, tables: this.tables, history: this.history, round: this.round, selectedDateId: this.selectedDateId, pendingWinners: this.pendingWinners })); }, - async loadData() { this.loading = true; this.error = ''; try { if (!this.currentClub) return; const [membersResponse, datesResponse] = await Promise.all([apiClient.get(`/clubmembers/get/${this.currentClub}/false`), apiClient.get(`/diary/${this.currentClub}`)]); this.allMembers = (Array.isArray(membersResponse.data) ? membersResponse.data : []).map(this.formatMember); this.members = [...this.allMembers]; this.trainingDates = Array.isArray(datesResponse.data) ? datesResponse.data : []; const restored = this.restore(); if (!restored) this.tables = this.makeTables(); const requestedDateId = this.$route?.query?.dateId; if (requestedDateId && this.trainingDates.some(date => String(date.id) === String(requestedDateId))) this.selectedDateId = String(requestedDateId); if (this.selectedDateId) await this.loadParticipantsForDate(); else { const today = new Date().toISOString().slice(0, 10); const todayEntry = this.trainingDates.find(date => date.date === today); if (todayEntry) { this.selectedDateId = String(todayEntry.id); await this.loadParticipantsForDate(); } } } catch (error) { this.error = 'Die Trainingsdaten konnten nicht geladen werden. Deine lokale Aufstellung bleibt erhalten.'; if (!this.tables.length) this.tables = this.makeTables(); } finally { this.loading = false; } }, + formatDateForSelector(id) { return this.formatDate(this.trainingDates.find(date => String(date.id) === String(id))?.date); }, + serializedState() { return { tableCount: this.tableCount, tables: this.tables.map(table => ({ ...table })), history: this.history.map(entry => ({ ...entry, before: Array.isArray(entry.before) ? entry.before.map(table => ({ ...table })) : [] })), round: this.round, pendingWinners: { ...this.pendingWinners } }; }, + applyState(saved) { const tableCount = Math.min(30, Math.max(1, Number(saved?.tableCount) || 4)); this.tableCount = tableCount; this.tables = Array.isArray(saved?.tables) && saved.tables.length === tableCount ? saved.tables.map(table => ({ left: table?.left || null, right: table?.right || null })) : this.makeTables(tableCount); this.history = Array.isArray(saved?.history) ? saved.history : []; this.round = Number(saved?.round) || this.history.length; this.pendingWinners = saved?.pendingWinners && typeof saved.pendingWinners === 'object' ? saved.pendingWinners : {}; this.selectedSeat = null; this.swapMode = false; this.swapSelection = []; }, + localState(dateId = this.currentSessionDateId) { try { return JSON.parse(safeLocalStorage.getItem(`${this.storageKey}:${dateId || 'current'}`) || 'null'); } catch (_) { return null; } }, + persist() { if (!this.currentClub || !this.tables.length || this.isReadOnly) return; const state = this.serializedState(); safeLocalStorage.setItem(this.sessionStorageKey, JSON.stringify(state)); safeLocalStorage.setItem(this.storageKey, JSON.stringify({ ...state, selectedDateId: this.selectedDateId })); if (!this.currentSessionDateId) return; clearTimeout(this.persistenceTimer); this.persistenceTimer = setTimeout(() => this.persistRemote(state, this.currentSessionDateId), 500); }, + async persistRemote(state, dateId) { try { await apiClient.put(`/kaisertisch/${this.currentClub}/${dateId}`, { state }); await this.loadTournamentHistory(); } catch (_) { /* local storage remains the offline and pre-migration fallback */ } }, + async loadTournamentHistory() { if (!this.currentClub) return; try { const response = await apiClient.get(`/kaisertisch/${this.currentClub}`); const remote = Array.isArray(response.data) ? response.data : []; const local = this.trainingDates.map(date => { const state = this.localState(date.id); return state?.history?.length ? { diaryDateId: date.id, date: date.date, roundCount: state.history.length, local: true } : null; }).filter(Boolean); const byDate = new Map([...local, ...remote].map(item => [String(item.diaryDateId), item])); this.tournamentHistory = [...byDate.values()].sort((a, b) => String(b.date || '').localeCompare(String(a.date || ''))); } catch (_) { const local = this.trainingDates.map(date => { const state = this.localState(date.id); return state?.history?.length ? { diaryDateId: date.id, date: date.date, roundCount: state.history.length, local: true } : null; }).filter(Boolean); this.tournamentHistory = local.sort((a, b) => String(b.date).localeCompare(String(a.date))); } }, + async loadSession(dateId, preferRemote = true) { let state = null; if (dateId && preferRemote) { try { const response = await apiClient.get(`/kaisertisch/${this.currentClub}/${dateId}`); state = response.data?.state; } catch (_) { /* a 404 means the tournament has only local data or has not started */ } } state ||= this.localState(dateId); this.applyState(state); }, + async changeTrainingDate(dateId) { if (this.isReadOnly || String(dateId) === String(this.selectedDateId)) return; this.persist(); this.selectedDateId = String(dateId || ''); this.currentSessionDateId = this.selectedDateId; await this.loadParticipantsForDate(); await this.loadSession(this.currentSessionDateId); await this.loadTournamentHistory(); }, + async selectTournament() { if (this.selectedTournamentDateId === 'current') { await this.loadSession(this.currentSessionDateId); return; } const dateId = this.selectedTournamentDateId; await this.loadSession(dateId); }, + async loadData() { this.loading = true; this.error = ''; try { if (!this.currentClub) return; const [membersResponse, datesResponse] = await Promise.all([apiClient.get(`/clubmembers/get/${this.currentClub}/false`), apiClient.get(`/diary/${this.currentClub}`)]); this.allMembers = (Array.isArray(membersResponse.data) ? membersResponse.data : []).map(this.formatMember); this.members = [...this.allMembers]; this.trainingDates = Array.isArray(datesResponse.data) ? datesResponse.data : []; let legacyState = null; try { legacyState = JSON.parse(safeLocalStorage.getItem(this.storageKey) || 'null'); } catch (_) { /* no legacy session */ } const requestedDateId = this.$route?.query?.dateId; const defaultDate = requestedDateId && this.trainingDates.some(date => String(date.id) === String(requestedDateId)) ? String(requestedDateId) : this.trainingDates.find(date => date.date === new Date().toISOString().slice(0, 10))?.id; this.selectedDateId = defaultDate ? String(defaultDate) : ''; this.currentSessionDateId = this.selectedDateId; if (this.currentSessionDateId && legacyState && !this.localState(this.currentSessionDateId)) safeLocalStorage.setItem(this.sessionStorageKey, JSON.stringify(legacyState)); if (this.selectedDateId) await this.loadParticipantsForDate(); await this.loadSession(this.currentSessionDateId); await this.loadTournamentHistory(); } catch (error) { this.error = 'Die Trainingsdaten konnten nicht geladen werden. Deine lokale Aufstellung bleibt erhalten.'; if (!this.tables.length) this.tables = this.makeTables(); } finally { this.loading = false; } }, async loadParticipantsForDate() { if (!this.selectedDateId) return; const dateId = String(this.selectedDateId); const requestId = ++this.participantRequestId; try { const response = await apiClient.get(`/participants/${dateId}`); if (requestId !== this.participantRequestId || dateId !== String(this.selectedDateId)) return; const presentIds = new Set((Array.isArray(response.data) ? response.data : []).filter(participant => !participant.attendanceStatus || participant.attendanceStatus === 'present').map(participant => String(participant.memberId))); this.members = this.allMembers.filter(member => presentIds.has(String(member.id)) || this.seatedIds.has(member.id)); } catch (_) { if (requestId === this.participantRequestId) this.error = 'Teilnehmende dieses Trainingstags konnten nicht geladen werden.'; } }, - adjustTableCount(direction) { const nextCount = Math.min(30, Math.max(1, this.tableCount + direction)); if (nextCount === this.tableCount) return; this.tableCount = nextCount; this.changeTableCount(); }, + adjustTableCount(direction) { if (this.isReadOnly) return; const nextCount = Math.min(30, Math.max(1, this.tableCount + direction)); if (nextCount === this.tableCount) return; this.tableCount = nextCount; this.changeTableCount(); }, changeTableCount() { const old = this.tables; this.tables = this.makeTables(this.tableCount).map((table, index) => old[index] || table); this.selectedSeat = null; this.swapSelection = []; this.pendingWinners = {}; }, - createDemo() { const names = this.members.length ? this.members.map(member => ({ id: member.id, name: member.name })) : DEMO_PLAYERS.map((name, index) => ({ id: `demo-${index}`, name })); if (!this.members.length) this.members = names; this.tables = this.makeTables().map((table, index) => ({ left: names[index * 2]?.id || null, right: names[index * 2 + 1]?.id || null })); this.history = []; this.round = 0; this.selectedSeat = null; this.pendingWinners = {}; }, + createDemo() { if (this.isReadOnly) return; const names = this.members.length ? this.members.map(member => ({ id: member.id, name: member.name })) : DEMO_PLAYERS.map((name, index) => ({ id: `demo-${index}`, name })); if (!this.members.length) this.members = names; this.tables = this.makeTables().map((table, index) => ({ left: names[index * 2]?.id || null, right: names[index * 2 + 1]?.id || null })); this.history = []; this.round = 0; this.selectedSeat = null; this.pendingWinners = {}; }, seatClass(table, side) { return { empty: !this.tables[table][side], selected: this.selectedSeat?.table === table && this.selectedSeat?.side === side, 'swap-selected': this.swapSelection.some(item => item.table === table && item.side === side), winner: this.pendingWinners[table] === this.tables[table][side] }; }, seatAriaLabel(table, side) { const value = this.tables[table][side]; return value ? `${this.playerName(value)} an ${this.tableLabel(table)}${this.pendingWinners[table] === value ? ', als Sieger markiert' : ', als Sieger auswählen'}` : `Freier ${side === 'left' ? 'linker' : 'rechter'} Platz an ${this.tableLabel(table)}`; }, - handleSeatClick(table, side) { const player = this.tables[table][side]; if (this.swapMode) { if (!player) return; const selection = { table, side }; if (this.swapSelection.some(item => item.table === table && item.side === side)) { this.swapSelection = []; return; } this.swapSelection.push(selection); if (this.swapSelection.length === 2) { const [first, second] = this.swapSelection; [this.tables[first.table][first.side], this.tables[second.table][second.side]] = [this.tables[second.table][second.side], this.tables[first.table][first.side]]; this.swapSelection = []; this.swapMode = false; this.pendingWinners = {}; } return; } if (!player) { this.selectedSeat = { table, side }; return; } if (!this.tables[table].left || !this.tables[table].right) { this.selectedSeat = { table, side }; return; } this.pendingWinners = { ...this.pendingWinners, [table]: player }; }, - placePlayer(id) { if (!this.selectedSeat) return; this.tables[this.selectedSeat.table][this.selectedSeat.side] = id; this.selectedSeat = null; this.pendingWinners = {}; }, - toggleSwapMode() { this.swapMode = !this.swapMode; this.swapSelection = []; this.selectedSeat = null; }, + handleSeatClick(table, side) { if (this.isReadOnly) return; const player = this.tables[table][side]; if (this.swapMode) { if (!player) return; const selection = { table, side }; if (this.swapSelection.some(item => item.table === table && item.side === side)) { this.swapSelection = []; return; } this.swapSelection.push(selection); if (this.swapSelection.length === 2) { const [first, second] = this.swapSelection; [this.tables[first.table][first.side], this.tables[second.table][second.side]] = [this.tables[second.table][second.side], this.tables[first.table][first.side]]; this.swapSelection = []; this.swapMode = false; this.pendingWinners = {}; } return; } if (!player) { this.selectedSeat = { table, side }; return; } if (!this.tables[table].left || !this.tables[table].right) { this.selectedSeat = { table, side }; return; } this.pendingWinners = { ...this.pendingWinners, [table]: player }; }, + placePlayer(id) { if (this.isReadOnly || !this.selectedSeat) return; this.tables[this.selectedSeat.table][this.selectedSeat.side] = id; this.selectedSeat = null; this.pendingWinners = {}; }, + toggleSwapMode() { if (this.isReadOnly) return; this.swapMode = !this.swapMode; this.swapSelection = []; this.selectedSeat = null; }, tableLabel(index) { return index === 0 ? 'Kaisertisch' : `Tisch ${index + 1}`; }, - applyRound() { if (!this.roundReady) return; const snapshot = this.tables.map(table => ({ ...table })); const indexes = this.activeTableIndexes; const oldTables = snapshot.map(table => ({ ...table })); const winner = index => this.pendingWinners[index]; const loser = index => oldTables[index].left === winner(index) ? oldTables[index].right : oldTables[index].left; if (indexes.length > 1) { indexes.forEach((tableIndex, position) => { const nextPlayers = position === 0 ? [winner(indexes[0]), winner(indexes[1])] : position === indexes.length - 1 ? [loser(indexes[position - 1]), loser(indexes[position])] : [loser(indexes[position - 1]), winner(indexes[position + 1])]; this.tables[tableIndex] = { left: nextPlayers[0], right: nextPlayers[1] }; }); } this.round += 1; const summary = indexes.map(index => `${this.tableLabel(index)}: ${this.playerName(winner(index))}`).join(' · '); this.history.unshift({ id: `${Date.now()}-${this.round}`, round: this.round, time: new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit' }).format(new Date()), summary, before: snapshot }); this.pendingWinners = {}; this.showRoundConfirm = false; }, - undo() { const previous = this.history.shift(); if (!previous) return; this.tables = previous.before.map(table => ({ ...table })); this.round = Math.max(0, this.round - 1); this.pendingWinners = {}; this.showRoundConfirm = false; }, + applyRound() { if (this.isReadOnly || !this.roundReady) return; const snapshot = this.tables.map(table => ({ ...table })); const indexes = this.activeTableIndexes; const oldTables = snapshot.map(table => ({ ...table })); const winner = index => this.pendingWinners[index]; const loser = index => oldTables[index].left === winner(index) ? oldTables[index].right : oldTables[index].left; if (indexes.length > 1) { indexes.forEach((tableIndex, position) => { const nextPlayers = position === 0 ? [winner(indexes[0]), winner(indexes[1])] : position === indexes.length - 1 ? [loser(indexes[position - 1]), loser(indexes[position])] : [loser(indexes[position - 1]), winner(indexes[position + 1])]; this.tables[tableIndex] = { left: nextPlayers[0], right: nextPlayers[1] }; }); } this.round += 1; const summary = indexes.map(index => `${this.tableLabel(index)}: ${this.playerName(winner(index))}`).join(' · '); this.history.unshift({ id: `${Date.now()}-${this.round}`, round: this.round, time: new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit' }).format(new Date()), summary, before: snapshot }); this.pendingWinners = {}; this.showRoundConfirm = false; }, + undo() { if (this.isReadOnly) return; const previous = this.history.shift(); if (!previous) return; this.tables = previous.before.map(table => ({ ...table })); this.round = Math.max(0, this.round - 1); this.pendingWinners = {}; this.showRoundConfirm = false; }, }, mounted() { this.loadData(); }, }; @@ -200,4 +255,7 @@ export default { .net { background:#e8ebde; border-top:3px dashed #58755e; border-bottom:3px dashed #58755e; } .round-control { display:flex;align-items:center;justify-content:space-between;gap:1rem;margin-top:1rem;padding:.75rem .85rem;border:1px solid #e2c79e;background:#fff6e6;color:#75552c; }.round-control strong,.round-control span { display:block; }.round-control strong { font-size:.9rem; }.round-control span { margin-top:.14rem;font-size:.77rem; }.round-control.complete { background:#eaf7ea;border-color:#a9d9ae;color:#21683b; }.round-apply:disabled { background:#b6c2b5;border-color:#aab5a9;color:#fff;cursor:not-allowed; }.player-seat.winner { background:#66c96a;color:#123d24;box-shadow:inset 0 0 0 3px #eaffdd; }.player-seat.winner::after { content:'✓ Sieger';display:block;font-size:.68rem;letter-spacing:.04em;margin-top:.22rem;font-weight:900; } +.session-status.archived { color:#694a22; background:#f6e9d5; border-color:#dfc198; }.archive-notice { display:grid;gap:.2rem;margin-top:1rem;padding:.75rem .85rem;border-left:4px solid #a07040;background:#fff4e3;color:#684722;font-size:.82rem; }.player-seat:disabled { cursor:default; }.player-seat:hover:disabled { background:transparent; }.btn-primary:disabled,.btn-secondary:disabled,.participant-pool button:disabled { opacity:.52;cursor:not-allowed; }.archive-select { display:grid;gap:.34rem;margin-top:.8rem;font-size:.77rem;font-weight:800;color:#dac8ae; }.archive-select select { width:100%;padding:.55rem .6rem;border:1px solid rgba(255,255,255,.28);border-radius:4px;background:#43534b;color:#fff;font:inherit; }.archive-empty { margin:.45rem 0 0;font-size:.75rem;line-height:1.4;color:#c6d0c7; } +.player-paths { margin-top:1rem;padding-top:.8rem;border-top:1px solid rgba(255,255,255,.2); }.player-paths h4 { margin:0 0 .55rem;color:#f1c686;font-size:.77rem;letter-spacing:.08em;text-transform:uppercase; }.player-path-row { display:grid;grid-template-columns:minmax(84px,.8fr) minmax(0,1.7fr);gap:.45rem .65rem;padding:.45rem 0;border-top:1px solid rgba(255,255,255,.1);font-size:.78rem;line-height:1.4; }.player-path-row strong { color:#fff;font-weight:800; }.player-path { display:flex;flex-wrap:wrap;align-items:center;gap:.25rem;color:#dce6dc; }.path-table { white-space:nowrap; }.path-arrow { color:#f1c686;font-weight:900; } +@media (max-width:580px) { .player-path-row { grid-template-columns:1fr;gap:.15rem; } }