diff --git a/backend/controllers/friendlyMatchController.js b/backend/controllers/friendlyMatchController.js
new file mode 100644
index 00000000..3dd03986
--- /dev/null
+++ b/backend/controllers/friendlyMatchController.js
@@ -0,0 +1,70 @@
+import FriendlyMatchService from '../services/friendlyMatchService.js';
+import { emitScheduleMatchUpdated } from '../services/socketService.js';
+
+function userTokenFrom(req) {
+ return req.headers.authcode;
+}
+
+export const listFriendlyMatches = async (req, res) => {
+ try {
+ const matches = await FriendlyMatchService.list(userTokenFrom(req), req.params.clubId);
+ res.status(200).json(matches);
+ } catch (error) {
+ console.error('[listFriendlyMatches] Error:', error);
+ res.status(error.statusCode || 500).json({ error: error.message || 'Freundschaftsspiele konnten nicht geladen werden' });
+ }
+};
+
+export const createFriendlyMatch = async (req, res) => {
+ try {
+ const match = await FriendlyMatchService.create(userTokenFrom(req), req.params.clubId, req.body);
+ emitScheduleMatchUpdated(req.params.clubId, match.id, match);
+ res.status(201).json(match);
+ } catch (error) {
+ console.error('[createFriendlyMatch] Error:', error);
+ res.status(error.statusCode || 500).json({ error: error.message || 'Freundschaftsspiel konnte nicht erstellt werden' });
+ }
+};
+
+export const updateFriendlyMatch = async (req, res) => {
+ try {
+ const match = await FriendlyMatchService.update(userTokenFrom(req), req.params.clubId, req.params.matchId, req.body);
+ emitScheduleMatchUpdated(req.params.clubId, match.id, match);
+ res.status(200).json(match);
+ } catch (error) {
+ console.error('[updateFriendlyMatch] Error:', error);
+ res.status(error.statusCode || 500).json({ error: error.message || 'Freundschaftsspiel konnte nicht gespeichert werden' });
+ }
+};
+
+export const deleteFriendlyMatch = async (req, res) => {
+ try {
+ const result = await FriendlyMatchService.remove(userTokenFrom(req), req.params.clubId, req.params.matchId);
+ emitScheduleMatchUpdated(req.params.clubId, Number(req.params.matchId), null);
+ res.status(200).json(result);
+ } catch (error) {
+ console.error('[deleteFriendlyMatch] Error:', error);
+ res.status(error.statusCode || 500).json({ error: error.message || 'Freundschaftsspiel konnte nicht gelöscht werden' });
+ }
+};
+
+export const updateFriendlyMatchPlayers = async (req, res) => {
+ try {
+ const match = await FriendlyMatchService.updatePlayers(userTokenFrom(req), req.params.clubId, req.params.matchId, req.body);
+ emitScheduleMatchUpdated(req.params.clubId, match.id, match);
+ res.status(200).json({ message: 'Teilnehmer gespeichert', data: match });
+ } catch (error) {
+ console.error('[updateFriendlyMatchPlayers] Error:', error);
+ res.status(error.statusCode || 500).json({ error: error.message || 'Teilnehmer konnten nicht gespeichert werden' });
+ }
+};
+
+export const getFriendlyMatchMembers = async (req, res) => {
+ try {
+ const members = await FriendlyMatchService.members(userTokenFrom(req), req.params.clubId);
+ res.status(200).json(members);
+ } catch (error) {
+ console.error('[getFriendlyMatchMembers] Error:', error);
+ res.status(error.statusCode || 500).json({ error: error.message || 'Mitglieder konnten nicht geladen werden' });
+ }
+};
diff --git a/backend/migrations/20260518_add_result_details_to_friendly_match.sql b/backend/migrations/20260518_add_result_details_to_friendly_match.sql
new file mode 100644
index 00000000..69174ce1
--- /dev/null
+++ b/backend/migrations/20260518_add_result_details_to_friendly_match.sql
@@ -0,0 +1,2 @@
+ALTER TABLE `friendly_match`
+ ADD COLUMN IF NOT EXISTS `result_details` JSON NULL AFTER `guest_participants`;
diff --git a/backend/migrations/20260518_create_friendly_match.sql b/backend/migrations/20260518_create_friendly_match.sql
new file mode 100644
index 00000000..37b2a498
--- /dev/null
+++ b/backend/migrations/20260518_create_friendly_match.sql
@@ -0,0 +1,30 @@
+CREATE TABLE IF NOT EXISTS `friendly_match` (
+ `id` INT NOT NULL AUTO_INCREMENT,
+ `club_id` INT NOT NULL,
+ `date` DATE NOT NULL,
+ `time` TIME NULL,
+ `home_team_name` VARCHAR(255) NOT NULL,
+ `guest_team_name` VARCHAR(255) NOT NULL,
+ `location_name` VARCHAR(255) NULL,
+ `location_address` VARCHAR(255) NULL,
+ `location_city` VARCHAR(255) NULL,
+ `location_zip` VARCHAR(32) NULL,
+ `match_system` VARCHAR(120) NOT NULL DEFAULT 'Braunschweiger System',
+ `singles_count` INT NOT NULL DEFAULT 12,
+ `doubles_count` INT NOT NULL DEFAULT 4,
+ `winning_sets` INT NOT NULL DEFAULT 3,
+ `home_match_points` INT NOT NULL DEFAULT 0,
+ `guest_match_points` INT NOT NULL DEFAULT 0,
+ `is_completed` TINYINT(1) NOT NULL DEFAULT 0,
+ `home_participants` JSON NULL,
+ `guest_participants` JSON NULL,
+ `result_details` JSON NULL,
+ `players_ready` JSON NULL,
+ `players_planned` JSON NULL,
+ `players_played` JSON NULL,
+ `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_friendly_match_club_date` (`club_id`, `date`),
+ KEY `idx_friendly_match_completed` (`club_id`, `is_completed`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/backend/models/FriendlyMatch.js b/backend/models/FriendlyMatch.js
new file mode 100644
index 00000000..b30e68b2
--- /dev/null
+++ b/backend/models/FriendlyMatch.js
@@ -0,0 +1,132 @@
+import { DataTypes } from 'sequelize';
+import sequelize from '../database.js';
+
+const FriendlyMatch = sequelize.define('FriendlyMatch', {
+ id: {
+ type: DataTypes.INTEGER,
+ primaryKey: true,
+ autoIncrement: true,
+ allowNull: false,
+ },
+ clubId: {
+ type: DataTypes.INTEGER,
+ allowNull: false,
+ field: 'club_id',
+ },
+ date: {
+ type: DataTypes.DATEONLY,
+ allowNull: false,
+ },
+ time: {
+ type: DataTypes.TIME,
+ allowNull: true,
+ },
+ homeTeamName: {
+ type: DataTypes.STRING(255),
+ allowNull: false,
+ field: 'home_team_name',
+ },
+ guestTeamName: {
+ type: DataTypes.STRING(255),
+ allowNull: false,
+ field: 'guest_team_name',
+ },
+ locationName: {
+ type: DataTypes.STRING(255),
+ allowNull: true,
+ field: 'location_name',
+ },
+ locationAddress: {
+ type: DataTypes.STRING(255),
+ allowNull: true,
+ field: 'location_address',
+ },
+ locationCity: {
+ type: DataTypes.STRING(255),
+ allowNull: true,
+ field: 'location_city',
+ },
+ locationZip: {
+ type: DataTypes.STRING(32),
+ allowNull: true,
+ field: 'location_zip',
+ },
+ matchSystem: {
+ type: DataTypes.STRING(120),
+ allowNull: false,
+ defaultValue: 'Braunschweiger System',
+ field: 'match_system',
+ },
+ singlesCount: {
+ type: DataTypes.INTEGER,
+ allowNull: false,
+ defaultValue: 12,
+ field: 'singles_count',
+ },
+ doublesCount: {
+ type: DataTypes.INTEGER,
+ allowNull: false,
+ defaultValue: 4,
+ field: 'doubles_count',
+ },
+ winningSets: {
+ type: DataTypes.INTEGER,
+ allowNull: false,
+ defaultValue: 3,
+ field: 'winning_sets',
+ },
+ homeMatchPoints: {
+ type: DataTypes.INTEGER,
+ allowNull: false,
+ defaultValue: 0,
+ field: 'home_match_points',
+ },
+ guestMatchPoints: {
+ type: DataTypes.INTEGER,
+ allowNull: false,
+ defaultValue: 0,
+ field: 'guest_match_points',
+ },
+ isCompleted: {
+ type: DataTypes.BOOLEAN,
+ allowNull: false,
+ defaultValue: false,
+ field: 'is_completed',
+ },
+ homeParticipants: {
+ type: DataTypes.JSON,
+ allowNull: true,
+ field: 'home_participants',
+ },
+ guestParticipants: {
+ type: DataTypes.JSON,
+ allowNull: true,
+ field: 'guest_participants',
+ },
+ resultDetails: {
+ type: DataTypes.JSON,
+ allowNull: true,
+ field: 'result_details',
+ },
+ playersReady: {
+ type: DataTypes.JSON,
+ allowNull: true,
+ field: 'players_ready',
+ },
+ playersPlanned: {
+ type: DataTypes.JSON,
+ allowNull: true,
+ field: 'players_planned',
+ },
+ playersPlayed: {
+ type: DataTypes.JSON,
+ allowNull: true,
+ field: 'players_played',
+ },
+}, {
+ tableName: 'friendly_match',
+ underscored: true,
+ timestamps: true,
+});
+
+export default FriendlyMatch;
diff --git a/backend/models/index.js b/backend/models/index.js
index 786e662b..ade07375 100644
--- a/backend/models/index.js
+++ b/backend/models/index.js
@@ -57,6 +57,7 @@ import BillingRun from './BillingRun.js';
import BillingDocument from './BillingDocument.js';
import BillingDocumentValue from './BillingDocumentValue.js';
import BillingUserSetting from './BillingUserSetting.js';
+import FriendlyMatch from './FriendlyMatch.js';
import MemberTtrHistory from './MemberTtrHistory.js';
import MemberPlayInterest from './MemberPlayInterest.js';
import ClickTtAccount from './ClickTtAccount.js';
@@ -451,6 +452,7 @@ export {
BillingDocument,
BillingDocumentValue,
BillingUserSetting,
+ FriendlyMatch,
MemberTtrHistory,
MemberPlayInterest,
ClickTtAccount,
diff --git a/backend/routes/friendlyMatchRoutes.js b/backend/routes/friendlyMatchRoutes.js
new file mode 100644
index 00000000..a4898f11
--- /dev/null
+++ b/backend/routes/friendlyMatchRoutes.js
@@ -0,0 +1,22 @@
+import express from 'express';
+import {
+ createFriendlyMatch,
+ deleteFriendlyMatch,
+ getFriendlyMatchMembers,
+ listFriendlyMatches,
+ updateFriendlyMatch,
+ updateFriendlyMatchPlayers,
+} from '../controllers/friendlyMatchController.js';
+import { authenticate } from '../middleware/authMiddleware.js';
+import { authorize } from '../middleware/authorizationMiddleware.js';
+
+const router = express.Router();
+
+router.get('/:clubId', authenticate, authorize('schedule', 'read'), listFriendlyMatches);
+router.post('/:clubId', authenticate, authorize('schedule', 'write'), createFriendlyMatch);
+router.put('/:clubId/:matchId', authenticate, authorize('schedule', 'write'), updateFriendlyMatch);
+router.delete('/:clubId/:matchId', authenticate, authorize('schedule', 'write'), deleteFriendlyMatch);
+router.patch('/:clubId/:matchId/players', authenticate, authorize('schedule', 'write'), updateFriendlyMatchPlayers);
+router.get('/:clubId/members/list', authenticate, authorize('schedule', 'read'), getFriendlyMatchMembers);
+
+export default router;
diff --git a/backend/server.js b/backend/server.js
index 4baa145e..5f686122 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -14,7 +14,7 @@ import {
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,
- MemberOrder, MemberOrderHistory, MemberGroupPhoto, BillingTemplate, BillingTemplateField, BillingRun, BillingDocument, BillingDocumentValue, BillingUserSetting, TrainingCancellation
+ MemberOrder, MemberOrderHistory, MemberGroupPhoto, BillingTemplate, BillingTemplateField, BillingRun, BillingDocument, BillingDocumentValue, BillingUserSetting, FriendlyMatch, TrainingCancellation
, CalendarEvent
} from './models/index.js';
import authRoutes from './routes/authRoutes.js';
@@ -60,6 +60,7 @@ import trainingCancellationRoutes from './routes/trainingCancellationRoutes.js';
import memberOrderRoutes from './routes/memberOrderRoutes.js';
import memberGroupPhotoRoutes from './routes/memberGroupPhotoRoutes.js';
import billingRoutes from './routes/billingRoutes.js';
+import friendlyMatchRoutes from './routes/friendlyMatchRoutes.js';
import calendarRoutes from './routes/calendarRoutes.js';
import calendarEventRoutes from './routes/calendarEventRoutes.js';
import schedulerService from './services/schedulerService.js';
@@ -311,6 +312,7 @@ app.use('/api/training-cancellations', trainingCancellationRoutes);
app.use('/api/member-orders', memberOrderRoutes);
app.use('/api/member-group-photos', memberGroupPhotoRoutes);
app.use('/api/billing', billingRoutes);
+app.use('/api/friendly-matches', friendlyMatchRoutes);
app.use('/api/calendar', calendarRoutes);
app.use('/api/calendar-events', calendarEventRoutes);
@@ -566,6 +568,7 @@ app.use((err, req, res, next) => {
await safeSync(BillingDocument);
await safeSync(BillingDocumentValue);
await safeSync(BillingUserSetting);
+ await safeSync(FriendlyMatch);
await safeSync(ClubTeam);
await safeSync(TrainingCancellation);
await safeSync(CalendarEvent);
diff --git a/backend/services/friendlyMatchService.js b/backend/services/friendlyMatchService.js
new file mode 100644
index 00000000..c7839cbc
--- /dev/null
+++ b/backend/services/friendlyMatchService.js
@@ -0,0 +1,214 @@
+import FriendlyMatch from '../models/FriendlyMatch.js';
+import Member from '../models/Member.js';
+import { checkAccess } from '../utils/userUtils.js';
+import HttpError from '../exceptions/HttpError.js';
+
+function cleanString(value, fallback = '') {
+ const text = String(value ?? '').trim();
+ return text || fallback;
+}
+
+function cleanOptionalString(value) {
+ const text = String(value ?? '').trim();
+ return text || null;
+}
+
+function normalizeParticipantList(list) {
+ if (typeof list === 'string') {
+ try {
+ list = JSON.parse(list);
+ } catch (error) {
+ return [];
+ }
+ }
+ if (!Array.isArray(list)) return [];
+ return list
+ .map((entry) => {
+ const type = entry?.type === 'member' ? 'member' : 'manual';
+ if (type === 'member') {
+ const memberId = Number.parseInt(entry?.memberId, 10);
+ if (!Number.isInteger(memberId)) return null;
+ return { type, memberId };
+ }
+ const firstName = cleanString(entry?.firstName);
+ const lastName = cleanString(entry?.lastName);
+ if (!firstName && !lastName) return null;
+ return { type, firstName, lastName };
+ })
+ .filter(Boolean);
+}
+
+function normalizeArrayValue(value) {
+ if (Array.isArray(value)) return value;
+ if (typeof value === 'string') {
+ try {
+ const parsed = JSON.parse(value);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch (error) {
+ return [];
+ }
+ }
+ return [];
+}
+
+function normalizeIdList(list) {
+ if (typeof list === 'string') {
+ try {
+ list = JSON.parse(list);
+ } catch (error) {
+ return null;
+ }
+ }
+ if (!Array.isArray(list)) return null;
+ const seen = new Set();
+ const result = [];
+ for (const value of list) {
+ const id = Number.parseInt(value, 10);
+ if (!Number.isInteger(id) || seen.has(id)) continue;
+ seen.add(id);
+ result.push(id);
+ }
+ return result;
+}
+
+function toScheduleRow(match) {
+ return {
+ id: match.id,
+ friendlyMatchId: match.id,
+ isFriendly: true,
+ date: match.date,
+ time: match.time,
+ homeTeam: { name: match.homeTeamName },
+ guestTeam: { name: match.guestTeamName },
+ location: {
+ name: match.locationName || 'N/A',
+ address: match.locationAddress || '',
+ city: match.locationCity || '',
+ zip: match.locationZip || '',
+ },
+ leagueDetails: { name: 'Freundschaftsspiel' },
+ homeMatchPoints: match.homeMatchPoints || 0,
+ guestMatchPoints: match.guestMatchPoints || 0,
+ isCompleted: match.isCompleted || false,
+ matchSystem: match.matchSystem,
+ singlesCount: match.singlesCount,
+ doublesCount: match.doublesCount,
+ winningSets: match.winningSets,
+ homeParticipants: normalizeParticipantList(match.homeParticipants),
+ guestParticipants: normalizeParticipantList(match.guestParticipants),
+ resultDetails: normalizeArrayValue(match.resultDetails),
+ playersReady: normalizeArrayValue(match.playersReady),
+ playersPlanned: normalizeArrayValue(match.playersPlanned),
+ playersPlayed: normalizeArrayValue(match.playersPlayed),
+ };
+}
+
+class FriendlyMatchService {
+ async list(userToken, clubId) {
+ await checkAccess(userToken, clubId);
+ const matches = await FriendlyMatch.findAll({
+ where: { clubId },
+ order: [['date', 'ASC'], ['time', 'ASC'], ['id', 'ASC']],
+ });
+ return matches.map(toScheduleRow);
+ }
+
+ async create(userToken, clubId, payload = {}) {
+ await checkAccess(userToken, clubId);
+ const homeTeamName = cleanString(payload.homeTeamName);
+ const guestTeamName = cleanString(payload.guestTeamName);
+ const date = cleanString(payload.date);
+ if (!homeTeamName || !guestTeamName || !date) {
+ throw new HttpError('Datum, Heimteam und Gastteam sind Pflichtfelder.', 400);
+ }
+
+ const match = await FriendlyMatch.create({
+ clubId,
+ date,
+ time: cleanOptionalString(payload.time),
+ homeTeamName,
+ guestTeamName,
+ locationName: cleanOptionalString(payload.locationName),
+ locationAddress: cleanOptionalString(payload.locationAddress),
+ locationCity: cleanOptionalString(payload.locationCity),
+ locationZip: cleanOptionalString(payload.locationZip),
+ matchSystem: cleanString(payload.matchSystem, 'Braunschweiger System'),
+ singlesCount: Number.parseInt(payload.singlesCount, 10) || 12,
+ doublesCount: Number.parseInt(payload.doublesCount, 10) || 4,
+ winningSets: Number.parseInt(payload.winningSets, 10) || 3,
+ homeMatchPoints: Number.parseInt(payload.homeMatchPoints, 10) || 0,
+ guestMatchPoints: Number.parseInt(payload.guestMatchPoints, 10) || 0,
+ isCompleted: Boolean(payload.isCompleted),
+ homeParticipants: normalizeParticipantList(payload.homeParticipants),
+ guestParticipants: normalizeParticipantList(payload.guestParticipants),
+ resultDetails: Array.isArray(payload.resultDetails) ? payload.resultDetails : [],
+ playersReady: [],
+ playersPlanned: [],
+ playersPlayed: [],
+ });
+ return toScheduleRow(match);
+ }
+
+ async update(userToken, clubId, matchId, payload = {}) {
+ await checkAccess(userToken, clubId);
+ const match = await FriendlyMatch.findOne({ where: { id: matchId, clubId } });
+ if (!match) throw new HttpError('Freundschaftsspiel nicht gefunden.', 404);
+
+ const updates = {};
+ for (const field of ['date', 'time', 'homeTeamName', 'guestTeamName', 'locationName', 'locationAddress', 'locationCity', 'locationZip', 'matchSystem']) {
+ if (Object.prototype.hasOwnProperty.call(payload, field)) {
+ updates[field] = field === 'date' || field === 'homeTeamName' || field === 'guestTeamName' || field === 'matchSystem'
+ ? cleanString(payload[field])
+ : cleanOptionalString(payload[field]);
+ }
+ }
+ for (const field of ['singlesCount', 'doublesCount', 'winningSets', 'homeMatchPoints', 'guestMatchPoints']) {
+ if (Object.prototype.hasOwnProperty.call(payload, field)) {
+ updates[field] = Number.parseInt(payload[field], 10) || 0;
+ }
+ }
+ if (Object.prototype.hasOwnProperty.call(payload, 'isCompleted')) updates.isCompleted = Boolean(payload.isCompleted);
+ if (Object.prototype.hasOwnProperty.call(payload, 'homeParticipants')) updates.homeParticipants = normalizeParticipantList(payload.homeParticipants);
+ if (Object.prototype.hasOwnProperty.call(payload, 'guestParticipants')) updates.guestParticipants = normalizeParticipantList(payload.guestParticipants);
+ if (Object.prototype.hasOwnProperty.call(payload, 'resultDetails')) {
+ updates.resultDetails = Array.isArray(payload.resultDetails) ? payload.resultDetails : [];
+ }
+
+ await match.update(updates);
+ return toScheduleRow(match);
+ }
+
+ async remove(userToken, clubId, matchId) {
+ await checkAccess(userToken, clubId);
+ const deleted = await FriendlyMatch.destroy({ where: { id: matchId, clubId } });
+ if (!deleted) throw new HttpError('Freundschaftsspiel nicht gefunden.', 404);
+ return { success: true };
+ }
+
+ async updatePlayers(userToken, clubId, matchId, payload = {}) {
+ await checkAccess(userToken, clubId);
+ const match = await FriendlyMatch.findOne({ where: { id: matchId, clubId } });
+ if (!match) throw new HttpError('Freundschaftsspiel nicht gefunden.', 404);
+
+ const ready = normalizeIdList(payload.playersReady);
+ const planned = normalizeIdList(payload.playersPlanned);
+ const played = normalizeIdList(payload.playersPlayed);
+ await match.update({
+ playersReady: ready ?? (match.playersReady || []),
+ playersPlanned: planned ?? (match.playersPlanned || []),
+ playersPlayed: played ?? (match.playersPlayed || []),
+ });
+ return toScheduleRow(match);
+ }
+
+ async members(userToken, clubId) {
+ await checkAccess(userToken, clubId);
+ return Member.findAll({
+ where: { clubId, active: true },
+ attributes: ['id', 'firstName', 'lastName', 'gender'],
+ order: [['lastName', 'ASC'], ['firstName', 'ASC']],
+ });
+ }
+}
+
+export default new FriendlyMatchService();
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index ed238d73..6bcb4c31 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -113,6 +113,10 @@
{{ $t('navigation.schedule') }}
+