feat: Implement friendly match management features
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:
Torsten Schulz (local)
2026-05-18 00:43:42 +02:00
parent 040e758044
commit 5dfdcb63bc
16 changed files with 1551 additions and 87 deletions

View 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' });
}
};

View File

@@ -0,0 +1,2 @@
ALTER TABLE `friendly_match`
ADD COLUMN IF NOT EXISTS `result_details` JSON NULL AFTER `guest_participants`;

View 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;

View 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;

View File

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

View 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;

View File

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

View 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();

View File

@@ -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'}`;

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -256,6 +256,7 @@ internal fun InternalTournamentEditorScreen(
matches = matches,
winningSets = d.winningSets ?: 3,
detail = d,
groupsJson = groupsJson,
tr = ::tr,
api = api,
scope = scope,

View File

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