feat: Implement friendly match management features
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
- Added backend support for managing friendly matches including listing, creating, updating, and deleting matches. - Introduced a new database table `friendly_match` with relevant fields for match details. - Created a service layer to handle business logic related to friendly matches. - Developed API routes for friendly match operations with appropriate authentication and authorization. - Added a Vue component for managing participants in friendly matches, allowing selection of members and manual entry of names. - Updated existing tournament editor screens to integrate friendly match functionalities.
This commit is contained in:
70
backend/controllers/friendlyMatchController.js
Normal file
70
backend/controllers/friendlyMatchController.js
Normal file
@@ -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' });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `friendly_match`
|
||||
ADD COLUMN IF NOT EXISTS `result_details` JSON NULL AFTER `guest_participants`;
|
||||
30
backend/migrations/20260518_create_friendly_match.sql
Normal file
30
backend/migrations/20260518_create_friendly_match.sql
Normal file
@@ -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;
|
||||
132
backend/models/FriendlyMatch.js
Normal file
132
backend/models/FriendlyMatch.js
Normal file
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
22
backend/routes/friendlyMatchRoutes.js
Normal file
22
backend/routes/friendlyMatchRoutes.js
Normal file
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
214
backend/services/friendlyMatchService.js
Normal file
214
backend/services/friendlyMatchService.js
Normal file
@@ -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();
|
||||
@@ -113,6 +113,10 @@
|
||||
<span class="nav-icon">📅</span>
|
||||
{{ $t('navigation.schedule') }}
|
||||
</router-link>
|
||||
<router-link v-if="hasPermission('schedule', 'read')" to="/friendly-matches" class="nav-link" title="Freundschaftsspiele">
|
||||
<span class="nav-icon">🤝</span>
|
||||
Freundschaftsspiele
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">
|
||||
@@ -292,7 +296,7 @@ export default {
|
||||
return this.isClubOwner || this.userRole === 'admin' || this.hasPermission('permissions', 'read');
|
||||
},
|
||||
isFullHeightRoute() {
|
||||
return this.$route?.name === 'schedule';
|
||||
return this.$route?.name === 'schedule' || this.$route?.name === 'friendly-matches';
|
||||
},
|
||||
viewReloadKey() {
|
||||
return `${this.$route.fullPath}|${this.currentClub || 'no-club'}`;
|
||||
|
||||
107
frontend/src/components/schedule/FriendlyParticipantsColumn.vue
Normal file
107
frontend/src/components/schedule/FriendlyParticipantsColumn.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<section class="friendly-participant-column">
|
||||
<h4>{{ title }}</h4>
|
||||
<div class="friendly-add-row">
|
||||
<select v-model="selectedMemberId">
|
||||
<option value="">Mitglied auswählen</option>
|
||||
<option v-for="member in members" :key="member.id" :value="member.id">
|
||||
{{ member.firstName }} {{ member.lastName }}
|
||||
</option>
|
||||
</select>
|
||||
<button type="button" @click="addSelectedMember">Hinzufügen</button>
|
||||
</div>
|
||||
<div class="friendly-add-row">
|
||||
<input v-model="manualName" type="text" placeholder="Manueller Name" @keyup.enter="addManual" />
|
||||
<button type="button" @click="addManual">Hinzufügen</button>
|
||||
</div>
|
||||
<ul class="friendly-participant-list">
|
||||
<li v-for="(participant, index) in participants" :key="index">
|
||||
<span>{{ participantLabel(participant) }}</span>
|
||||
<button type="button" @click="$emit('remove', index)">x</button>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'FriendlyParticipantsColumn',
|
||||
props: {
|
||||
title: { type: String, required: true },
|
||||
members: { type: Array, required: true },
|
||||
participants: { type: Array, required: true }
|
||||
},
|
||||
emits: ['add-member', 'add-manual', 'remove'],
|
||||
data() {
|
||||
return {
|
||||
selectedMemberId: '',
|
||||
manualName: ''
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
memberLabel(id) {
|
||||
const member = this.members.find(m => Number(m.id) === Number(id));
|
||||
return member ? `${member.firstName} ${member.lastName}`.trim() : `Mitglied #${id}`;
|
||||
},
|
||||
participantLabel(participant) {
|
||||
if (participant.type === 'member') return this.memberLabel(participant.memberId);
|
||||
return `${participant.firstName || ''} ${participant.lastName || ''}`.trim();
|
||||
},
|
||||
addSelectedMember() {
|
||||
if (!this.selectedMemberId) return;
|
||||
this.$emit('add-member', Number(this.selectedMemberId));
|
||||
this.selectedMemberId = '';
|
||||
},
|
||||
addManual() {
|
||||
const value = this.manualName.trim();
|
||||
if (!value) return;
|
||||
this.$emit('add-manual', value);
|
||||
this.manualName = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.friendly-participant-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.friendly-add-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.friendly-add-row input,
|
||||
.friendly-add-row select {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.friendly-participant-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.5rem 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.friendly-participant-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: var(--background-soft, #f7f7f7);
|
||||
border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -3,17 +3,19 @@
|
||||
<div class="schedule-static-chrome">
|
||||
<div class="schedule-page-header">
|
||||
<div class="schedule-page-title">
|
||||
<h2>{{ $t('schedule.title') }}</h2>
|
||||
<p>{{ $t('schedule.subtitle') }}</p>
|
||||
<h2>{{ resolvedTitle }}</h2>
|
||||
<p>{{ resolvedSubtitle }}</p>
|
||||
</div>
|
||||
<div class="schedule-page-actions">
|
||||
<SeasonSelector
|
||||
v-if="showScheduleActions"
|
||||
:model-value="selectedSeasonId"
|
||||
:show-current-season="true"
|
||||
@update:model-value="$emit('update:selected-season-id', $event)"
|
||||
@season-change="$emit('season-change', $event)"
|
||||
/>
|
||||
<button @click="$emit('open-import-modal')">{{ $t('schedule.importSchedule') }}</button>
|
||||
<button v-if="showScheduleActions" @click="$emit('open-import-modal')">{{ $t('schedule.importSchedule') }}</button>
|
||||
<button v-if="showFriendlyActions" @click="$emit('open-friendly-match-modal')" class="btn-secondary">Freundschaftsspiel</button>
|
||||
<button
|
||||
v-if="showGalleryButton"
|
||||
@click="$emit('open-gallery-dialog')"
|
||||
@@ -50,7 +52,7 @@
|
||||
</div>
|
||||
|
||||
<div class="output schedule-layout">
|
||||
<aside class="schedule-sidebar">
|
||||
<aside v-if="showSidebar" class="schedule-sidebar">
|
||||
<div class="schedule-sidebar-card">
|
||||
<div class="schedule-sidebar-header">
|
||||
<h3>{{ $t('schedule.selection') }}</h3>
|
||||
@@ -63,7 +65,7 @@
|
||||
:placeholder="$t('schedule.searchTeams')"
|
||||
@input="$emit('update:team-search-query', $event.target.value.trim())"
|
||||
/>
|
||||
<div class="schedule-quick-links">
|
||||
<div v-if="showScheduleActions" class="schedule-quick-links">
|
||||
<button
|
||||
type="button"
|
||||
class="schedule-quick-link"
|
||||
@@ -131,7 +133,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedLeague && selectedLeague !== ''" class="tab-navigation">
|
||||
<div v-if="selectedLeague && selectedLeague !== '' && showTableTab" class="tab-navigation">
|
||||
<button :class="['tab-button', { active: activeTab === 'schedule' }]" @click="$emit('update:active-tab', 'schedule')">
|
||||
📅 {{ $t('schedule.scheduleTab') }} <span class="tab-count">{{ matchesCount }}</span>
|
||||
</button>
|
||||
@@ -169,6 +171,8 @@ export default {
|
||||
components: { SeasonSelector },
|
||||
props: {
|
||||
selectedSeasonId: { type: [Number, String, null], default: null },
|
||||
title: { type: String, default: '' },
|
||||
subtitle: { type: String, default: '' },
|
||||
showGalleryButton: { type: Boolean, required: true },
|
||||
galleryLoading: { type: Boolean, required: true },
|
||||
filteredScheduleTeamsCount: { type: Number, required: true },
|
||||
@@ -187,12 +191,17 @@ export default {
|
||||
activeTab: { type: String, required: true },
|
||||
tableCount: { type: Number, required: true },
|
||||
fetchingTeamData: { type: Boolean, required: true },
|
||||
fetchingTable: { type: Boolean, required: true }
|
||||
fetchingTable: { type: Boolean, required: true },
|
||||
showFriendlyActions: { type: Boolean, default: false },
|
||||
showScheduleActions: { type: Boolean, default: true },
|
||||
showSidebar: { type: Boolean, default: true },
|
||||
showTableTab: { type: Boolean, default: true }
|
||||
},
|
||||
emits: [
|
||||
'update:selected-season-id',
|
||||
'season-change',
|
||||
'open-import-modal',
|
||||
'open-friendly-match-modal',
|
||||
'open-gallery-dialog',
|
||||
'update:team-search-query',
|
||||
'load-all-matches',
|
||||
@@ -202,7 +211,15 @@ export default {
|
||||
'generate-pdf',
|
||||
'fetch-table',
|
||||
'update:active-tab'
|
||||
]
|
||||
],
|
||||
computed: {
|
||||
resolvedTitle() {
|
||||
return this.title || this.$t('schedule.title');
|
||||
},
|
||||
resolvedSubtitle() {
|
||||
return this.subtitle || this.$t('schedule.subtitle');
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ const routes = [
|
||||
{ path: '/calendar', name: 'calendar', component: CalendarView },
|
||||
{ path: '/pending-approvals', name: 'pending-approvals', component: PendingApprovalsView},
|
||||
{ path: '/schedule', name: 'schedule', component: ScheduleView},
|
||||
{ path: '/friendly-matches', name: 'friendly-matches', component: ScheduleView, props: { friendlyOnly: true } },
|
||||
{ path: '/tournaments', name: 'tournaments', component: TournamentsView },
|
||||
{ path: '/tournament-participations', name: 'tournament-participations', component: OfficialTournaments },
|
||||
{ path: '/training-stats', name: 'training-stats', component: TrainingStatsView },
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
<ScheduleLayoutShell
|
||||
ref="scheduleShell"
|
||||
:selected-season-id="selectedSeasonId"
|
||||
:title="friendlyOnly ? 'Freundschaftsspiele' : $t('schedule.title')"
|
||||
:subtitle="friendlyOnly ? 'Interne Spiele ohne Click-TT-Verbindung verwalten.' : $t('schedule.subtitle')"
|
||||
:show-gallery-button="Boolean(playerSelectionDialog.match)"
|
||||
:gallery-loading="galleryLoading"
|
||||
:filtered-schedule-teams-count="filteredScheduleTeams.length"
|
||||
@@ -22,9 +24,14 @@
|
||||
:table-count="leagueTable.length"
|
||||
:fetching-team-data="fetchingTeamData"
|
||||
:fetching-table="fetchingTable"
|
||||
:show-friendly-actions="friendlyOnly"
|
||||
:show-schedule-actions="!friendlyOnly"
|
||||
:show-sidebar="!friendlyOnly"
|
||||
:show-table-tab="!friendlyOnly"
|
||||
@update:selected-season-id="selectedSeasonId = $event"
|
||||
@season-change="onSeasonChange"
|
||||
@open-import-modal="openImportModal"
|
||||
@open-friendly-match-modal="openFriendlyMatchDialog"
|
||||
@open-gallery-dialog="openGalleryDialog"
|
||||
@update:team-search-query="teamSearchQuery = $event"
|
||||
@load-all-matches="loadAllMatches"
|
||||
@@ -89,13 +96,14 @@
|
||||
<th>{{ $t('schedule.time') }}</th>
|
||||
<th>{{ $t('schedule.homeTeam') }}</th>
|
||||
<th>{{ $t('schedule.guestTeam') }}</th>
|
||||
<th>{{ $t('schedule.result') }}</th>
|
||||
<th v-if="!friendlyOnly">{{ $t('schedule.result') }}</th>
|
||||
<th
|
||||
v-if="selectedLeague === $t('schedule.overallSchedule') || selectedLeague === $t('schedule.adultSchedule')">
|
||||
{{ $t('schedule.ageClass') }}</th>
|
||||
<th>{{ $t('schedule.code') }}</th>
|
||||
<th>{{ $t('schedule.homePin') }}</th>
|
||||
<th>{{ $t('schedule.guestPin') }}</th>
|
||||
<th v-if="!friendlyOnly">{{ $t('schedule.code') }}</th>
|
||||
<th>{{ friendlyOnly ? 'Aktionen' : $t('schedule.homePin') }}</th>
|
||||
<th v-if="!friendlyOnly">{{ $t('schedule.guestPin') }}</th>
|
||||
<th v-if="friendlyOnly"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -123,7 +131,7 @@
|
||||
<td :class="{ 'highlighted-club': isClubHighlighted(match.guestTeam?.name) }">
|
||||
{{ match.guestTeam?.name || 'N/A' }}
|
||||
</td>
|
||||
<td class="result-cell" :class="getResultClass(match)">
|
||||
<td v-if="!friendlyOnly" class="result-cell" :class="getResultClass(match)">
|
||||
<span v-if="match.isCompleted" class="result-score">
|
||||
{{ match.homeMatchPoints }}:{{ match.guestMatchPoints }}
|
||||
</span>
|
||||
@@ -132,7 +140,7 @@
|
||||
<td
|
||||
v-if="selectedLeague === $t('schedule.overallSchedule') || selectedLeague === $t('schedule.adultSchedule')">
|
||||
{{ match.leagueDetails?.name || 'N/A' }}</td>
|
||||
<td class="code-cell match-report-cell" @click.stop="openMatchReport(match)">
|
||||
<td v-if="!friendlyOnly" class="code-cell match-report-cell" @click.stop="openMatchReport(match)">
|
||||
<span v-if="match.code && selectedLeague && selectedLeague !== ''">
|
||||
<button @click.stop="openMatchReport(match)" class="nuscore-link"
|
||||
:title="$t('schedule.openMatchReport')">📊</button>
|
||||
@@ -142,20 +150,30 @@
|
||||
<span v-else-if="match.code" class="code-value clickable"
|
||||
@click.stop="copyToClipboard(match.code, $t('schedule.code'), $event)"
|
||||
:title="$t('schedule.copyCode') + ': ' + match.code">{{ match.code }}</span>
|
||||
<span v-else class="no-data">-</span>
|
||||
<span v-else-if="!match.isFriendly" class="no-data">-</span>
|
||||
</td>
|
||||
<td class="pin-cell">
|
||||
<span v-if="match.homePin" class="pin-value clickable"
|
||||
<div v-if="match.isFriendly" class="friendly-actions-cell">
|
||||
<button type="button" class="btn-secondary" @click.stop="openFriendlyResultDialog(match)">Ergebnis</button>
|
||||
<button type="button" class="btn-secondary" @click.stop="openFriendlyMatchDialog(match)">Bearbeiten</button>
|
||||
</div>
|
||||
<span v-else-if="match.homePin" class="pin-value clickable"
|
||||
@click.stop="copyToClipboard(match.homePin, $t('schedule.homePin'), $event)"
|
||||
:title="$t('schedule.copyHomePin') + ': ' + match.homePin">{{ match.homePin }}</span>
|
||||
<span v-else class="no-data">-</span>
|
||||
</td>
|
||||
<td class="pin-cell">
|
||||
<td v-if="!friendlyOnly" class="pin-cell">
|
||||
<span v-if="match.guestPin" class="pin-value clickable"
|
||||
@click.stop="copyToClipboard(match.guestPin, $t('schedule.guestPin'), $event)"
|
||||
:title="$t('schedule.copyGuestPin') + ': ' + match.guestPin">{{ match.guestPin }}</span>
|
||||
<span v-else class="no-data">-</span>
|
||||
</td>
|
||||
<td v-if="friendlyOnly" class="result-cell" :class="getResultClass(match)">
|
||||
<span v-if="match.isCompleted" class="result-score">
|
||||
{{ match.homeMatchPoints }}:{{ match.guestMatchPoints }}
|
||||
</span>
|
||||
<span v-else class="result-pending">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -326,6 +344,67 @@
|
||||
</div>
|
||||
</BaseDialog>
|
||||
|
||||
<BaseDialog
|
||||
v-model="friendlyResultDialog.isOpen"
|
||||
:title="`Ergebniseingabe - ${friendlyResultDialog.match?.homeTeam?.name || ''} vs ${friendlyResultDialog.match?.guestTeam?.name || ''}`"
|
||||
width="96vw"
|
||||
max-width="1500px"
|
||||
@close="closeFriendlyResultDialog"
|
||||
>
|
||||
<div class="friendly-result-dialog" v-if="friendlyResultDialog.match">
|
||||
<div class="score-summary">
|
||||
<div class="score-display">
|
||||
<span class="score-label">Spielstand:</span>
|
||||
<span class="score-value">{{ friendlyResultScore.home }}:{{ friendlyResultScore.guest }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<table class="friendly-result-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Typ</th>
|
||||
<th>Heim</th>
|
||||
<th>Gast</th>
|
||||
<th>Satz 1</th>
|
||||
<th>Satz 2</th>
|
||||
<th>Satz 3</th>
|
||||
<th>Satz 4</th>
|
||||
<th>Satz 5</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in friendlyResultDialog.rows" :key="row.id">
|
||||
<td>{{ index + 1 }}</td>
|
||||
<td>{{ row.type === 'double' ? 'Doppel' : 'Einzel' }}</td>
|
||||
<td><input v-model="row.homeName" class="player-input" type="text" /></td>
|
||||
<td><input v-model="row.guestName" class="player-input" type="text" /></td>
|
||||
<td v-for="setIndex in 5" :key="setIndex">
|
||||
<input
|
||||
v-model="row.sets[setIndex - 1]"
|
||||
class="set-input"
|
||||
placeholder="11:7"
|
||||
:disabled="isFriendlySetClosed(row, setIndex - 1)"
|
||||
@blur="normalizeFriendlySet(row, setIndex - 1)"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn-secondary" @click="row.completed = !row.completed">
|
||||
{{ row.completed ? 'Abgeschlossen' : 'Offen' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="friendlyResultDialog.error" class="friendly-result-error">{{ friendlyResultDialog.error }}</div>
|
||||
<div class="dialog-actions">
|
||||
<button @click="saveFriendlyResults(false)" class="btn-save">Speichern</button>
|
||||
<button @click="completeFriendlyResults" class="btn-save">Abschließen</button>
|
||||
<button @click="closeFriendlyResultDialog" class="btn-cancel">{{ $t('schedule.cancel') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
|
||||
<BaseDialog
|
||||
v-model="locationDialog.isOpen"
|
||||
:title="$t('schedule.locationDialogTitle')"
|
||||
@@ -359,6 +438,57 @@
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
|
||||
<BaseDialog
|
||||
v-model="friendlyMatchDialog.isOpen"
|
||||
:title="friendlyMatchDialog.editingId ? 'Freundschaftsspiel bearbeiten' : 'Freundschaftsspiel anlegen'"
|
||||
:max-width="900"
|
||||
@close="closeFriendlyMatchDialog"
|
||||
>
|
||||
<div class="friendly-form">
|
||||
<div class="friendly-form-grid">
|
||||
<label>Datum <input v-model="friendlyMatchDialog.form.date" type="date" /></label>
|
||||
<label>Uhrzeit <input v-model="friendlyMatchDialog.form.time" type="time" /></label>
|
||||
<label>Heimteam <input v-model="friendlyMatchDialog.form.homeTeamName" type="text" /></label>
|
||||
<label>Gastteam <input v-model="friendlyMatchDialog.form.guestTeamName" type="text" /></label>
|
||||
<label>Spielsystem
|
||||
<select v-model="friendlyMatchDialog.form.matchSystem">
|
||||
<option>Braunschweiger System</option>
|
||||
<option>Bundessystem</option>
|
||||
<option>Werner-Scheffler-System</option>
|
||||
<option>Modifiziertes Swaythling-Cup-System</option>
|
||||
<option>Freies System</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Gewinnsätze <input v-model.number="friendlyMatchDialog.form.winningSets" type="number" min="1" /></label>
|
||||
</div>
|
||||
|
||||
<div class="friendly-participants">
|
||||
<FriendlyParticipantsColumn
|
||||
title="Heim-Aufstellung"
|
||||
:members="friendlyMatchDialog.members"
|
||||
:participants="friendlyMatchDialog.form.homeParticipants"
|
||||
@add-member="addFriendlyParticipant('homeParticipants', $event)"
|
||||
@add-manual="addManualFriendlyParticipant('homeParticipants', $event)"
|
||||
@remove="removeFriendlyParticipant('homeParticipants', $event)"
|
||||
/>
|
||||
<FriendlyParticipantsColumn
|
||||
title="Gast-Aufstellung"
|
||||
:members="friendlyMatchDialog.members"
|
||||
:participants="friendlyMatchDialog.form.guestParticipants"
|
||||
@add-member="addFriendlyParticipant('guestParticipants', $event)"
|
||||
@add-manual="addManualFriendlyParticipant('guestParticipants', $event)"
|
||||
@remove="removeFriendlyParticipant('guestParticipants', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="dialog-actions">
|
||||
<button @click="saveFriendlyMatch" class="btn-save">Speichern</button>
|
||||
<button v-if="friendlyMatchDialog.editingId" @click="deleteFriendlyMatch" class="btn-cancel">Löschen</button>
|
||||
<button @click="closeFriendlyMatchDialog" class="btn-cancel">{{ $t('schedule.cancel') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -373,6 +503,7 @@ import ConfirmDialog from '../components/ConfirmDialog.vue';
|
||||
import BaseDialog from '../components/BaseDialog.vue';
|
||||
import CsvImportDialog from '../components/CsvImportDialog.vue';
|
||||
import ScheduleLayoutShell from '../components/schedule/ScheduleLayoutShell.vue';
|
||||
import FriendlyParticipantsColumn from '../components/schedule/FriendlyParticipantsColumn.vue';
|
||||
import {
|
||||
connectSocket,
|
||||
disconnectSocket,
|
||||
@@ -383,13 +514,20 @@ import {
|
||||
} from '../services/socketService.js';
|
||||
export default {
|
||||
name: 'ScheduleView',
|
||||
props: {
|
||||
friendlyOnly: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
components: {
|
||||
SeasonSelector,
|
||||
InfoDialog,
|
||||
ConfirmDialog,
|
||||
BaseDialog,
|
||||
CsvImportDialog,
|
||||
ScheduleLayoutShell
|
||||
ScheduleLayoutShell,
|
||||
FriendlyParticipantsColumn
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['isAuthenticated', 'currentClub', 'clubs', 'currentClubName']),
|
||||
@@ -410,6 +548,9 @@ export default {
|
||||
pendingMatchesCount() {
|
||||
return this.matches.length - this.completedMatchesCount;
|
||||
},
|
||||
friendlyResultScore() {
|
||||
return this.calculateFriendlyResultScore(this.friendlyResultDialog.rows);
|
||||
},
|
||||
nextScheduledMatchLabel() {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
@@ -424,6 +565,8 @@ export default {
|
||||
if (!this.selectedLeague) return '';
|
||||
return this.activeTab === 'table'
|
||||
? this.$t('schedule.workspaceTableDescription', { count: this.leagueTable.length })
|
||||
: this.friendlyOnly
|
||||
? `Interne Freundschaftsspiele: ${this.matches.length}`
|
||||
: this.$t('schedule.workspaceScheduleDescription', {
|
||||
matches: this.matches.length,
|
||||
completed: this.completedMatchesCount,
|
||||
@@ -439,6 +582,9 @@ export default {
|
||||
.filter(teamName => teamName !== ownTeamName)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
},
|
||||
friendlyMatchesLabel() {
|
||||
return 'Freundschaftsspiele';
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
currentClub: {
|
||||
@@ -451,6 +597,9 @@ export default {
|
||||
connectSocket(newVal);
|
||||
onScheduleMatchUpdated(this.handleScheduleMatchUpdated);
|
||||
onMatchReportSubmitted(this.handleMatchReportSubmitted);
|
||||
if (this.friendlyOnly) {
|
||||
this.loadFriendlyMatches();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -490,6 +639,7 @@ export default {
|
||||
allLeagueMatches: [],
|
||||
leagueMatchScope: 'own',
|
||||
selectedComparisonTeamName: '',
|
||||
friendlyMatches: [],
|
||||
|
||||
// Player Selection Dialog
|
||||
playerSelectionDialog: {
|
||||
@@ -508,9 +658,75 @@ export default {
|
||||
galleryMembers: [],
|
||||
galleryError: '',
|
||||
gallerySize: 200,
|
||||
friendlyMatchDialog: {
|
||||
isOpen: false,
|
||||
editingId: null,
|
||||
members: [],
|
||||
form: {
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
time: '',
|
||||
homeTeamName: '',
|
||||
guestTeamName: '',
|
||||
locationName: '',
|
||||
locationAddress: '',
|
||||
locationZip: '',
|
||||
locationCity: '',
|
||||
matchSystem: 'Braunschweiger System',
|
||||
singlesCount: 12,
|
||||
doublesCount: 4,
|
||||
winningSets: 3,
|
||||
homeMatchPoints: 0,
|
||||
guestMatchPoints: 0,
|
||||
isCompleted: false,
|
||||
homeParticipants: [],
|
||||
guestParticipants: []
|
||||
}
|
||||
},
|
||||
friendlyResultDialog: {
|
||||
isOpen: false,
|
||||
match: null,
|
||||
rows: [],
|
||||
error: '',
|
||||
saving: false,
|
||||
saveAgain: false
|
||||
},
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
emptyFriendlyMatchForm() {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return {
|
||||
date: today,
|
||||
time: '',
|
||||
homeTeamName: this.currentClubName || '',
|
||||
guestTeamName: '',
|
||||
locationName: '',
|
||||
locationAddress: '',
|
||||
locationZip: '',
|
||||
locationCity: '',
|
||||
matchSystem: 'Braunschweiger System',
|
||||
singlesCount: 12,
|
||||
doublesCount: 4,
|
||||
winningSets: 3,
|
||||
homeMatchPoints: 0,
|
||||
guestMatchPoints: 0,
|
||||
isCompleted: false,
|
||||
homeParticipants: [],
|
||||
guestParticipants: []
|
||||
};
|
||||
},
|
||||
parseFriendlyArray(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 [];
|
||||
},
|
||||
sortMatchesByDateTime(matches) {
|
||||
if (!Array.isArray(matches)) {
|
||||
return [];
|
||||
@@ -643,12 +859,14 @@ export default {
|
||||
const preselectedIds = Array.from(new Set([...readyIds, ...plannedIds, ...playedIds]));
|
||||
|
||||
// Fetch members for the current club
|
||||
const response = await apiClient.get(`/clubmembers/get/${this.currentClub}/true`);
|
||||
const response = match.isFriendly
|
||||
? await apiClient.get(`/friendly-matches/${this.currentClub}/members/list`)
|
||||
: await apiClient.get(`/clubmembers/get/${this.currentClub}/true`);
|
||||
const allMembers = response.data;
|
||||
|
||||
const lineupHalf = this.getLineupHalfForMatch(match);
|
||||
const eligibleMemberIds = await this.getEligibleMemberIdsForSelectedTeam(lineupHalf);
|
||||
const activeMembers = allMembers.filter(member => member.active);
|
||||
const eligibleMemberIds = match.isFriendly ? [] : await this.getEligibleMemberIdsForSelectedTeam(lineupHalf);
|
||||
const activeMembers = match.isFriendly ? allMembers : allMembers.filter(member => member.active);
|
||||
const allowedIds = new Set(
|
||||
[...eligibleMemberIds, ...preselectedIds]
|
||||
.map((id) => Number(id))
|
||||
@@ -760,12 +978,18 @@ export default {
|
||||
console.log('[savePlayerSelection] Saving players:', { playersReady, playersPlanned, playersPlayed, matchId: match.id });
|
||||
|
||||
try {
|
||||
const response = await apiClient.patch(`/matches/${match.id}/players`, {
|
||||
clubId: this.currentClub,
|
||||
playersReady,
|
||||
playersPlanned,
|
||||
playersPlayed
|
||||
});
|
||||
const response = match.isFriendly
|
||||
? await apiClient.patch(`/friendly-matches/${this.currentClub}/${match.id}/players`, {
|
||||
playersReady,
|
||||
playersPlanned,
|
||||
playersPlayed
|
||||
})
|
||||
: await apiClient.patch(`/matches/${match.id}/players`, {
|
||||
clubId: this.currentClub,
|
||||
playersReady,
|
||||
playersPlanned,
|
||||
playersPlayed
|
||||
});
|
||||
if (response.status >= 400) {
|
||||
throw new Error(response?.data?.error || 'Failed to update match players');
|
||||
}
|
||||
@@ -911,6 +1135,309 @@ export default {
|
||||
this.selectedFile = file;
|
||||
this.importCSV();
|
||||
},
|
||||
async loadFriendlyMembers() {
|
||||
const response = await apiClient.get(`/friendly-matches/${this.currentClub}/members/list`);
|
||||
this.friendlyMatchDialog.members = response.data || [];
|
||||
},
|
||||
async openFriendlyMatchDialog(match = null) {
|
||||
await this.loadFriendlyMembers();
|
||||
this.friendlyMatchDialog.editingId = match?.isFriendly ? match.id : null;
|
||||
this.friendlyMatchDialog.form = match?.isFriendly
|
||||
? {
|
||||
date: match.date ? String(match.date).slice(0, 10) : new Date().toISOString().slice(0, 10),
|
||||
time: match.time ? String(match.time).slice(0, 5) : '',
|
||||
homeTeamName: match.homeTeam?.name || '',
|
||||
guestTeamName: match.guestTeam?.name || '',
|
||||
locationName: match.location?.name === 'N/A' ? '' : (match.location?.name || ''),
|
||||
locationAddress: match.location?.address || '',
|
||||
locationZip: match.location?.zip || '',
|
||||
locationCity: match.location?.city || '',
|
||||
matchSystem: match.matchSystem || 'Braunschweiger System',
|
||||
singlesCount: match.singlesCount ?? 12,
|
||||
doublesCount: match.doublesCount ?? 4,
|
||||
winningSets: match.winningSets ?? 3,
|
||||
homeMatchPoints: match.homeMatchPoints ?? 0,
|
||||
guestMatchPoints: match.guestMatchPoints ?? 0,
|
||||
isCompleted: Boolean(match.isCompleted),
|
||||
homeParticipants: [...this.parseFriendlyArray(match.homeParticipants)],
|
||||
guestParticipants: [...this.parseFriendlyArray(match.guestParticipants)]
|
||||
}
|
||||
: this.emptyFriendlyMatchForm();
|
||||
this.friendlyMatchDialog.isOpen = true;
|
||||
},
|
||||
closeFriendlyMatchDialog() {
|
||||
this.friendlyMatchDialog.isOpen = false;
|
||||
this.friendlyMatchDialog.editingId = null;
|
||||
this.friendlyMatchDialog.form = this.emptyFriendlyMatchForm();
|
||||
},
|
||||
addFriendlyParticipant(field, memberId) {
|
||||
const list = this.friendlyMatchDialog.form[field];
|
||||
if (list.some(p => p.type === 'member' && Number(p.memberId) === Number(memberId))) return;
|
||||
list.push({ type: 'member', memberId });
|
||||
},
|
||||
addManualFriendlyParticipant(field, fullName) {
|
||||
const parts = String(fullName).trim().split(/\s+/);
|
||||
const firstName = parts.shift() || '';
|
||||
const lastName = parts.join(' ');
|
||||
this.friendlyMatchDialog.form[field].push({ type: 'manual', firstName, lastName });
|
||||
},
|
||||
removeFriendlyParticipant(field, index) {
|
||||
this.friendlyMatchDialog.form[field].splice(index, 1);
|
||||
},
|
||||
friendlyParticipantLabel(participant, fallback = '') {
|
||||
if (!participant) return fallback;
|
||||
if (participant.type === 'member') {
|
||||
const member = this.friendlyMatchDialog.members.find(m => Number(m.id) === Number(participant.memberId));
|
||||
return member ? `${member.firstName} ${member.lastName}`.trim() : fallback;
|
||||
}
|
||||
return `${participant.firstName || ''} ${participant.lastName || ''}`.trim() || fallback;
|
||||
},
|
||||
friendlyParticipantLabels(participants) {
|
||||
return (Array.isArray(participants) ? participants : [])
|
||||
.map((participant) => this.friendlyParticipantLabel(participant, ''))
|
||||
.filter(Boolean);
|
||||
},
|
||||
friendlyDoubleLabel(labels, index) {
|
||||
if (!labels.length) return '';
|
||||
if (labels.length === 1) return labels[0];
|
||||
const pairings = [
|
||||
[0, 1],
|
||||
[2, 3],
|
||||
[0, 2],
|
||||
[1, 3]
|
||||
];
|
||||
const fallbackStart = (index * 2) % labels.length;
|
||||
const [firstIndex, secondIndex] = pairings[index] || [fallbackStart, fallbackStart + 1];
|
||||
const first = labels[firstIndex % labels.length];
|
||||
const second = labels[secondIndex % labels.length];
|
||||
return first === second ? first : `${first} / ${second}`;
|
||||
},
|
||||
buildGeneratedFriendlyResultRows(match) {
|
||||
const homeLabels = this.friendlyParticipantLabels(match.homeParticipants);
|
||||
const guestLabels = this.friendlyParticipantLabels(match.guestParticipants);
|
||||
const rows = [];
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
rows.push({
|
||||
id: `d-${i + 1}`,
|
||||
type: 'double',
|
||||
homeName: this.friendlyDoubleLabel(homeLabels, i),
|
||||
guestName: this.friendlyDoubleLabel(guestLabels, i),
|
||||
sets: ['', '', '', '', ''],
|
||||
completed: false
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
rows.push({
|
||||
id: `s-${i + 1}`,
|
||||
type: 'single',
|
||||
homeName: homeLabels[i % Math.max(homeLabels.length, 1)] || '',
|
||||
guestName: guestLabels[i % Math.max(guestLabels.length, 1)] || '',
|
||||
sets: ['', '', '', '', ''],
|
||||
completed: false
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
buildFriendlyResultRows(match) {
|
||||
const existing = this.parseFriendlyArray(match.resultDetails);
|
||||
const generated = this.buildGeneratedFriendlyResultRows(match);
|
||||
if (existing.length) {
|
||||
return existing.map((row, index) => ({
|
||||
id: row.id || `m-${index}`,
|
||||
type: row.type === 'double' ? 'double' : 'single',
|
||||
homeName: row.homeName || generated[index]?.homeName || '',
|
||||
guestName: row.guestName || generated[index]?.guestName || '',
|
||||
sets: Array.from({ length: 5 }, (_, i) => row.sets?.[i] || ''),
|
||||
completed: Boolean(row.completed)
|
||||
}));
|
||||
}
|
||||
return generated;
|
||||
},
|
||||
async openFriendlyResultDialog(match) {
|
||||
await this.loadFriendlyMembers();
|
||||
this.friendlyResultDialog.match = match;
|
||||
this.friendlyResultDialog.rows = this.buildFriendlyResultRows(match);
|
||||
this.friendlyResultDialog.error = '';
|
||||
this.friendlyResultDialog.isOpen = true;
|
||||
},
|
||||
closeFriendlyResultDialog() {
|
||||
this.friendlyResultDialog.isOpen = false;
|
||||
this.friendlyResultDialog.match = null;
|
||||
this.friendlyResultDialog.rows = [];
|
||||
this.friendlyResultDialog.error = '';
|
||||
this.friendlyResultDialog.saving = false;
|
||||
this.friendlyResultDialog.saveAgain = false;
|
||||
},
|
||||
async normalizeFriendlySet(row, index) {
|
||||
const value = String(row.sets[index] || '').trim();
|
||||
if (!value) {
|
||||
this.applyFriendlyRowCompletion(row);
|
||||
await this.autoSaveFriendlyResults();
|
||||
return;
|
||||
}
|
||||
const normalized = this.normalizeFriendlySetValue(value);
|
||||
if (!normalized) {
|
||||
this.friendlyResultDialog.error = 'Bitte gültige Sätze eingeben, z.B. 11:7, 7 oder -7.';
|
||||
return;
|
||||
}
|
||||
row.sets[index] = normalized;
|
||||
this.applyFriendlyRowCompletion(row);
|
||||
this.friendlyResultDialog.error = '';
|
||||
await this.autoSaveFriendlyResults();
|
||||
},
|
||||
normalizeFriendlySetValue(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) return '';
|
||||
if (raw.includes(':')) {
|
||||
const parts = raw.split(':');
|
||||
if (parts.length !== 2) return null;
|
||||
const a = Number(parts[0]);
|
||||
const b = Number(parts[1]);
|
||||
if (!Number.isInteger(a) || !Number.isInteger(b) || a < 0 || b < 0) return null;
|
||||
if ((a < 11 && b < 11) || Math.abs(a - b) < 2) return null;
|
||||
return `${a}:${b}`;
|
||||
}
|
||||
const losing = Math.abs(Number(raw));
|
||||
if (!Number.isInteger(losing)) return null;
|
||||
const winning = losing < 10 ? 11 : losing + 2;
|
||||
return raw.startsWith('-') ? `${losing}:${winning}` : `${winning}:${losing}`;
|
||||
},
|
||||
getFriendlyWinningSets() {
|
||||
const value = Number.parseInt(this.friendlyResultDialog.match?.winningSets, 10);
|
||||
return Number.isInteger(value) && value > 0 ? value : 3;
|
||||
},
|
||||
calculateFriendlyRowState(row) {
|
||||
const requiredSets = this.getFriendlyWinningSets();
|
||||
let homeSets = 0;
|
||||
let guestSets = 0;
|
||||
for (let index = 0; index < (row.sets || []).length; index += 1) {
|
||||
const set = row.sets[index];
|
||||
const normalized = this.normalizeFriendlySetValue(set);
|
||||
if (!normalized) continue;
|
||||
const [home, guest] = normalized.split(':').map(Number);
|
||||
if (home > guest) homeSets += 1;
|
||||
if (guest > home) guestSets += 1;
|
||||
if (homeSets >= requiredSets || guestSets >= requiredSets) {
|
||||
return {
|
||||
winner: homeSets > guestSets ? 'home' : 'guest',
|
||||
decisiveIndex: index
|
||||
};
|
||||
}
|
||||
}
|
||||
return { winner: null, decisiveIndex: null };
|
||||
},
|
||||
calculateFriendlyRowWinner(row) {
|
||||
return this.calculateFriendlyRowState(row).winner;
|
||||
},
|
||||
applyFriendlyRowCompletion(row) {
|
||||
const state = this.calculateFriendlyRowState(row);
|
||||
row.completed = Boolean(state.winner);
|
||||
if (state.winner && Number.isInteger(state.decisiveIndex)) {
|
||||
row.sets = row.sets.map((set, index) => index > state.decisiveIndex ? '' : set);
|
||||
}
|
||||
},
|
||||
isFriendlySetClosed(row, index) {
|
||||
const state = this.calculateFriendlyRowState(row);
|
||||
return Boolean(state.winner && Number.isInteger(state.decisiveIndex) && index > state.decisiveIndex);
|
||||
},
|
||||
calculateFriendlyResultScore(rows) {
|
||||
return (rows || []).reduce((score, row) => {
|
||||
const winner = this.calculateFriendlyRowWinner(row);
|
||||
if (winner === 'home') score.home += 1;
|
||||
if (winner === 'guest') score.guest += 1;
|
||||
return score;
|
||||
}, { home: 0, guest: 0 });
|
||||
},
|
||||
async autoSaveFriendlyResults() {
|
||||
if (this.friendlyResultDialog.saving) {
|
||||
this.friendlyResultDialog.saveAgain = true;
|
||||
return;
|
||||
}
|
||||
do {
|
||||
this.friendlyResultDialog.saveAgain = false;
|
||||
await this.saveFriendlyResults(false, { closeDialog: false, reloadMatches: false });
|
||||
} while (this.friendlyResultDialog.saveAgain);
|
||||
},
|
||||
async saveFriendlyResults(isCompleted = false, options = {}) {
|
||||
const { closeDialog = true, reloadMatches = true } = options;
|
||||
const match = this.friendlyResultDialog.match;
|
||||
if (!match) return;
|
||||
for (const row of this.friendlyResultDialog.rows) {
|
||||
const normalizedSets = [];
|
||||
for (const set of row.sets) {
|
||||
if (!String(set || '').trim()) {
|
||||
normalizedSets.push('');
|
||||
continue;
|
||||
}
|
||||
const normalized = this.normalizeFriendlySetValue(set);
|
||||
if (!normalized) {
|
||||
this.friendlyResultDialog.error = 'Bitte ungültige Sätze korrigieren.';
|
||||
return;
|
||||
}
|
||||
normalizedSets.push(normalized);
|
||||
}
|
||||
row.sets = normalizedSets;
|
||||
this.applyFriendlyRowCompletion(row);
|
||||
}
|
||||
const score = this.calculateFriendlyResultScore(this.friendlyResultDialog.rows);
|
||||
try {
|
||||
this.friendlyResultDialog.saving = true;
|
||||
await apiClient.put(`/friendly-matches/${this.currentClub}/${match.id}`, {
|
||||
homeMatchPoints: score.home,
|
||||
guestMatchPoints: score.guest,
|
||||
isCompleted,
|
||||
resultDetails: this.friendlyResultDialog.rows
|
||||
});
|
||||
match.homeMatchPoints = score.home;
|
||||
match.guestMatchPoints = score.guest;
|
||||
match.isCompleted = isCompleted;
|
||||
match.resultDetails = this.friendlyResultDialog.rows.map((row) => ({ ...row, sets: [...row.sets] }));
|
||||
if (closeDialog) {
|
||||
this.closeFriendlyResultDialog();
|
||||
}
|
||||
if (reloadMatches) {
|
||||
await this.loadFriendlyMatches();
|
||||
}
|
||||
} catch (error) {
|
||||
this.friendlyResultDialog.error = getSafeErrorMessage(error, 'Ergebnisse konnten nicht gespeichert werden.');
|
||||
} finally {
|
||||
this.friendlyResultDialog.saving = false;
|
||||
}
|
||||
},
|
||||
async completeFriendlyResults() {
|
||||
await this.saveFriendlyResults(true);
|
||||
},
|
||||
async saveFriendlyMatch() {
|
||||
try {
|
||||
const payload = {
|
||||
...this.friendlyMatchDialog.form,
|
||||
homeParticipants: this.parseFriendlyArray(this.friendlyMatchDialog.form.homeParticipants).map((participant) => ({ ...participant })),
|
||||
guestParticipants: this.parseFriendlyArray(this.friendlyMatchDialog.form.guestParticipants).map((participant) => ({ ...participant }))
|
||||
};
|
||||
const id = this.friendlyMatchDialog.editingId;
|
||||
if (id) {
|
||||
await apiClient.put(`/friendly-matches/${this.currentClub}/${id}`, payload);
|
||||
} else {
|
||||
await apiClient.post(`/friendly-matches/${this.currentClub}`, payload);
|
||||
}
|
||||
this.closeFriendlyMatchDialog();
|
||||
await this.loadFriendlyMatches();
|
||||
} catch (error) {
|
||||
await this.showInfo('Fehler', getSafeErrorMessage(error, 'Freundschaftsspiel konnte nicht gespeichert werden.'), '', 'error');
|
||||
}
|
||||
},
|
||||
async deleteFriendlyMatch() {
|
||||
if (!this.friendlyMatchDialog.editingId) return;
|
||||
const confirmed = await this.showConfirm('Freundschaftsspiel löschen', 'Soll dieses Freundschaftsspiel gelöscht werden?', '', 'warning');
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await apiClient.delete(`/friendly-matches/${this.currentClub}/${this.friendlyMatchDialog.editingId}`);
|
||||
this.closeFriendlyMatchDialog();
|
||||
await this.loadFriendlyMatches();
|
||||
} catch (error) {
|
||||
await this.showInfo('Fehler', getSafeErrorMessage(error, 'Freundschaftsspiel konnte nicht gelöscht werden.'), '', 'error');
|
||||
}
|
||||
},
|
||||
async importCSV() {
|
||||
if (!this.selectedFile) return;
|
||||
|
||||
@@ -1159,6 +1686,25 @@ export default {
|
||||
this.matches = [];
|
||||
}
|
||||
},
|
||||
async loadFriendlyMatches() {
|
||||
this.selectedLeague = this.friendlyMatchesLabel;
|
||||
this.selectedTeam = null;
|
||||
this.ownLeagueMatches = [];
|
||||
this.allLeagueMatches = [];
|
||||
this.leagueMatchScope = 'own';
|
||||
this.selectedComparisonTeamName = '';
|
||||
this.activeTab = 'schedule';
|
||||
this.leagueTable = [];
|
||||
try {
|
||||
const response = await apiClient.get(`/friendly-matches/${this.currentClub}`);
|
||||
this.friendlyMatches = response.data || [];
|
||||
this.matches = this.sortMatchesByDateTime(this.friendlyMatches);
|
||||
} catch (error) {
|
||||
this.showInfo(this.$t('messages.error'), getSafeErrorMessage(error, 'Freundschaftsspiele konnten nicht geladen werden.'), '', 'error');
|
||||
this.friendlyMatches = [];
|
||||
this.matches = [];
|
||||
}
|
||||
},
|
||||
formatDate(date) {
|
||||
if (!date) return 'N/A';
|
||||
const d = new Date(date);
|
||||
@@ -1401,10 +1947,35 @@ export default {
|
||||
this.loadAllMatches();
|
||||
} else if (this.selectedLeague === this.$t('schedule.adultSchedule')) {
|
||||
this.loadAdultMatches();
|
||||
} else if (this.selectedLeague === this.friendlyMatchesLabel) {
|
||||
this.loadFriendlyMatches();
|
||||
}
|
||||
},
|
||||
|
||||
handleScheduleMatchUpdated(payload) {
|
||||
if (this.friendlyOnly) {
|
||||
if (payload?.match?.isFriendly) {
|
||||
const idx = this.matches.findIndex(m => m.id === payload.match.id);
|
||||
if (idx !== -1) {
|
||||
this.matches.splice(idx, 1, payload.match);
|
||||
} else {
|
||||
this.matches.push(payload.match);
|
||||
}
|
||||
this.friendlyMatches = [...this.matches];
|
||||
this.matches = this.sortMatchesByDateTime(this.matches);
|
||||
return;
|
||||
}
|
||||
if (payload?.matchId != null) {
|
||||
const idx = this.matches.findIndex(m => m.id === payload.matchId);
|
||||
if (idx !== -1) {
|
||||
this.matches.splice(idx, 1);
|
||||
this.friendlyMatches = [...this.matches];
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.refreshScheduleData();
|
||||
return;
|
||||
}
|
||||
if (payload?.match && payload.matchId != null) {
|
||||
const idx = this.matches.findIndex(m => m.id === payload.matchId);
|
||||
if (idx !== -1) {
|
||||
@@ -1437,9 +2008,10 @@ export default {
|
||||
},
|
||||
},
|
||||
async created() {
|
||||
// Teams werden geladen, sobald eine Saison ausgewählt ist
|
||||
// Die SeasonSelector-Komponente wählt automatisch die aktuelle Saison aus
|
||||
// und ruft anschließend onSeasonChange auf, was loadTeams() ausführt
|
||||
if (this.friendlyOnly) {
|
||||
await this.loadFriendlyMatches();
|
||||
return;
|
||||
}
|
||||
this.loadTeams();
|
||||
},
|
||||
beforeUnmount() {
|
||||
@@ -1535,6 +2107,116 @@ td {
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.friendly-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.friendly-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.friendly-form-grid label,
|
||||
.friendly-participant-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.friendly-form-grid input,
|
||||
.friendly-form-grid select,
|
||||
.friendly-add-row input,
|
||||
.friendly-add-row select {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.friendly-checkbox {
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.friendly-participants {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.friendly-participant-column {
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.friendly-add-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.friendly-participant-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.5rem 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.friendly-participant-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: var(--background-soft, #f7f7f7);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.friendly-actions-cell {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.friendly-result-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.friendly-result-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.friendly-result-table input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.35rem 0.45rem;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.friendly-result-table .player-input {
|
||||
min-width: 12rem;
|
||||
}
|
||||
|
||||
.friendly-result-table .set-input {
|
||||
width: 4.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.friendly-result-error {
|
||||
color: #b00020;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -71,6 +71,7 @@ import de.tsschulz.tt_tagebuch.shared.api.models.InternalTournamentDetailDto
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import de.tsschulz.tt_tagebuch.shared.api.models.TournamentClubTournamentBody
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
@@ -79,6 +80,9 @@ import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.foundation.background
|
||||
import de.tsschulz.tt_tagebuch.shared.api.models.TournamentMatchActiveBody
|
||||
|
||||
@Composable
|
||||
internal fun TournamentEditorClassesTab(
|
||||
@@ -96,6 +100,7 @@ internal fun TournamentEditorClassesTab(
|
||||
var newDoubles by remember { mutableStateOf(false) }
|
||||
var showDistributedDialog by remember { mutableStateOf(false) }
|
||||
var distributedMatches by remember { mutableStateOf<List<TournamentMatchDto>>(emptyList()) }
|
||||
var distributedMessage by remember { mutableStateOf<String?>(null) }
|
||||
if (showAdd) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showAdd = false },
|
||||
@@ -147,12 +152,26 @@ internal fun TournamentEditorClassesTab(
|
||||
val resp = withContext(Dispatchers.IO) {
|
||||
api.distributeTables(de.tsschulz.tt_tagebuch.shared.api.models.TournamentClubTournamentBody(clubId, tournamentId))
|
||||
}
|
||||
// If server returned updated matches, fetch full match details to show names
|
||||
if (resp.updated.isNotEmpty()) {
|
||||
val allMatches = withContext(Dispatchers.IO) { api.listMatches(clubId, tournamentId) }
|
||||
val ids = resp.updated.map { it.id }.toSet()
|
||||
distributedMatches = allMatches.filter { it.id in ids }
|
||||
|
||||
// If server returned no updates, show info message
|
||||
if (resp.updated.isEmpty()) {
|
||||
// show simple AlertDialog with server message or default
|
||||
showDistributedDialog = true
|
||||
distributedMatches = emptyList()
|
||||
distributedMessage = resp.message ?: tr("tournaments.tablesDistributed", "Tische wurden verteilt.")
|
||||
} else {
|
||||
// If server already included player objects, use them directly
|
||||
val firstHasPlayers = resp.updated.firstOrNull()?.player1 != null || resp.updated.firstOrNull()?.player2 != null
|
||||
if (firstHasPlayers) {
|
||||
distributedMatches = resp.updated
|
||||
showDistributedDialog = true
|
||||
} else {
|
||||
// fallback: fetch full matches to extract player info
|
||||
val allMatches = withContext(Dispatchers.IO) { api.listMatches(clubId, tournamentId) }
|
||||
val ids = resp.updated.map { it.id }.toSet()
|
||||
distributedMatches = allMatches.filter { it.id in ids }
|
||||
showDistributedDialog = true
|
||||
}
|
||||
}
|
||||
}.onFailure { onError(it.message) }
|
||||
onReload()
|
||||
@@ -167,24 +186,29 @@ internal fun TournamentEditorClassesTab(
|
||||
title = { Text(tr("tournaments.distributeTablesResult", "Tischverteilung")) },
|
||||
text = {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
// Header
|
||||
Row(Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
||||
Text(tr("tournaments.playerA", "Spieler A"), modifier = Modifier.weight(1f), fontWeight = FontWeight.SemiBold)
|
||||
Text(tr("tournaments.playerB", "Spieler B"), modifier = Modifier.weight(1f), fontWeight = FontWeight.SemiBold)
|
||||
Text(tr("tournaments.tableShort", "Tisch"), modifier = Modifier.width(48.dp), fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
Divider()
|
||||
LazyColumn {
|
||||
items(distributedMatches) { m ->
|
||||
val name1 = extractPlayerName(m.player1)
|
||||
val name2 = extractPlayerName(m.player2)
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
Text(name1, modifier = Modifier.weight(1f))
|
||||
Text("–", modifier = Modifier.width(16.dp), textAlign = androidx.compose.ui.text.style.TextAlign.Center)
|
||||
Text(name2, modifier = Modifier.weight(1f))
|
||||
Text((m.tableNumber ?: "-").toString(), modifier = Modifier.width(48.dp))
|
||||
if (distributedMatches.isEmpty()) {
|
||||
// No assignments returned
|
||||
Text(distributedMessage ?: tr("tournaments.tablesDistributed", "Tische wurden verteilt."))
|
||||
} else {
|
||||
// Header
|
||||
Row(Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
||||
Text(tr("tournaments.playerA", "Spieler A"), modifier = Modifier.weight(1f), fontWeight = FontWeight.SemiBold)
|
||||
Text(tr("tournaments.playerB", "Spieler B"), modifier = Modifier.weight(1f), fontWeight = FontWeight.SemiBold)
|
||||
Text(tr("tournaments.tableShort", "Tisch"), modifier = Modifier.width(48.dp), fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
Divider()
|
||||
LazyColumn {
|
||||
items(distributedMatches) { m ->
|
||||
val name1 = extractPlayerName(m.player1)
|
||||
val name2 = extractPlayerName(m.player2)
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
Text(name1, modifier = Modifier.weight(1f))
|
||||
Text("–", modifier = Modifier.width(16.dp), textAlign = androidx.compose.ui.text.style.TextAlign.Center)
|
||||
Text(name2, modifier = Modifier.weight(1f))
|
||||
Text((m.tableNumber ?: "-").toString(), modifier = Modifier.width(48.dp))
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -505,6 +529,7 @@ internal fun TournamentEditorMatchesTab(
|
||||
matches: List<TournamentMatchDto>,
|
||||
winningSets: Int,
|
||||
detail: InternalTournamentDetailDto,
|
||||
groupsJson: JsonElement?,
|
||||
tr: (String, String) -> String,
|
||||
api: TournamentsApi,
|
||||
scope: CoroutineScope,
|
||||
@@ -519,6 +544,10 @@ internal fun TournamentEditorMatchesTab(
|
||||
) {
|
||||
var confirmDelete by remember { mutableStateOf<Pair<Int, Int>?>(null) }
|
||||
val hasKO = matches.any { (it.round ?: "").lowercase() != "group" && (it.round ?: "").isNotBlank() }
|
||||
var showDistributedDialog by remember { mutableStateOf(false) }
|
||||
var distributedMatches by remember { mutableStateOf<List<TournamentMatchDto>>(emptyList()) }
|
||||
var distributedMessage by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
@@ -546,6 +575,32 @@ internal fun TournamentEditorMatchesTab(
|
||||
},
|
||||
) { Text(tr("tournaments.startKnockout", "K.-o. starten")) }
|
||||
}
|
||||
TextButton(onClick = {
|
||||
scope.launch {
|
||||
runCatching {
|
||||
val resp = withContext(Dispatchers.IO) {
|
||||
api.distributeTables(TournamentClubTournamentBody(clubId, tournamentId))
|
||||
}
|
||||
if (resp.updated.isEmpty()) {
|
||||
showDistributedDialog = true
|
||||
distributedMatches = emptyList()
|
||||
distributedMessage = resp.message ?: tr("tournaments.tablesDistributed", "Tische wurden verteilt.")
|
||||
} else {
|
||||
val firstHasPlayers = resp.updated.firstOrNull()?.player1 != null || resp.updated.firstOrNull()?.player2 != null
|
||||
if (firstHasPlayers) {
|
||||
distributedMatches = resp.updated
|
||||
showDistributedDialog = true
|
||||
} else {
|
||||
val allMatches = withContext(Dispatchers.IO) { api.listMatches(clubId, tournamentId) }
|
||||
val ids = resp.updated.map { it.id }.toSet()
|
||||
distributedMatches = allMatches.filter { it.id in ids }
|
||||
showDistributedDialog = true
|
||||
}
|
||||
}
|
||||
}.onFailure { onError(it.message) }
|
||||
onReload()
|
||||
}
|
||||
}) { Text(tr("tournaments.distributeTables", "Freie Tische verteilen")) }
|
||||
}
|
||||
// Table-like view: show header + flattened rows so Android resembles web UI
|
||||
val displayList = matches.sortedWith(compareBy<TournamentMatchDto> {
|
||||
@@ -555,6 +610,28 @@ internal fun TournamentEditorMatchesTab(
|
||||
it.groupRound ?: it.round.toIntOrNull() ?: Int.MAX_VALUE
|
||||
}.thenBy { it.groupId ?: Int.MAX_VALUE }.thenBy { it.groupRound ?: 0 })
|
||||
|
||||
// build group id -> sequential number map from groupsJson (order returned by backend)
|
||||
val groupNumberById = remember(groupsJson) {
|
||||
val map = mutableMapOf<Int, Int>()
|
||||
try {
|
||||
if (groupsJson is JsonArray) {
|
||||
var idx = 1
|
||||
for (g in groupsJson) {
|
||||
// Backend returns groups as { groupId, classId, groupNumber, participants }
|
||||
val id = g.jsonObject["groupId"]?.jsonPrimitive?.intOrNull
|
||||
?: g.jsonObject["id"]?.jsonPrimitive?.intOrNull
|
||||
if (id != null) {
|
||||
map[id] = idx
|
||||
idx++
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// ignore
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
// header
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Runde", fontWeight = FontWeight.SemiBold, modifier = Modifier.width(56.dp))
|
||||
@@ -614,37 +691,26 @@ internal fun TournamentEditorMatchesTab(
|
||||
}
|
||||
}
|
||||
val roundLabel = m.groupRound?.toString() ?: m.round.takeIf { it.isNotBlank() } ?: "-"
|
||||
val groupLabel = m.groupId?.let { "${tr("tournaments.group", "Gruppe")} $it" } ?: "-"
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
val groupNumber = m.groupId?.let { groupNumberById[it] }
|
||||
val groupLabel = groupNumber?.let { "${tr("tournaments.group", "Gruppe")} $it" } ?: "-"
|
||||
val rowBg = when {
|
||||
m.isFinished == true -> MaterialTheme.colors.onSurface.copy(alpha = 0.06f)
|
||||
m.isActive == true -> Color(0xFFFFF3E0)
|
||||
else -> Color.Transparent
|
||||
}
|
||||
|
||||
Row(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(rowBg)
|
||||
.padding(vertical = 6.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(roundLabel, modifier = Modifier.width(56.dp))
|
||||
Text(groupLabel, modifier = Modifier.width(96.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("${displayNameFromPlayerJson(m.player1)} – ${displayNameFromPlayerJson(m.player2)}", style = MaterialTheme.typography.body2)
|
||||
val results = m.tournamentResults
|
||||
if (!results.isNullOrEmpty()) {
|
||||
results.sortedBy { it.set }.forEach { r ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val a = r.pointsPlayer1?.toString() ?: "-"
|
||||
val b = r.pointsPlayer2?.toString() ?: "-"
|
||||
Text("${r.set}: $a:$b", style = MaterialTheme.typography.caption)
|
||||
if (m.isFinished != true) {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
IconButton(onClick = { confirmDelete = Pair(m.id, r.set) }, modifier = Modifier.size(28.dp)) {
|
||||
Icon(
|
||||
Icons.Filled.Close,
|
||||
contentDescription = "Löschen",
|
||||
tint = MaterialTheme.colors.error,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(0.7f)) {
|
||||
if (m.isFinished == true) {
|
||||
Text(formatMatchSets(m))
|
||||
Text((m.result ?: "—") + " ✓")
|
||||
} else if (m.result == "BYE") {
|
||||
Text("BYE")
|
||||
} else {
|
||||
@@ -676,9 +742,47 @@ internal fun TournamentEditorMatchesTab(
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(m.tournamentResults?.size?.toString() ?: "0", modifier = Modifier.width(72.dp))
|
||||
Column(modifier = Modifier.width(72.dp)) {
|
||||
val results = m.tournamentResults
|
||||
if (!results.isNullOrEmpty()) {
|
||||
Text(formatMatchSets(m))
|
||||
results.sortedBy { it.set }.forEach { r ->
|
||||
val a = r.pointsPlayer1?.let { kotlin.math.abs(it).toString() } ?: "-"
|
||||
val b = r.pointsPlayer2?.let { kotlin.math.abs(it).toString() } ?: "-"
|
||||
Text("${r.set}: $a:$b", style = MaterialTheme.typography.caption)
|
||||
}
|
||||
} else {
|
||||
Text(m.tournamentResults?.size?.toString() ?: "0")
|
||||
}
|
||||
}
|
||||
// Table number editor
|
||||
Column(modifier = Modifier.width(96.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
var tableInput by remember(m.id) { mutableStateOf(m.tableNumber?.toString() ?: "") }
|
||||
var savingTable by remember(m.id) { mutableStateOf(false) }
|
||||
OutlinedTextField(
|
||||
value = tableInput,
|
||||
onValueChange = { tableInput = it.filter { ch -> ch.isDigit() || ch == '-' } },
|
||||
singleLine = true,
|
||||
label = { Text(tr("tournaments.tableShort", "Tisch")) },
|
||||
modifier = Modifier.width(72.dp).onFocusChanged { fs ->
|
||||
if (!fs.isFocused && !savingTable) {
|
||||
val nt = tableInput.toIntOrNull()
|
||||
savingTable = true
|
||||
scope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
api.setMatchTable(clubId, tournamentId, m.id, TournamentMatchTableBody(tableNumber = nt))
|
||||
}
|
||||
}.onFailure { onError(it.message) }
|
||||
savingTable = false
|
||||
onReload()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
// actions column
|
||||
Column(modifier = Modifier.width(96.dp)) {
|
||||
Column(modifier = Modifier.width(120.dp)) {
|
||||
if (m.isFinished != true) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
TextButton(onClick = {
|
||||
@@ -692,6 +796,17 @@ internal fun TournamentEditorMatchesTab(
|
||||
onReload()
|
||||
}
|
||||
}) { Text(tr("tournaments.finishMatchShort", "Fertig")) }
|
||||
// start/stop match
|
||||
TextButton(onClick = {
|
||||
scope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
api.setMatchActive(clubId, tournamentId, m.id, TournamentMatchActiveBody(isActive = true))
|
||||
}
|
||||
}.onFailure { onError(it.message) }
|
||||
onReload()
|
||||
}
|
||||
}) { Text(tr("tournaments.startMatch", "Starten")) }
|
||||
}
|
||||
} else {
|
||||
TextButton(onClick = {
|
||||
@@ -705,6 +820,19 @@ internal fun TournamentEditorMatchesTab(
|
||||
}
|
||||
}) { Text(tr("tournaments.correct", "Korrigieren")) }
|
||||
}
|
||||
// Stop button shown regardless (to allow stopping active matches)
|
||||
if (m.isActive == true) {
|
||||
TextButton(onClick = {
|
||||
scope.launch {
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
api.setMatchActive(clubId, tournamentId, m.id, TournamentMatchActiveBody(isActive = false))
|
||||
}
|
||||
}.onFailure { onError(it.message) }
|
||||
onReload()
|
||||
}
|
||||
}) { Text(tr("tournaments.stopMatch", "Stoppen")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
@@ -740,6 +868,42 @@ internal fun TournamentEditorMatchesTab(
|
||||
}
|
||||
)
|
||||
}
|
||||
if (showDistributedDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDistributedDialog = false },
|
||||
title = { Text(tr("tournaments.distributeTablesResult", "Tischverteilung")) },
|
||||
text = {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
if (distributedMatches.isEmpty()) {
|
||||
Text(distributedMessage ?: tr("tournaments.tablesDistributed", "Tische wurden verteilt."))
|
||||
} else {
|
||||
Row(Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
||||
Text(tr("tournaments.playerA", "Spieler A"), modifier = Modifier.weight(1f), fontWeight = FontWeight.SemiBold)
|
||||
Text(tr("tournaments.playerB", "Spieler B"), modifier = Modifier.weight(1f), fontWeight = FontWeight.SemiBold)
|
||||
Text(tr("tournaments.tableShort", "Tisch"), modifier = Modifier.width(48.dp), fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
Divider()
|
||||
LazyColumn {
|
||||
items(distributedMatches) { m ->
|
||||
val name1 = displayNameFromPlayerJson(m.player1)
|
||||
val name2 = displayNameFromPlayerJson(m.player2)
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
Text(name1, modifier = Modifier.weight(1f))
|
||||
Text("–", modifier = Modifier.width(16.dp), textAlign = androidx.compose.ui.text.style.TextAlign.Center)
|
||||
Text(name2, modifier = Modifier.weight(1f))
|
||||
Text(m.tableNumber?.toString() ?: "-", modifier = Modifier.width(48.dp))
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showDistributedDialog = false }) { Text(tr("common.ok", "OK")) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -769,8 +933,13 @@ private fun MatchResultRow(
|
||||
if (setsText.isNotBlank()) {
|
||||
Text(setsText, style = MaterialTheme.typography.body2)
|
||||
}
|
||||
val resultDisplay = if (!m.tournamentResults.isNullOrEmpty()) {
|
||||
formatMatchSets(m) + if (m.isFinished == true) " ✓" else ""
|
||||
} else {
|
||||
(m.result ?: "—") + if (m.isFinished == true) " ✓" else ""
|
||||
}
|
||||
Text(
|
||||
"${tr("tournaments.result", "Ergebnis")}: ${m.result ?: "—"} ${if (m.isFinished == true) "✓" else ""}",
|
||||
"${tr("tournaments.result", "Ergebnis")}: $resultDisplay",
|
||||
style = MaterialTheme.typography.caption,
|
||||
)
|
||||
if (m.isFinished != true) {
|
||||
@@ -889,13 +1058,16 @@ private fun normalizeResult(s: String): String? {
|
||||
private fun formatMatchSets(match: TournamentMatchDto): String {
|
||||
val results = match.tournamentResults.orEmpty()
|
||||
if (results.isEmpty()) return match.result ?: "—"
|
||||
return results
|
||||
.sortedBy { it.set }
|
||||
.joinToString(", ") { result ->
|
||||
val p1 = result.pointsPlayer1?.let { kotlin.math.abs(it).toString() } ?: "-"
|
||||
val p2 = result.pointsPlayer2?.let { kotlin.math.abs(it).toString() } ?: "-"
|
||||
"$p1:$p2"
|
||||
var p1Wins = 0
|
||||
var p2Wins = 0
|
||||
for (r in results.sortedBy { it.set }) {
|
||||
val a = r.pointsPlayer1?.let { kotlin.math.abs(it) }
|
||||
val b = r.pointsPlayer2?.let { kotlin.math.abs(it) }
|
||||
if (a != null && b != null) {
|
||||
if (a > b) p1Wins++ else if (b > a) p2Wins++
|
||||
}
|
||||
}
|
||||
return "${p1Wins}:${p2Wins}"
|
||||
}
|
||||
|
||||
private fun displayNameFromPlayerJson(el: JsonElement?): String {
|
||||
|
||||
@@ -256,6 +256,7 @@ internal fun InternalTournamentEditorScreen(
|
||||
matches = matches,
|
||||
winningSets = d.winningSets ?: 3,
|
||||
detail = d,
|
||||
groupsJson = groupsJson,
|
||||
tr = ::tr,
|
||||
api = api,
|
||||
scope = scope,
|
||||
|
||||
@@ -71,6 +71,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
|
||||
private enum class WorkspaceTab { Overview, Competitions, Results }
|
||||
|
||||
private val OfficialPanelBorder = Color(0xFFDBE3F0)
|
||||
@@ -1482,6 +1483,8 @@ private fun ResultsTabContent(
|
||||
onReload: () -> Unit,
|
||||
onShowInfo: (String, String) -> Unit,
|
||||
) {
|
||||
|
||||
|
||||
val rows = remember(parsed, participationMap.toMap()) {
|
||||
resultsRows(parsed, participationMap.toMap(), memberNameById)
|
||||
}
|
||||
@@ -1511,6 +1514,8 @@ private fun ResultsTabContent(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
Reference in New Issue
Block a user