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