Freundschaftsspiele korrigiert
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 52s

This commit is contained in:
Torsten Schulz (local)
2026-06-06 12:42:17 +02:00
parent 5727404f88
commit 5194d4582f
22 changed files with 1527 additions and 177 deletions

1
.gitignore vendored
View File

@@ -17,3 +17,4 @@ mobile-app/build/
mobile-app/composeApp/build/
mobile-app/shared/build/
mobile-app/local.properties
mobile-app/signing.properties

View File

@@ -0,0 +1,52 @@
import clubVenueService from '../services/clubVenueService.js';
const handleError = (res, label, error) => {
if (error.message === 'noaccess') return res.status(403).json({ error: 'noaccess' });
if (error.statusCode || error.status) return res.status(error.statusCode || error.status).json({ error: error.message });
console.error(`[${label}] - error:`, error);
return res.status(500).json({ error: 'internalerror' });
};
export const listClubVenues = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubId } = req.params;
const venues = await clubVenueService.list(token, clubId);
res.status(200).json(venues);
} catch (error) {
handleError(res, 'listClubVenues', error);
}
};
export const createClubVenue = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubId } = req.params;
const venue = await clubVenueService.create(token, clubId, req.body);
res.status(201).json(venue);
} catch (error) {
handleError(res, 'createClubVenue', error);
}
};
export const updateClubVenue = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubId, venueId } = req.params;
const venue = await clubVenueService.update(token, clubId, venueId, req.body);
res.status(200).json(venue);
} catch (error) {
handleError(res, 'updateClubVenue', error);
}
};
export const deleteClubVenue = async (req, res) => {
try {
const { authcode: token } = req.headers;
const { clubId, venueId } = req.params;
const result = await clubVenueService.delete(token, clubId, venueId);
res.status(200).json(result);
} catch (error) {
handleError(res, 'deleteClubVenue', error);
}
};

View File

@@ -37,6 +37,21 @@ export const listSharedFriendlyMatches = async (req, res) => {
}
};
export const getSharedFriendlyMatchMembers = async (req, res) => {
try {
const members = await friendlyMatchSharedService.membersForSide(
userTokenFrom(req),
req.params.clubId,
req.params.matchId,
req.params.side,
);
res.status(200).json(members);
} catch (error) {
console.error('[getSharedFriendlyMatchMembers] Error:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Mitglieder konnten nicht geladen werden.' });
}
};
export const updateSharedFriendlyMatch = async (req, res) => {
try {
const match = await friendlyMatchSharedService.updateShared(

View File

@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS `club_venue` (
`id` INT NOT NULL AUTO_INCREMENT,
`club_id` INT NOT NULL,
`name` VARCHAR(255) NOT NULL,
`address` VARCHAR(255) NULL,
`zip` VARCHAR(32) NULL,
`city` VARCHAR(255) NULL,
`sort_order` INT NOT NULL DEFAULT 0,
`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_club_venue_club_sort` (`club_id`, `sort_order`),
KEY `idx_club_venue_club_name` (`club_id`, `name`),
CONSTRAINT `fk_club_venue_club` FOREIGN KEY (`club_id`) REFERENCES `clubs` (`id`) ON DELETE CASCADE
);

View File

@@ -0,0 +1,54 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
import Club from './Club.js';
const ClubVenue = sequelize.define('ClubVenue', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: Club,
key: 'id',
},
onDelete: 'CASCADE',
field: 'club_id',
},
name: {
type: DataTypes.STRING(255),
allowNull: false,
},
address: {
type: DataTypes.STRING(255),
allowNull: true,
},
zip: {
type: DataTypes.STRING(32),
allowNull: true,
},
city: {
type: DataTypes.STRING(255),
allowNull: true,
},
sortOrder: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'sort_order',
},
}, {
tableName: 'club_venue',
underscored: true,
timestamps: true,
indexes: [
{ fields: ['club_id', 'sort_order'] },
{ fields: ['club_id', 'name'] },
],
});
export default ClubVenue;

View File

@@ -69,6 +69,7 @@ import TrainingGroup from './TrainingGroup.js';
import MemberTrainingGroup from './MemberTrainingGroup.js';
import ClubDisabledPresetGroup from './ClubDisabledPresetGroup.js';
import TrainingTime from './TrainingTime.js';
import ClubVenue from './ClubVenue.js';
// Official tournaments relations
OfficialTournament.hasMany(OfficialCompetition, { foreignKey: 'tournamentId', as: 'competitions' });
OfficialCompetition.belongsTo(OfficialTournament, { foreignKey: 'tournamentId', as: 'tournament' });
@@ -443,6 +444,9 @@ FriendlyMatchInvitation.hasOne(FriendlyMatchShared, {
constraints: false,
});
Club.hasMany(ClubVenue, { foreignKey: 'clubId', as: 'venues' });
ClubVenue.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
export {
User,
Log,
@@ -512,4 +516,5 @@ export {
MemberTrainingGroup,
ClubDisabledPresetGroup,
TrainingTime,
ClubVenue,
};

View File

@@ -0,0 +1,18 @@
import express from 'express';
import { authenticate } from '../middleware/authMiddleware.js';
import { authorize } from '../middleware/authorizationMiddleware.js';
import {
createClubVenue,
deleteClubVenue,
listClubVenues,
updateClubVenue,
} from '../controllers/clubVenueController.js';
const router = express.Router();
router.get('/:clubId', authenticate, authorize('settings', 'read'), listClubVenues);
router.post('/:clubId', authenticate, authorize('settings', 'write'), createClubVenue);
router.put('/:clubId/:venueId', authenticate, authorize('settings', 'write'), updateClubVenue);
router.delete('/:clubId/:venueId', authenticate, authorize('settings', 'write'), deleteClubVenue);
export default router;

View File

@@ -2,6 +2,7 @@ import express from 'express';
import {
deleteSharedFriendlyMatch,
findSharedFriendlyMatches,
getSharedFriendlyMatchMembers,
listSharedFriendlyMatches,
updateSharedFriendlyMatch,
updateSharedFriendlyMatchPlayers,
@@ -13,6 +14,7 @@ const router = express.Router();
router.get('/find', authenticate, authorize('schedule', 'read'), findSharedFriendlyMatches);
router.get('/shared/:clubId', authenticate, authorize('schedule', 'read'), listSharedFriendlyMatches);
router.get('/shared/:clubId/:matchId/members/:side', authenticate, authorize('schedule', 'read'), getSharedFriendlyMatchMembers);
router.put('/shared/:clubId/:matchId', authenticate, authorize('schedule', 'write'), updateSharedFriendlyMatch);
router.patch('/shared/:clubId/:matchId/players', authenticate, authorize('schedule', 'write'), updateSharedFriendlyMatchPlayers);
router.delete('/shared/:clubId/:matchId', authenticate, authorize('schedule', 'write'), deleteSharedFriendlyMatch);

View File

@@ -16,7 +16,7 @@ import {
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, ClickTtAccount, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact, MemberTtrHistory, MemberPlayInterest,
MemberOrder, MemberOrderHistory, MemberGroupPhoto, BillingTemplate, BillingTemplateField, BillingRun, BillingDocument, BillingDocumentValue, BillingUserSetting, FriendlyMatch, TrainingCancellation
, FriendlyMatchShared, FriendlyMatchInvitation
, CalendarEvent
, CalendarEvent, ClubVenue
} from './models/index.js';
import authRoutes from './routes/authRoutes.js';
import clubRoutes from './routes/clubRoutes.js';
@@ -57,6 +57,7 @@ import clickTtHttpPageRoutes from './routes/clickTtHttpPageRoutes.js';
import memberTransferConfigRoutes from './routes/memberTransferConfigRoutes.js';
import trainingGroupRoutes from './routes/trainingGroupRoutes.js';
import trainingTimeRoutes from './routes/trainingTimeRoutes.js';
import clubVenueRoutes from './routes/clubVenueRoutes.js';
import trainingCancellationRoutes from './routes/trainingCancellationRoutes.js';
import memberOrderRoutes from './routes/memberOrderRoutes.js';
import memberGroupPhotoRoutes from './routes/memberGroupPhotoRoutes.js';
@@ -355,6 +356,7 @@ app.use('/api/clicktt', clickTtHttpPageRoutes);
app.use('/api/member-transfer-config', memberTransferConfigRoutes);
app.use('/api/training-groups', trainingGroupRoutes);
app.use('/api/training-times', trainingTimeRoutes);
app.use('/api/club-venues', clubVenueRoutes);
app.use('/api/training-cancellations', trainingCancellationRoutes);
app.use('/api/member-orders', memberOrderRoutes);
app.use('/api/member-group-photos', memberGroupPhotoRoutes);
@@ -565,6 +567,7 @@ app.use((err, req, res, next) => {
await safeSync(User);
await safeSync(Club);
await safeSync(ClubVenue);
await safeSync(UserClub);
await safeSync(Log);
await safeSync(Member);

View File

@@ -0,0 +1,58 @@
import ClubVenue from '../models/ClubVenue.js';
import { checkAccess } from '../utils/userUtils.js';
import HttpError from '../exceptions/HttpError.js';
const clean = (value) => String(value || '').trim();
const cleanNullable = (value) => clean(value) || null;
class ClubVenueService {
async list(userToken, clubId) {
await checkAccess(userToken, clubId);
return ClubVenue.findAll({
where: { clubId },
order: [['sortOrder', 'ASC'], ['name', 'ASC'], ['id', 'ASC']],
});
}
async create(userToken, clubId, payload = {}) {
await checkAccess(userToken, clubId);
const name = clean(payload.name);
if (!name) throw new HttpError('Name ist erforderlich', 400);
const maxSortOrder = await ClubVenue.max('sortOrder', { where: { clubId } }) || 0;
return ClubVenue.create({
clubId,
name,
address: cleanNullable(payload.address),
zip: cleanNullable(payload.zip),
city: cleanNullable(payload.city),
sortOrder: Number.isFinite(Number(payload.sortOrder)) ? Number(payload.sortOrder) : maxSortOrder + 1,
});
}
async update(userToken, clubId, venueId, payload = {}) {
await checkAccess(userToken, clubId);
const venue = await ClubVenue.findOne({ where: { id: venueId, clubId } });
if (!venue) throw new HttpError('Spiellokal nicht gefunden', 404);
const updates = {};
if (payload.name !== undefined) {
const name = clean(payload.name);
if (!name) throw new HttpError('Name ist erforderlich', 400);
updates.name = name;
}
if (payload.address !== undefined) updates.address = cleanNullable(payload.address);
if (payload.zip !== undefined) updates.zip = cleanNullable(payload.zip);
if (payload.city !== undefined) updates.city = cleanNullable(payload.city);
if (payload.sortOrder !== undefined && Number.isFinite(Number(payload.sortOrder))) updates.sortOrder = Number(payload.sortOrder);
return venue.update(updates);
}
async delete(userToken, clubId, venueId) {
await checkAccess(userToken, clubId);
const venue = await ClubVenue.findOne({ where: { id: venueId, clubId } });
if (!venue) throw new HttpError('Spiellokal nicht gefunden', 404);
await venue.destroy();
return { success: true };
}
}
export default new ClubVenueService();

View File

@@ -71,6 +71,21 @@ function normalizeIdList(list) {
return result;
}
function isMatchLocked(match) {
const dateText = cleanString(match?.date);
if (!dateText) return false;
const [year, month, day] = dateText.split('-').map((value) => Number.parseInt(value, 10));
if (!year || !month || !day) return false;
const endsAt = new Date(year, month - 1, day, 23, 59, 59, 999);
return endsAt.getTime() <= Date.now();
}
function assertMatchEditable(match) {
if (isMatchLocked(match)) {
throw new HttpError('Der Termin ist verstrichen. Das Freundschaftsspiel ist nur noch sichtbar.', 409);
}
}
function toScheduleRow(match) {
return {
id: match.id,
@@ -100,6 +115,7 @@ function toScheduleRow(match) {
playersReady: normalizeArrayValue(match.playersReady),
playersPlanned: normalizeArrayValue(match.playersPlanned),
playersPlayed: normalizeArrayValue(match.playersPlayed),
isLocked: isMatchLocked(match),
};
}
@@ -153,6 +169,7 @@ class FriendlyMatchService {
await checkAccess(userToken, clubId);
const match = await FriendlyMatch.findOne({ where: { id: matchId, clubId } });
if (!match) throw new HttpError('Freundschaftsspiel nicht gefunden.', 404);
assertMatchEditable(match);
const updates = {};
for (const field of ['date', 'time', 'homeTeamName', 'guestTeamName', 'locationName', 'locationAddress', 'locationCity', 'locationZip', 'matchSystem']) {
@@ -189,6 +206,7 @@ class FriendlyMatchService {
await checkAccess(userToken, clubId);
const match = await FriendlyMatch.findOne({ where: { id: matchId, clubId } });
if (!match) throw new HttpError('Freundschaftsspiel nicht gefunden.', 404);
assertMatchEditable(match);
const ready = normalizeIdList(payload.playersReady);
const planned = normalizeIdList(payload.playersPlanned);

View File

@@ -4,6 +4,7 @@ import FriendlyMatchInvitation from '../models/FriendlyMatchInvitation.js';
import UserClub from '../models/UserClub.js';
import User from '../models/User.js';
import Club from '../models/Club.js';
import Member from '../models/Member.js';
import HttpError from '../exceptions/HttpError.js';
import { checkAccess, getUserByToken } from '../utils/userUtils.js';
import { sendFriendlyMatchInvitationEmail } from './emailService.js';
@@ -66,7 +67,41 @@ function isClubInvolved(clubId, match) {
return Number(match.homeClubId) === id || Number(match.guestClubId) === id;
}
function toSharedScheduleRow(match) {
function isSharedMatchLocked(match) {
const dateText = cleanString(match?.date);
if (!dateText) return false;
const [year, month, day] = dateText.split('-').map((value) => Number.parseInt(value, 10));
if (!year || !month || !day) return false;
const endsAt = new Date(year, month - 1, day, 23, 59, 59, 999);
return endsAt.getTime() <= Date.now();
}
function assertSharedMatchEditable(match) {
if (isSharedMatchLocked(match)) {
throw new HttpError('Der Termin ist verstrichen. Das Freundschaftsspiel ist nur noch sichtbar.', 409);
}
}
function canShowOpponentMembers(match) {
return String(match?.status || '') === 'active' && !isSharedMatchLocked(match);
}
function toSharedScheduleRow(match, viewerClubId = null) {
const hideOpponent = viewerClubId != null && !canShowOpponentMembers(match);
const viewerIsHome = Number(viewerClubId) === Number(match.homeClubId);
const rawHomeParticipants = normalizeArrayValue(match.homeParticipants);
const rawGuestParticipants = normalizeArrayValue(match.guestParticipants);
const rawResultDetails = normalizeArrayValue(match.resultDetails);
const homeParticipants = hideOpponent && !viewerIsHome ? [] : rawHomeParticipants;
const guestParticipants = hideOpponent && viewerIsHome ? [] : rawGuestParticipants;
const resultDetails = hideOpponent
? rawResultDetails.map((row) => ({
...row,
homeName: viewerIsHome ? row?.homeName : '',
guestName: viewerIsHome ? '' : row?.guestName,
}))
: rawResultDetails;
return {
id: match.id,
friendlyMatchId: match.id,
@@ -92,13 +127,14 @@ function toSharedScheduleRow(match) {
singlesCount: match.singlesCount,
doublesCount: match.doublesCount,
winningSets: match.winningSets,
homeParticipants: normalizeArrayValue(match.homeParticipants),
guestParticipants: normalizeArrayValue(match.guestParticipants),
resultDetails: normalizeArrayValue(match.resultDetails),
homeParticipants,
guestParticipants,
resultDetails,
playersReady: normalizeArrayValue(match.playersReady),
playersPlanned: normalizeArrayValue(match.playersPlanned),
playersPlayed: normalizeArrayValue(match.playersPlayed),
status: match.status,
isLocked: isSharedMatchLocked(match),
matchName: match.matchName,
createdFromInvitationId: match.createdFromInvitationId,
};
@@ -143,7 +179,7 @@ class FriendlyMatchSharedService {
const out = matches
.map((match) => {
const row = toSharedScheduleRow(match);
const row = toSharedScheduleRow(match, clubId);
const combined = normalizeTextForSearch([
row.matchName,
row.homeTeam?.name,
@@ -180,7 +216,7 @@ class FriendlyMatchSharedService {
},
order: [['date', 'ASC'], ['startTime', 'ASC'], ['id', 'ASC']],
});
return matches.map(toSharedScheduleRow);
return matches.map((match) => toSharedScheduleRow(match, clubId));
}
async getSharedById(userToken, clubId, matchId) {
@@ -189,7 +225,7 @@ class FriendlyMatchSharedService {
if (!match || !isClubInvolved(clubId, match)) {
throw new HttpError('Gemeinsames Freundschaftsspiel nicht gefunden.', 404);
}
return toSharedScheduleRow(match);
return toSharedScheduleRow(match, clubId);
}
async updateShared(userToken, clubId, matchId, payload = {}) {
@@ -199,8 +235,13 @@ class FriendlyMatchSharedService {
if (!match || !isClubInvolved(clubId, match)) {
throw new HttpError('Gemeinsames Freundschaftsspiel nicht gefunden.', 404);
}
assertSharedMatchEditable(match);
const updates = {};
if (Object.prototype.hasOwnProperty.call(payload, 'time') && !Object.prototype.hasOwnProperty.call(payload, 'startTime')) {
payload.startTime = payload.time;
}
for (const field of ['date', 'startTime', 'matchName', 'homeTeamName', 'guestTeamName', 'locationName', 'locationAddress', 'locationCity', 'locationZip', 'matchSystem', 'status']) {
if (Object.prototype.hasOwnProperty.call(payload, field)) {
updates[field] = ['date', 'homeTeamName', 'guestTeamName', 'matchSystem', 'status'].includes(field)
@@ -229,7 +270,28 @@ class FriendlyMatchSharedService {
}
await match.update(updates);
return toSharedScheduleRow(match);
return toSharedScheduleRow(match, clubId);
}
async membersForSide(userToken, clubId, matchId, side) {
await checkAccess(userToken, clubId);
const match = await FriendlyMatchShared.findByPk(matchId);
if (!match || !isClubInvolved(clubId, match)) {
throw new HttpError('Gemeinsames Freundschaftsspiel nicht gefunden.', 404);
}
const requestedSide = side === 'guest' ? 'guest' : 'home';
const targetClubId = requestedSide === 'guest' ? match.guestClubId : match.homeClubId;
const isOpponent = Number(targetClubId) !== Number(clubId);
if (isOpponent && !canShowOpponentMembers(match)) {
return [];
}
return Member.findAll({
where: { clubId: targetClubId, active: true },
attributes: ['id', 'firstName', 'lastName', 'gender', 'clubId'],
order: [['lastName', 'ASC'], ['firstName', 'ASC']],
});
}
async updateSharedPlayers(userToken, clubId, matchId, payload = {}) {
@@ -239,18 +301,34 @@ class FriendlyMatchSharedService {
if (!match || !isClubInvolved(clubId, match)) {
throw new HttpError('Gemeinsames Freundschaftsspiel nicht gefunden.', 404);
}
assertSharedMatchEditable(match);
const ready = normalizeIdList(payload.playersReady);
const planned = normalizeIdList(payload.playersPlanned);
const played = normalizeIdList(payload.playersPlayed);
const currentClubMembers = await Member.findAll({
where: { clubId },
attributes: ['id'],
});
const currentClubMemberIds = new Set(currentClubMembers.map((member) => Number(member.id)));
const mergeForCurrentClub = (existingValue, nextValue) => {
if (nextValue == null) return normalizeArrayValue(existingValue);
const existing = normalizeArrayValue(existingValue)
.map((id) => Number.parseInt(id, 10))
.filter((id) => Number.isInteger(id));
const preservedOtherClub = existing.filter((id) => !currentClubMemberIds.has(id));
const nextCurrentClub = nextValue.filter((id) => currentClubMemberIds.has(id));
return [...new Set([...preservedOtherClub, ...nextCurrentClub])];
};
await match.update({
playersReady: ready ?? (match.playersReady || []),
playersPlanned: planned ?? (match.playersPlanned || []),
playersPlayed: played ?? (match.playersPlayed || []),
playersReady: mergeForCurrentClub(match.playersReady, ready),
playersPlanned: mergeForCurrentClub(match.playersPlanned, planned),
playersPlayed: mergeForCurrentClub(match.playersPlayed, played),
});
return toSharedScheduleRow(match);
return toSharedScheduleRow(match, clubId);
}
async removeShared(userToken, clubId, matchId) {
@@ -367,7 +445,7 @@ class FriendlyMatchSharedService {
return {
invitation: toInvitationDto(invitation),
sharedMatch: toSharedScheduleRow(shared),
sharedMatch: toSharedScheduleRow(shared, clubId),
};
}

View File

@@ -1,23 +1,24 @@
<template>
<section class="friendly-participant-column">
<h4>{{ title }}</h4>
<div class="friendly-add-row">
<div v-if="!readonly && allowMembers" 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">
<option v-for="member in availableMembers" :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">
<p v-else-if="!readonly && memberHint" class="friendly-participant-hint">{{ memberHint }}</p>
<div v-if="!readonly && allowManual" 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>
<button v-if="!readonly" type="button" @click="$emit('remove', index)">x</button>
</li>
</ul>
</section>
@@ -29,9 +30,21 @@ export default {
props: {
title: { type: String, required: true },
members: { type: Array, required: true },
participants: { type: Array, required: true }
participants: { type: Array, required: true },
allowMembers: { type: Boolean, default: true },
allowManual: { type: Boolean, default: true },
readonly: { type: Boolean, default: false },
memberHint: { type: String, default: '' }
},
emits: ['add-member', 'add-manual', 'remove'],
computed: {
availableMembers() {
const selected = new Set((this.participants || [])
.filter((participant) => participant?.type === 'member')
.map((participant) => Number(participant.memberId)));
return (this.members || []).filter((member) => !selected.has(Number(member.id)));
}
},
data() {
return {
selectedMemberId: '',
@@ -104,4 +117,10 @@ export default {
background: var(--background-soft, #f7f7f7);
border-radius: 6px;
}
.friendly-participant-hint {
margin: 0;
color: var(--text-muted, #666);
font-size: 0.9rem;
}
</style>

View File

@@ -4,24 +4,30 @@
<!-- Tab Navigation -->
<div class="tab-navigation">
<button
<button
:class="['tab-button', { active: activeTab === 'settings' }]"
@click="activeTab = 'settings'"
>
{{ $t('clubSettings.settings') }}
</button>
<button
<button
:class="['tab-button', { active: activeTab === 'training-groups' }]"
@click="activeTab = 'training-groups'"
>
👨👩👧👦 {{ $t('clubSettings.trainingGroups') }}
</button>
<button
<button
:class="['tab-button', { active: activeTab === 'training-times' }]"
@click="activeTab = 'training-times'"
>
🕐 {{ $t('clubSettings.trainingTimes') }}
</button>
<button
:class="['tab-button', { active: activeTab === 'venues' }]"
@click="activeTab = 'venues'"
>
🏟 Spiellokale
</button>
</div>
<!-- Settings Tab -->
@@ -138,6 +144,57 @@
<TrainingTimesTab />
</div>
<!-- End Training Times Tab -->
<!-- Venues Tab -->
<div v-if="activeTab === 'venues'" class="venues-tab">
<p v-if="!currentClub" class="hint hint-warning">Kein Verein ausgewählt.</p>
<p v-else-if="venuesLoading" class="hint">{{ $t('common.loading') }}</p>
<p v-else-if="venuesError" class="hint hint-error">{{ venuesError }}</p>
<section v-if="currentClub" class="card venue-form-card">
<h2>{{ venueForm.id ? 'Spiellokal bearbeiten' : 'Spiellokal anlegen' }}</h2>
<div class="field-grid">
<div class="field-group">
<label>Name</label>
<input v-model="venueForm.name" class="text-input" placeholder="z. B. Sporthalle Harheim" />
</div>
<div class="field-group">
<label>Straße / Adresse</label>
<input v-model="venueForm.address" class="text-input" placeholder="Straße und Hausnummer" />
</div>
<div class="field-group">
<label>PLZ</label>
<input v-model="venueForm.zip" class="text-input" placeholder="60437" />
</div>
<div class="field-group">
<label>Ort</label>
<input v-model="venueForm.city" class="text-input" placeholder="Frankfurt am Main" />
</div>
</div>
<div class="actions">
<button class="btn btn-primary" @click="saveVenue">{{ venueForm.id ? 'Speichern' : 'Hinzufügen' }}</button>
<button v-if="venueForm.id" class="btn btn-secondary" @click="resetVenueForm">Abbrechen</button>
</div>
</section>
<section v-if="currentClub" class="card venues-list-card">
<h2>Spiellokale</h2>
<p v-if="!venues.length" class="hint">Noch keine Spiellokale angelegt.</p>
<div v-else class="venue-list">
<div v-for="venue in venues" :key="venue.id" class="venue-row">
<div>
<strong>{{ venue.name }}</strong>
<div class="venue-address">{{ formatVenueAddress(venue) || 'Keine Adresse hinterlegt' }}</div>
</div>
<div class="venue-actions">
<button class="btn btn-secondary" @click="editVenue(venue)">Bearbeiten</button>
<button class="btn btn-danger" @click="deleteVenue(venue)">Löschen</button>
</div>
</div>
</div>
</section>
</div>
<!-- End Venues Tab -->
</div>
</template>
@@ -193,6 +250,10 @@ export default {
saved: false,
loading: false,
loadError: null,
venues: [],
venuesLoading: false,
venuesError: null,
venueForm: this.emptyVenueForm(),
};
},
computed: {
@@ -204,7 +265,13 @@ export default {
watch: {
currentClub: {
handler(clubId) {
if (clubId) this.loadClubSettings();
if (clubId) {
this.loadClubSettings();
this.loadVenues();
} else {
this.venues = [];
this.resetVenueForm();
}
},
immediate: true,
},
@@ -267,6 +334,73 @@ export default {
return null;
}
},
emptyVenueForm() {
return { id: null, name: '', address: '', zip: '', city: '' };
},
resetVenueForm() {
this.venueForm = this.emptyVenueForm();
},
formatVenueAddress(venue) {
return [venue?.address, [venue?.zip, venue?.city].filter(Boolean).join(' ')].filter(Boolean).join(', ');
},
async loadVenues() {
if (!this.currentClub) return;
this.venuesLoading = true;
this.venuesError = null;
try {
const response = await apiClient.get(`/club-venues/${this.currentClub}`);
this.venues = response.data || [];
} catch (e) {
this.venuesError = 'Spiellokale konnten nicht geladen werden.';
this.venues = [];
} finally {
this.venuesLoading = false;
}
},
editVenue(venue) {
this.venueForm = {
id: venue.id,
name: venue.name || '',
address: venue.address || '',
zip: venue.zip || '',
city: venue.city || '',
};
},
async saveVenue() {
if (!this.currentClub) return;
const payload = {
name: this.venueForm.name,
address: this.venueForm.address,
zip: this.venueForm.zip,
city: this.venueForm.city,
};
if (!payload.name.trim()) {
alert('Bitte einen Namen für das Spiellokal angeben.');
return;
}
try {
if (this.venueForm.id) {
await apiClient.put(`/club-venues/${this.currentClub}/${this.venueForm.id}`, payload);
} else {
await apiClient.post(`/club-venues/${this.currentClub}`, payload);
}
this.resetVenueForm();
await this.loadVenues();
} catch (e) {
alert('Spiellokal konnte nicht gespeichert werden.');
}
},
async deleteVenue(venue) {
if (!this.currentClub || !venue?.id) return;
if (!confirm(`Spiellokal "${venue.name}" wirklich löschen?`)) return;
try {
await apiClient.delete(`/club-venues/${this.currentClub}/${venue.id}`);
if (this.venueForm.id === venue.id) this.resetVenueForm();
await this.loadVenues();
} catch (e) {
alert('Spiellokal konnte nicht gelöscht werden.');
}
},
async save() {
if (!this.currentClub) {
alert(this.$t('clubSettings.noClubSelected'));
@@ -326,10 +460,17 @@ export default {
.actions { display: flex; align-items: center; gap: 10px; margin-top: 10px; }
.btn.btn-primary { background: var(--primary-color); color: #fff; border: none; padding: 8px 12px; border-radius: 6px; cursor: pointer; }
.btn.btn-primary:hover { background: var(--primary-hover); }
.btn.btn-secondary { background: #f8fafc; color: #1f2937; border: 1px solid #cbd5e1; padding: 8px 12px; border-radius: 6px; cursor: pointer; }
.btn.btn-danger { background: #fff5f5; color: #b91c1c; border: 1px solid #fecaca; padding: 8px 12px; border-radius: 6px; cursor: pointer; }
.saved-hint { color: #28a745; font-weight: 600; }
.hint { color: #666; font-size: 12px; margin-top: 8px; }
.hint-warning { color: #856404; background: #fff3cd; padding: 12px; border-radius: 6px; }
.hint-error { color: #721c24; background: #f8d7da; padding: 12px; border-radius: 6px; }
.venue-form-card, .venues-list-card { margin-bottom: 16px; }
.venue-list { display: grid; gap: 10px; }
.venue-row { display: flex; justify-content: space-between; gap: 16px; align-items: center; padding: 12px; border: 1px solid #e5e7eb; border-radius: 6px; background: #f9fafb; }
.venue-address { margin-top: 4px; color: #666; font-size: 13px; }
.venue-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
.tab-navigation {
display: flex;
@@ -363,5 +504,7 @@ export default {
@media (max-width: 720px) {
.field-grid { grid-template-columns: 1fr; }
.venue-row { align-items: stretch; flex-direction: column; }
.venue-actions { justify-content: flex-start; }
}
</style>

View File

@@ -204,7 +204,7 @@
<span class="status-label">
{{ isOurClubPlayingHome(match) ? $t('schedule.homeLabel') || $t('schedule.homeGame') : $t('schedule.awayLabel') || $t('schedule.away') }}
</span>
<button type="button" class="btn-small" @click.stop="toggleHomeAway(match)">
<button type="button" class="btn-small" :disabled="isFriendlyMatchReadOnly(match)" @click.stop="toggleHomeAway(match)">
{{ $t('schedule.swapButton') }}
</button>
</div>
@@ -212,8 +212,8 @@
<td class="pin-cell">
<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>
<button type="button" class="btn-secondary" @click.stop="openFriendlyResultDialog(match)">{{ isFriendlyMatchReadOnly(match) ? 'Ansehen' : 'Ergebnis' }}</button>
<button type="button" class="btn-secondary" @click.stop="openFriendlyMatchDialog(match)">{{ isFriendlyMatchReadOnly(match) ? 'Details' : 'Bearbeiten' }}</button>
</div>
<span v-else-if="match.homePin" class="pin-value clickable"
@click.stop="copyToClipboard(match.homePin, $t('schedule.homePin'), $event)"
@@ -369,6 +369,7 @@
<input
type="checkbox"
:checked="member.isReady"
:disabled="playerSelectionDialog.readonly"
@change="togglePlayerReady(member)"
/>
</td>
@@ -376,6 +377,7 @@
<input
type="checkbox"
:checked="member.isPlanned"
:disabled="playerSelectionDialog.readonly"
@change="togglePlayerPlanned(member)"
/>
</td>
@@ -383,6 +385,7 @@
<input
type="checkbox"
:checked="member.hasPlayed"
:disabled="playerSelectionDialog.readonly"
@change="togglePlayerPlayed(member)"
/>
</td>
@@ -396,7 +399,7 @@
</div>
<div class="dialog-actions">
<button @click="savePlayerSelection" class="btn-save">{{ $t('schedule.save') }}</button>
<button v-if="!playerSelectionDialog.readonly" @click="savePlayerSelection" class="btn-save">{{ $t('schedule.save') }}</button>
<button @click="closePlayerSelectionDialog" class="btn-cancel">{{ $t('schedule.cancel') }}</button>
</div>
</div>
@@ -413,7 +416,7 @@
<div class="score-summary">
<div class="score-display">
<span class="score-label">Spielstand:</span>
<span class="score-value">{{ friendlyResultScore.home }}:{{ friendlyResultScore.guest }}</span>
<span :class="['score-value', friendlyScorePerspectiveClass(friendlyResultScore)]">{{ friendlyResultScore.home }}:{{ friendlyResultScore.guest }}</span>
</div>
</div>
<table class="friendly-result-table">
@@ -428,6 +431,8 @@
<th>Satz 3</th>
<th>Satz 4</th>
<th>Satz 5</th>
<th>Sätze</th>
<th>Punkte</th>
<th>Status</th>
</tr>
</thead>
@@ -435,29 +440,39 @@
<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 class="friendly-result-player">{{ row.homeName || '-' }}</td>
<td class="friendly-result-player">{{ row.guestName || '-' }}</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)"
:disabled="friendlyResultReadonly || isFriendlySetClosed(row, setIndex - 1)"
@blur="normalizeFriendlySet(row, setIndex - 1)"
/>
</td>
<td :class="['friendly-result-score-cell', friendlyScorePerspectiveClass(calculateFriendlyRowSets(row))]">{{ friendlyRowSetScore(row) }}</td>
<td :class="['friendly-result-score-cell', friendlyScorePerspectiveClass(friendlyRowPointScoreObject(row))]">{{ friendlyRowPointScore(row) }}</td>
<td>
<button type="button" class="btn-secondary" @click="row.completed = !row.completed">
<button type="button" class="btn-secondary" :disabled="friendlyResultReadonly" @click="row.completed = !row.completed">
{{ row.completed ? 'Abgeschlossen' : 'Offen' }}
</button>
</td>
</tr>
</tbody>
<tfoot>
<tr class="friendly-result-total-row">
<td colspan="9">Gesamt</td>
<td :class="['friendly-result-score-cell', friendlyScorePerspectiveClass(friendlyResultSetScore)]">{{ friendlyResultSetScore.home }}:{{ friendlyResultSetScore.guest }}</td>
<td :class="['friendly-result-score-cell', friendlyScorePerspectiveClass(friendlyResultScore)]">{{ friendlyResultScore.home }}:{{ friendlyResultScore.guest }}</td>
<td></td>
</tr>
</tfoot>
</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 v-if="!friendlyResultReadonly" @click="saveFriendlyResults(false)" class="btn-save">Speichern</button>
<button v-if="!friendlyResultReadonly" @click="completeFriendlyResults" class="btn-save">Abschließen</button>
<button @click="closeFriendlyResultDialog" class="btn-cancel">{{ $t('schedule.cancel') }}</button>
</div>
</div>
@@ -545,44 +560,94 @@
>
<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>Datum <input v-model="friendlyMatchDialog.form.date" type="date" :disabled="friendlyMatchDialog.readonly" /></label>
<label>Uhrzeit <input v-model="friendlyMatchDialog.form.time" type="time" :disabled="friendlyMatchDialog.readonly" /></label>
<label>Heimteam <input v-model="friendlyMatchDialog.form.homeTeamName" type="text" :disabled="friendlyMatchDialog.readonly" /></label>
<label>Gastteam <input v-model="friendlyMatchDialog.form.guestTeamName" type="text" :disabled="friendlyMatchDialog.readonly" /></label>
<label>Spielsystem
<select v-model="friendlyMatchDialog.form.matchSystem">
<select v-model="friendlyMatchDialog.form.matchSystem" :disabled="friendlyMatchDialog.readonly">
<option>Braunschweiger System</option>
<option>Bundessystem</option>
<option>Werner-Scheffler-System</option>
<option>Sechser-Paarkreuz-System</option>
<option>Europaliga-System</option>
<option>Swaythling-Cup-System</option>
<option>Corbillon-Cup-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>
<label>Gewinnsätze <input v-model.number="friendlyMatchDialog.form.winningSets" type="number" min="1" :disabled="friendlyMatchDialog.readonly" /></label>
<label v-if="canSetFriendlyVenue" class="friendly-venue-field">Spiellokal
<select v-model="friendlyMatchDialog.selectedVenueId" :disabled="friendlyMatchDialog.readonly" @change="applyFriendlyVenue">
<option value="">Kein Spiellokal ausgewählt</option>
<option v-for="venue in friendlyMatchDialog.venues" :key="venue.id" :value="String(venue.id)">{{ venue.name }}</option>
</select>
</label>
<div v-else-if="friendlyVenueSummary" class="friendly-venue-summary">
<strong>Spiellokal</strong>
<span>{{ friendlyVenueSummary }}</span>
</div>
</div>
<div class="friendly-participants">
<FriendlyParticipantsColumn
title="Heim-Aufstellung"
:members="friendlyMatchDialog.members"
:members="friendlyParticipantMemberOptions('homeParticipants')"
:participants="friendlyMatchDialog.form.homeParticipants"
:allow-members="!friendlyMatchDialog.readonly"
:allow-manual="false"
:readonly="friendlyMatchDialog.readonly"
@add-member="addFriendlyParticipant('homeParticipants', $event)"
@add-manual="addManualFriendlyParticipant('homeParticipants', $event)"
@remove="removeFriendlyParticipant('homeParticipants', $event)"
/>
<FriendlyParticipantsColumn
title="Gast-Aufstellung"
:members="friendlyMatchDialog.members"
:members="friendlyParticipantMemberOptions('guestParticipants')"
:participants="friendlyMatchDialog.form.guestParticipants"
:allow-members="friendlyParticipantMemberOptions('guestParticipants').length > 0"
:allow-manual="friendlyParticipantMemberOptions('guestParticipants').length === 0"
:readonly="friendlyMatchDialog.readonly"
:member-hint="friendlyGuestMemberHint"
@add-member="addFriendlyParticipant('guestParticipants', $event)"
@add-manual="addManualFriendlyParticipant('guestParticipants', $event)"
@remove="removeFriendlyParticipant('guestParticipants', $event)"
/>
</div>
<section v-if="friendlyEditDoubleRows().length" class="friendly-doubles-section">
<h4>Doppel</h4>
<div v-for="(row, index) in friendlyEditDoubleRows()" :key="row.id" class="friendly-double-row">
<strong>Doppel {{ index + 1 }}</strong>
<div class="friendly-double-side">
<span>Heim</span>
<select :value="friendlyDoublePart(row.homeName, 0)" :disabled="friendlyMatchDialog.readonly" @change="setFriendlyEditDoublePart(row, 'homeName', 0, $event.target.value)">
<option value="">Spieler 1</option>
<option v-for="name in friendlyEditDoubleOptions('homeParticipants', row, 'homeName', 0)" :key="`ed-h1-${row.id}-${name}`" :value="name">{{ name }}</option>
</select>
<select :value="friendlyDoublePart(row.homeName, 1)" :disabled="friendlyMatchDialog.readonly" @change="setFriendlyEditDoublePart(row, 'homeName', 1, $event.target.value)">
<option value="">Spieler 2</option>
<option v-for="name in friendlyEditDoubleOptions('homeParticipants', row, 'homeName', 1)" :key="`ed-h2-${row.id}-${name}`" :value="name">{{ name }}</option>
</select>
</div>
<div class="friendly-double-side">
<span>Gast</span>
<select :value="friendlyDoublePart(row.guestName, 0)" :disabled="friendlyMatchDialog.readonly" @change="setFriendlyEditDoublePart(row, 'guestName', 0, $event.target.value)">
<option value="">Spieler 1</option>
<option v-for="name in friendlyEditDoubleOptions('guestParticipants', row, 'guestName', 0)" :key="`ed-g1-${row.id}-${name}`" :value="name">{{ name }}</option>
</select>
<select :value="friendlyDoublePart(row.guestName, 1)" :disabled="friendlyMatchDialog.readonly" @change="setFriendlyEditDoublePart(row, 'guestName', 1, $event.target.value)">
<option value="">Spieler 2</option>
<option v-for="name in friendlyEditDoubleOptions('guestParticipants', row, 'guestName', 1)" :key="`ed-g2-${row.id}-${name}`" :value="name">{{ name }}</option>
</select>
</div>
</div>
</section>
<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 v-if="!friendlyMatchDialog.readonly" @click="saveFriendlyMatch" class="btn-save">Speichern</button>
<button v-if="friendlyMatchDialog.editingId && !friendlyMatchDialog.readonly" @click="deleteFriendlyMatch" class="btn-cancel">Löschen</button>
<button @click="closeFriendlyMatchDialog" class="btn-cancel">{{ $t('schedule.cancel') }}</button>
</div>
</div>
@@ -659,6 +724,30 @@ export default {
friendlyResultScore() {
return this.calculateFriendlyResultScore(this.friendlyResultDialog.rows);
},
friendlyResultSetScore() {
return this.calculateFriendlyResultSetScore(this.friendlyResultDialog.rows);
},
friendlyResultReadonly() {
return this.isFriendlyMatchReadOnly(this.friendlyResultDialog.match);
},
friendlyGuestMemberHint() {
const match = this.friendlyMatchDialog.match;
if (!match?.isSharedFriendly) return '';
if (this.isFriendlyMatchReadOnly(match)) return 'Der Termin ist verstrichen. Die Gastmannschaft ist nur sichtbar.';
return 'Gastmitglieder sind erst nach Annahme sichtbar.';
},
canSetFriendlyVenue() {
const match = this.friendlyMatchDialog.match;
if (this.friendlyMatchDialog.readonly) return false;
if (!match?.isSharedFriendly) return true;
return Number(match.homeClubId) === Number(this.currentClub);
},
friendlyVenueSummary() {
const form = this.friendlyMatchDialog.form;
return [form.locationName, [form.locationAddress, [form.locationZip, form.locationCity].filter(Boolean).join(' ')].filter(Boolean).join(', ')]
.filter(Boolean)
.join(' - ');
},
nextScheduledMatchLabel() {
const today = new Date();
today.setHours(0, 0, 0, 0);
@@ -768,7 +857,8 @@ export default {
isOpen: false,
match: null,
members: [],
loading: false
loading: false,
readonly: false
},
locationDialog: {
isOpen: false,
@@ -783,7 +873,13 @@ export default {
friendlyMatchDialog: {
isOpen: false,
editingId: null,
match: null,
members: [],
homeMembers: [],
guestMembers: [],
venues: [],
selectedVenueId: '',
readonly: false,
form: {
date: new Date().toISOString().slice(0, 10),
time: '',
@@ -938,7 +1034,8 @@ export default {
guestMatchPoints: 0,
isCompleted: false,
homeParticipants: [],
guestParticipants: []
guestParticipants: [],
resultDetails: []
};
},
parseFriendlyArray(value) {
@@ -953,6 +1050,54 @@ export default {
}
return [];
},
friendlyMatchEndsAt(match) {
if (!match?.date) return null;
const date = String(match.date).slice(0, 10);
const value = new Date(`${date}T23:59:59`);
return Number.isNaN(value.getTime()) ? null : value;
},
isFriendlyMatchReadOnly(match) {
if (!match?.isFriendly) return false;
if (match.isLocked === true) return true;
const endsAt = this.friendlyMatchEndsAt(match);
return endsAt ? endsAt.getTime() <= Date.now() : false;
},
sortFriendlyMembers(members) {
return (members || []).slice().sort((a, b) => {
const fa = (a.firstName || '').toString().toLowerCase();
const fb = (b.firstName || '').toString().toLowerCase();
if (fa < fb) return -1;
if (fa > fb) return 1;
return (a.lastName || '').toString().localeCompare((b.lastName || '').toString());
});
},
friendlyMemberIdList(value) {
if (Array.isArray(value)) return value.map((id) => Number(id)).filter((id) => Number.isFinite(id));
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed.map((id) => Number(id)).filter((id) => Number.isFinite(id)) : [];
} catch (error) {
return [];
}
}
return [];
},
friendlyEligibleMemberIds(match = this.friendlyMatchDialog.match) {
const ready = new Set(this.friendlyMemberIdList(match?.playersReady));
const planned = new Set(this.friendlyMemberIdList(match?.playersPlanned));
if (!ready.size || !planned.size) return null;
return new Set([...ready].filter((id) => planned.has(id)));
},
friendlyParticipantMemberOptions(field) {
const source = field === 'guestParticipants' ? this.friendlyMatchDialog.guestMembers : this.friendlyMatchDialog.homeMembers;
const eligible = this.friendlyEligibleMemberIds();
if (!eligible) return source;
const selected = new Set((this.friendlyMatchDialog.form[field] || [])
.filter((participant) => participant?.type === 'member')
.map((participant) => Number(participant.memberId)));
return source.filter((member) => eligible.has(Number(member.id)) || selected.has(Number(member.id)));
},
sortMatchesByDateTime(matches) {
if (!Array.isArray(matches)) {
return [];
@@ -1077,6 +1222,7 @@ export default {
async openPlayerSelectionDialog(match) {
this.playerSelectionDialog.match = match;
this.playerSelectionDialog.readonly = this.isFriendlyMatchReadOnly(match);
this.playerSelectionDialog.isOpen = true;
this.playerSelectionDialog.loading = true;
@@ -1098,11 +1244,17 @@ export default {
const playedIds = normalizePlayersList(match.playersPlayed);
const preselectedIds = Array.from(new Set([...readyIds, ...plannedIds, ...playedIds]));
// Fetch members for the current club
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;
let allMembers = [];
if (match.isFriendly && match.isSharedFriendly) {
const ownSide = Number(match.homeClubId) === Number(this.currentClub) ? 'home' : 'guest';
const response = await apiClient.get(`/friendly-matches/shared/${this.currentClub}/${match.id}/members/${ownSide}`);
allMembers = response.data || [];
} else {
const response = match.isFriendly
? await apiClient.get(`/friendly-matches/${this.currentClub}/members/list`)
: await apiClient.get(`/clubmembers/get/${this.currentClub}/true`);
allMembers = response.data || [];
}
const lineupHalf = this.getLineupHalfForMatch(match);
const eligibleMemberIds = match.isFriendly ? [] : await this.getEligibleMemberIdsForSelectedTeam(lineupHalf);
@@ -1214,17 +1366,28 @@ export default {
async savePlayerSelection(closeDialog = true) {
const match = this.playerSelectionDialog.match;
if (!match) return;
if (!match || this.isFriendlyMatchReadOnly(match)) return;
const playersReady = this.playerSelectionDialog.members
.filter(m => m.isReady)
.map(m => m.id);
const playersPlanned = this.playerSelectionDialog.members
.filter(m => m.isPlanned)
.map(m => m.id);
const playersPlayed = this.playerSelectionDialog.members
.filter(m => m.hasPlayed)
.map(m => m.id);
const normalizePlayersList = (value) => {
if (Array.isArray(value)) return value.map((id) => Number(id)).filter((id) => Number.isFinite(id));
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed.map((id) => Number(id)).filter((id) => Number.isFinite(id)) : [];
} catch (error) {
return [];
}
}
return [];
};
const visibleIds = new Set(this.playerSelectionDialog.members.map((m) => Number(m.id)));
const mergeVisibleSelection = (existing, predicate) => [
...normalizePlayersList(existing).filter((id) => !visibleIds.has(Number(id))),
...this.playerSelectionDialog.members.filter(predicate).map((m) => Number(m.id)),
].filter((id, index, arr) => Number.isFinite(id) && arr.indexOf(id) === index);
const playersReady = mergeVisibleSelection(match.playersReady, (m) => m.isReady);
const playersPlanned = mergeVisibleSelection(match.playersPlanned, (m) => m.isPlanned);
const playersPlayed = mergeVisibleSelection(match.playersPlayed, (m) => m.hasPlayed);
console.log('[savePlayerSelection] Saving players:', { playersReady, playersPlanned, playersPlayed, matchId: match.id });
@@ -1386,23 +1549,67 @@ export default {
this.selectedFile = file;
this.importCSV();
},
async loadFriendlyMembers() {
const response = await apiClient.get(`/friendly-matches/${this.currentClub}/members/list`);
const members = response.data || [];
// Sort members alphabetically by firstName then lastName
this.friendlyMatchDialog.members = members.slice().sort((a, b) => {
const fa = (a.firstName || '').toString().toLowerCase();
const fb = (b.firstName || '').toString().toLowerCase();
if (fa < fb) return -1;
if (fa > fb) return 1;
const la = (a.lastName || '').toString().toLowerCase();
const lb = (b.lastName || '').toString().toLowerCase();
return la.localeCompare(lb);
});
async loadFriendlyVenues(match = null) {
this.friendlyMatchDialog.venues = [];
this.friendlyMatchDialog.selectedVenueId = '';
if (!this.currentClub) return;
const canLoad = !match?.isSharedFriendly || Number(match.homeClubId) === Number(this.currentClub);
if (!canLoad) return;
try {
const response = await apiClient.get(`/club-venues/${this.currentClub}`);
this.friendlyMatchDialog.venues = response.data || [];
} catch (error) {
this.friendlyMatchDialog.venues = [];
}
},
findFriendlyVenueForForm() {
const form = this.friendlyMatchDialog.form;
return this.friendlyMatchDialog.venues.find((venue) => (
String(venue.name || '') === String(form.locationName || '')
&& String(venue.address || '') === String(form.locationAddress || '')
&& String(venue.zip || '') === String(form.locationZip || '')
&& String(venue.city || '') === String(form.locationCity || '')
));
},
applyFriendlyVenue() {
const venue = this.friendlyMatchDialog.venues.find((item) => String(item.id) === String(this.friendlyMatchDialog.selectedVenueId));
if (!venue) {
this.friendlyMatchDialog.form.locationName = '';
this.friendlyMatchDialog.form.locationAddress = '';
this.friendlyMatchDialog.form.locationZip = '';
this.friendlyMatchDialog.form.locationCity = '';
return;
}
this.friendlyMatchDialog.form.locationName = venue.name || '';
this.friendlyMatchDialog.form.locationAddress = venue.address || '';
this.friendlyMatchDialog.form.locationZip = venue.zip || '';
this.friendlyMatchDialog.form.locationCity = venue.city || '';
},
async loadFriendlyMembers(match = null) {
const localMembers = async () => {
const response = await apiClient.get(`/friendly-matches/${this.currentClub}/members/list`);
return this.sortFriendlyMembers(response.data || []);
};
if (match?.isSharedFriendly) {
const [homeResponse, guestResponse] = await Promise.all([
apiClient.get(`/friendly-matches/shared/${this.currentClub}/${match.id}/members/home`),
apiClient.get(`/friendly-matches/shared/${this.currentClub}/${match.id}/members/guest`),
]);
this.friendlyMatchDialog.homeMembers = this.sortFriendlyMembers(homeResponse.data || []);
this.friendlyMatchDialog.guestMembers = this.sortFriendlyMembers(guestResponse.data || []);
this.friendlyMatchDialog.members = [...this.friendlyMatchDialog.homeMembers, ...this.friendlyMatchDialog.guestMembers];
return;
}
const members = await localMembers();
this.friendlyMatchDialog.members = members;
this.friendlyMatchDialog.homeMembers = members;
this.friendlyMatchDialog.guestMembers = [];
},
async openFriendlyMatchDialog(match = null) {
await this.loadFriendlyMembers();
await this.loadFriendlyMembers(match);
this.friendlyMatchDialog.match = match?.isFriendly ? match : null;
this.friendlyMatchDialog.editingId = match?.isFriendly ? match.id : null;
this.friendlyMatchDialog.readonly = this.isFriendlyMatchReadOnly(match);
this.friendlyMatchDialog.form = match?.isFriendly
? {
date: match.date ? String(match.date).slice(0, 10) : new Date().toISOString().slice(0, 10),
@@ -1421,14 +1628,22 @@ export default {
guestMatchPoints: match.guestMatchPoints ?? 0,
isCompleted: Boolean(match.isCompleted),
homeParticipants: [...this.parseFriendlyArray(match.homeParticipants)],
guestParticipants: [...this.parseFriendlyArray(match.guestParticipants)]
guestParticipants: [...this.parseFriendlyArray(match.guestParticipants)],
resultDetails: [...this.parseFriendlyArray(match.resultDetails)]
}
: this.emptyFriendlyMatchForm();
await this.loadFriendlyVenues(match);
const selectedVenue = this.findFriendlyVenueForForm();
this.friendlyMatchDialog.selectedVenueId = selectedVenue ? String(selectedVenue.id) : '';
this.friendlyMatchDialog.isOpen = true;
},
closeFriendlyMatchDialog() {
this.friendlyMatchDialog.isOpen = false;
this.friendlyMatchDialog.editingId = null;
this.friendlyMatchDialog.match = null;
this.friendlyMatchDialog.readonly = false;
this.friendlyMatchDialog.venues = [];
this.friendlyMatchDialog.selectedVenueId = '';
this.friendlyMatchDialog.form = this.emptyFriendlyMatchForm();
},
addFriendlyParticipant(field, memberId) {
@@ -1448,7 +1663,7 @@ export default {
friendlyParticipantLabel(participant, fallback = '') {
if (!participant) return fallback;
if (participant.type === 'member') {
const member = this.friendlyMatchDialog.members.find(m => Number(m.id) === Number(participant.memberId));
const member = [...this.friendlyMatchDialog.homeMembers, ...this.friendlyMatchDialog.guestMembers, ...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;
@@ -1473,49 +1688,231 @@ export default {
const second = labels[secondIndex % labels.length];
return first === second ? first : `${first} / ${second}`;
},
friendlyDoublePart(value, index) {
return String(value || '').split('/').map((part) => part.trim())[index] || '';
},
setFriendlyDoublePart(row, field, index, value) {
const parts = [this.friendlyDoublePart(row[field], 0), this.friendlyDoublePart(row[field], 1)];
parts[index] = value;
row[field] = parts.filter(Boolean).join(' / ');
this.autoSaveFriendlyResults();
},
friendlyResultSideLabels(side) {
const participants = side === 'guest'
? this.friendlyResultDialog.match?.guestParticipants
: this.friendlyResultDialog.match?.homeParticipants;
return this.friendlyParticipantLabels(participants);
},
friendlyResultNameOptions(side, row, field, doubleIndex = null) {
const labels = this.friendlyResultSideLabels(side);
const used = new Set();
for (const candidate of this.friendlyResultDialog.rows || []) {
if (candidate === row) continue;
if (candidate.type === 'double') {
this.friendlyDoublePart(candidate[field], 0) && used.add(this.friendlyDoublePart(candidate[field], 0));
this.friendlyDoublePart(candidate[field], 1) && used.add(this.friendlyDoublePart(candidate[field], 1));
} else if (candidate[field]) {
used.add(candidate[field]);
}
}
if (doubleIndex != null) {
const other = this.friendlyDoublePart(row[field], doubleIndex === 0 ? 1 : 0);
if (other) used.add(other);
}
const current = doubleIndex == null ? row[field] : this.friendlyDoublePart(row[field], doubleIndex);
return labels.filter((label) => label === current || !used.has(label));
},
friendlyEditDoubleRows() {
const form = this.friendlyMatchDialog.form;
const template = this.friendlyResultTemplate({
...form,
homeParticipants: form.homeParticipants,
guestParticipants: form.guestParticipants,
});
const doubleIds = template.filter((row) => row.type === 'double').map((row) => row.id);
if (!Array.isArray(form.resultDetails)) form.resultDetails = [];
const existingById = new Map(form.resultDetails
.filter((row) => row?.id)
.map((row) => [String(row.id), row]));
const singles = form.resultDetails.filter((row) => row?.type !== 'double');
const doubles = doubleIds.map((id) => {
const row = existingById.get(id) || { id, type: 'double', homeName: '', guestName: '', sets: ['', '', '', '', ''], completed: false };
row.id = id;
row.type = 'double';
row.sets = Array.from({ length: 5 }, (_, i) => row.sets?.[i] || '');
return row;
});
form.resultDetails = [...doubles, ...singles];
return doubles;
},
friendlyEditDoubleLabels(field) {
return this.friendlyParticipantLabels(this.friendlyMatchDialog.form[field]);
},
setFriendlyEditDoublePart(row, field, index, value) {
const parts = [this.friendlyDoublePart(row[field], 0), this.friendlyDoublePart(row[field], 1)];
parts[index] = value;
row[field] = parts.filter(Boolean).join(' / ');
},
friendlyEditDoubleOptions(participantField, row, nameField, doubleIndex) {
const labels = this.friendlyEditDoubleLabels(participantField);
const rows = this.friendlyEditDoubleRows();
const used = new Set();
for (const candidate of rows) {
if (candidate === row) continue;
this.friendlyDoublePart(candidate[nameField], 0) && used.add(this.friendlyDoublePart(candidate[nameField], 0));
this.friendlyDoublePart(candidate[nameField], 1) && used.add(this.friendlyDoublePart(candidate[nameField], 1));
}
const other = this.friendlyDoublePart(row[nameField], doubleIndex === 0 ? 1 : 0);
if (other) used.add(other);
const current = this.friendlyDoublePart(row[nameField], doubleIndex);
return labels.filter((label) => label === current || !used.has(label));
},
friendlySystemKey(system) {
return String(system || '').trim().toLowerCase();
},
friendlyResultTemplate(match) {
const system = this.friendlySystemKey(match.matchSystem);
const homeCount = this.friendlyParticipantLabels(match.homeParticipants).length;
const guestCount = this.friendlyParticipantLabels(match.guestParticipants).length;
const rows = (entries) => entries.map((entry, index) => ({
id: entry.id || `${entry.type === 'double' ? 'd' : 's'}-${index + 1}`,
type: entry.type,
home: entry.home,
guest: entry.guest,
}));
const d = (id, home, guest) => ({ id, type: 'double', home, guest });
const s = (id, home, guest) => ({ id, type: 'single', home, guest });
if (system.includes('bundessystem')) {
return rows([
d('d-1', 'D1', 'D1'), d('d-2', 'D2', 'D2'),
s('s-1', 'A1', 'B1'), s('s-2', 'A2', 'B2'), s('s-3', 'A3', 'B3'), s('s-4', 'A4', 'B4'),
s('s-5', 'A1', 'B2'), s('s-6', 'A2', 'B1'), s('s-7', 'A3', 'B4'), s('s-8', 'A4', 'B3'),
]);
}
if (system.includes('werner')) {
return rows([
d('d-1', 'D1', 'D1'), d('d-2', 'D2', 'D2'),
s('s-1', 'A1', 'B2'), s('s-2', 'A2', 'B1'), s('s-3', 'A3', 'B4'), s('s-4', 'A4', 'B3'),
s('s-5', 'A1', 'B1'), s('s-6', 'A2', 'B2'), s('s-7', 'A3', 'B3'), s('s-8', 'A4', 'B4'),
]);
}
if (system.includes('sechser')) {
return rows([
d('d-1', 'D1', 'D1'), d('d-2', 'D2', 'D2'), d('d-3', 'D3', 'D3'),
s('s-1', 'A1', 'B2'), s('s-2', 'A2', 'B1'), s('s-3', 'A1', 'B1'), s('s-4', 'A2', 'B2'),
s('s-5', 'A3', 'B4'), s('s-6', 'A4', 'B3'), s('s-7', 'A3', 'B3'), s('s-8', 'A4', 'B4'),
s('s-9', 'A5', 'B6'), s('s-10', 'A6', 'B5'), s('s-11', 'A5', 'B5'), s('s-12', 'A6', 'B6'),
d('d-4', 'D1', 'D1'),
]);
}
if (system.includes('europaliga')) {
return rows([
d('d-1', 'D1', 'D1'), d('d-2', 'D2', 'D2'), d('d-3', 'D3', 'D3'),
s('s-1', 'A1', 'B1'), s('s-2', 'A2', 'B2'), s('s-3', 'A1', 'B2'), s('s-4', 'A2', 'B1'),
s('s-5', 'A3', 'B3'), s('s-6', 'A4', 'B4'), s('s-7', 'A3', 'B4'), s('s-8', 'A4', 'B3'),
s('s-9', 'A5', 'B5'), s('s-10', 'A6', 'B6'), s('s-11', 'A5', 'B6'), s('s-12', 'A6', 'B5'),
]);
}
if (system.includes('corbillon')) {
return rows([
s('s-1', 'A1', 'B1'), s('s-2', 'A2', 'B2'), d('d-1', 'D1', 'D1'), s('s-3', 'A1', 'B2'), s('s-4', 'A2', 'B1'),
]);
}
if (system.includes('modifiziertes swaythling')) {
return rows([
s('s-1', 'A1', 'B2'), s('s-2', 'A2', 'B1'), s('s-3', 'A3', 'B3'), d('d-1', 'D1', 'D1'), s('s-4', 'A1', 'B1'), s('s-5', 'A3', 'B2'), s('s-6', 'A2', 'B3'),
]);
}
if (system.includes('swaythling')) {
return rows([
s('s-1', 'A1', 'B1'), s('s-2', 'A2', 'B2'), s('s-3', 'A3', 'B3'), s('s-4', 'A1', 'B2'), s('s-5', 'A2', 'B1'),
]);
}
if (system.includes('braunschweiger')) {
if (homeCount >= 4 && guestCount >= 4) {
return rows([
d('d-1', 'D1', 'D1'), d('d-2', 'D2', 'D2'),
s('s-1', 'A1', 'B1'), s('s-2', 'A2', 'B2'), s('s-3', 'A3', 'B3'), s('s-4', 'A4', 'B4'),
s('s-5', 'A1', 'B2'), s('s-6', 'A2', 'B1'), s('s-7', 'A3', 'B4'), s('s-8', 'A4', 'B3'),
]);
}
if (homeCount >= 4 && guestCount <= 3) {
return rows([
d('d-1', 'D1', 'D1'),
s('s-1', 'A3', 'B3'), s('s-2', 'A1', 'B2'), s('s-3', 'A2', 'B1'), s('s-4', 'A4', 'B2'),
s('s-5', 'A1', 'B1'), s('s-6', 'A4', 'B3'), s('s-7', 'A2', 'B2'), s('s-8', 'A1', 'B3'), s('s-9', 'A3', 'B1'),
]);
}
if (homeCount <= 3 && guestCount >= 4) {
return rows([
d('d-1', 'D1', 'D1'),
s('s-1', 'A3', 'B3'), s('s-2', 'A2', 'B1'), s('s-3', 'A1', 'B2'), s('s-4', 'A2', 'B4'),
s('s-5', 'A1', 'B1'), s('s-6', 'A3', 'B4'), s('s-7', 'A2', 'B2'), s('s-8', 'A3', 'B1'), s('s-9', 'A1', 'B3'),
]);
}
return rows([
d('d-1', 'D1', 'D1'),
s('s-1', 'A1', 'B2'), s('s-2', 'A2', 'B1'), s('s-3', 'A3', 'B2'), s('s-4', 'A2', 'B3'),
s('s-5', 'A1', 'B1'), s('s-6', 'A3', 'B3'), s('s-7', 'A2', 'B2'), s('s-8', 'A3', 'B1'), s('s-9', 'A1', 'B3'),
]);
}
const doublesCount = Number.parseInt(match.doublesCount, 10) || 0;
const singlesCount = Number.parseInt(match.singlesCount, 10) || 0;
return rows([
...Array.from({ length: doublesCount }, (_, i) => d(`d-${i + 1}`, `D${i + 1}`, `D${i + 1}`)),
...Array.from({ length: singlesCount }, (_, i) => s(`s-${i + 1}`, `A${(i % Math.max(homeCount, 1)) + 1}`, `B${(i % Math.max(guestCount, 1)) + 1}`)),
]);
},
friendlyPlayerForCode(labels, code) {
const match = String(code || '').match(/[AB](\d+)/i);
if (!match) return '';
return labels[Number(match[1]) - 1] || '';
},
friendlyDoubleForCode(match, labels, side, code) {
const number = Number(String(code || '').match(/D[A-Z]?(\d+)/i)?.[1] || 1);
const row = this.parseFriendlyArray(match.resultDetails).find((candidate) => String(candidate?.id) === `d-${number}`);
const value = row?.[side === 'guest' ? 'guestName' : 'homeName'];
return value || this.friendlyDoubleLabel(labels, number - 1);
},
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;
return this.friendlyResultTemplate(match).map((templateRow) => ({
id: templateRow.id,
type: templateRow.type,
homeName: templateRow.type === 'double'
? this.friendlyDoubleForCode(match, homeLabels, 'home', templateRow.home)
: this.friendlyPlayerForCode(homeLabels, templateRow.home),
guestName: templateRow.type === 'double'
? this.friendlyDoubleForCode(match, guestLabels, 'guest', templateRow.guest)
: this.friendlyPlayerForCode(guestLabels, templateRow.guest),
sets: ['', '', '', '', ''],
completed: false
}));
},
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;
if (!existing.length) return generated;
const existingById = new Map(existing
.filter((row) => row?.id)
.map((row) => [String(row.id), row]));
return generated.map((generatedRow, index) => {
const existingRow = existingById.get(String(generatedRow.id)) || existing[index] || null;
if (!existingRow) return generatedRow;
return {
...generatedRow,
homeName: generatedRow.homeName || '',
guestName: generatedRow.guestName || '',
sets: Array.from({ length: 5 }, (_, i) => existingRow.sets?.[i] || generatedRow.sets?.[i] || ''),
completed: Boolean(existingRow.completed),
};
});
},
async openFriendlyResultDialog(match) {
await this.loadFriendlyMembers();
await this.loadFriendlyMembers(match);
this.friendlyResultDialog.match = match;
this.friendlyResultDialog.rows = this.buildFriendlyResultRows(match);
this.friendlyResultDialog.error = '';
@@ -1555,7 +1952,7 @@ export default {
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;
if (Math.max(a, b) < 11 || Math.abs(a - b) < 2) return null;
return `${a}:${b}`;
}
const losing = Math.abs(Number(raw));
@@ -1587,6 +1984,50 @@ export default {
}
return { winner: null, decisiveIndex: null };
},
calculateFriendlyRowSets(row) {
const requiredSets = this.getFriendlyWinningSets();
let homeSets = 0;
let guestSets = 0;
for (const set of row.sets || []) {
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) break;
}
return { home: homeSets, guest: guestSets };
},
friendlyRowSetScore(row) {
const sets = this.calculateFriendlyRowSets(row);
return `${sets.home}:${sets.guest}`;
},
friendlyRowPointScore(row) {
const score = this.friendlyRowPointScoreObject(row);
return `${score.home}:${score.guest}`;
},
friendlyRowPointScoreObject(row) {
const winner = this.calculateFriendlyRowWinner(row);
if (winner === 'home') return { home: 1, guest: 0 };
if (winner === 'guest') return { home: 0, guest: 1 };
return { home: 0, guest: 0 };
},
friendlyResultViewerSide() {
const match = this.friendlyResultDialog.match;
if (match?.isSharedFriendly) {
if (Number(match.homeClubId) === Number(this.currentClub)) return 'home';
if (Number(match.guestClubId) === Number(this.currentClub)) return 'guest';
}
return this.isOurClubPlayingHome(match) ? 'home' : 'guest';
},
friendlyScorePerspectiveClass(score) {
const home = Number(score?.home || 0);
const guest = Number(score?.guest || 0);
if (home === guest) return 'score-even';
const viewerSide = this.friendlyResultViewerSide();
const viewerLeading = viewerSide === 'guest' ? guest > home : home > guest;
return viewerLeading ? 'score-leading' : 'score-trailing';
},
calculateFriendlyRowWinner(row) {
return this.calculateFriendlyRowState(row).winner;
},
@@ -1609,6 +2050,17 @@ export default {
return score;
}, { home: 0, guest: 0 });
},
calculateFriendlyResultSetScore(rows) {
return (rows || []).reduce((score, row) => {
const sets = this.calculateFriendlyRowSets(row);
score.home += sets.home;
score.guest += sets.guest;
return score;
}, { home: 0, guest: 0 });
},
isFriendlyResultComplete(rows = this.friendlyResultDialog.rows) {
return Array.isArray(rows) && rows.length > 0 && rows.every((row) => Boolean(this.calculateFriendlyRowWinner(row)));
},
async autoSaveFriendlyResults() {
if (this.friendlyResultDialog.saving) {
this.friendlyResultDialog.saveAgain = true;
@@ -1622,7 +2074,7 @@ export default {
async saveFriendlyResults(isCompleted = false, options = {}) {
const { closeDialog = true, reloadMatches = true } = options;
const match = this.friendlyResultDialog.match;
if (!match) return;
if (!match || this.isFriendlyMatchReadOnly(match)) return;
for (const row of this.friendlyResultDialog.rows) {
const normalizedSets = [];
for (const set of row.sets) {
@@ -1641,17 +2093,18 @@ export default {
this.applyFriendlyRowCompletion(row);
}
const score = this.calculateFriendlyResultScore(this.friendlyResultDialog.rows);
const completed = Boolean(isCompleted || this.isFriendlyResultComplete(this.friendlyResultDialog.rows));
try {
this.friendlyResultDialog.saving = true;
await apiClient.put(`${match.isSharedFriendly ? `/friendly-matches/shared/${this.currentClub}/${match.id}` : `/friendly-matches/${this.currentClub}/${match.id}`}`, {
homeMatchPoints: score.home,
guestMatchPoints: score.guest,
isCompleted,
isCompleted: completed,
resultDetails: this.friendlyResultDialog.rows
});
match.homeMatchPoints = score.home;
match.guestMatchPoints = score.guest;
match.isCompleted = isCompleted;
match.isCompleted = completed;
match.resultDetails = this.friendlyResultDialog.rows.map((row) => ({ ...row, sets: [...row.sets] }));
if (closeDialog) {
this.closeFriendlyResultDialog();
@@ -1669,6 +2122,8 @@ export default {
await this.saveFriendlyResults(true);
},
async saveFriendlyMatch() {
if (this.friendlyMatchDialog.readonly) return;
this.friendlyEditDoubleRows();
try {
const payload = {
...this.friendlyMatchDialog.form,
@@ -1693,7 +2148,7 @@ export default {
}
},
async toggleHomeAway(match) {
if (!match || !match.id) return;
if (!match || !match.id || this.isFriendlyMatchReadOnly(match)) return;
const originalHome = match.homeTeam ? { ...match.homeTeam } : { name: '' };
const originalGuest = match.guestTeam ? { ...match.guestTeam } : { name: '' };
// Optimistic UI update: swap locally
@@ -1727,7 +2182,7 @@ export default {
}
},
async deleteFriendlyMatch() {
if (!this.friendlyMatchDialog.editingId) return;
if (!this.friendlyMatchDialog.editingId || this.friendlyMatchDialog.readonly) return;
const confirmed = await this.showConfirm('Freundschaftsspiel löschen', 'Soll dieses Freundschaftsspiel gelöscht werden?', '', 'warning');
if (!confirmed) return;
try {
@@ -2474,6 +2929,14 @@ td {
border-radius: 6px;
}
.friendly-venue-summary {
display: flex;
flex-direction: column;
gap: 0.35rem;
color: #374151;
font-size: 0.9rem;
}
.friendly-checkbox {
flex-direction: row !important;
align-items: center;
@@ -2515,6 +2978,34 @@ td {
border-radius: 6px;
}
.friendly-doubles-section {
border: 1px solid var(--border-color, #ddd);
border-radius: 8px;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.friendly-double-row {
display: grid;
gap: 0.5rem;
background: var(--background-soft, #f7f7f7);
border-radius: 6px;
padding: 0.65rem;
}
.friendly-double-side {
display: grid;
grid-template-columns: 4rem 1fr 1fr;
gap: 0.5rem;
align-items: center;
}
.friendly-double-side select {
min-width: 0;
}
.friendly-actions-cell {
display: flex;
gap: 0.5rem;
@@ -2532,7 +3023,8 @@ td {
border-collapse: collapse;
}
.friendly-result-table input {
.friendly-result-table input,
.friendly-result-table select {
width: 100%;
box-sizing: border-box;
padding: 0.35rem 0.45rem;
@@ -2544,11 +3036,37 @@ td {
min-width: 12rem;
}
.friendly-double-select {
display: grid;
gap: 0.25rem;
}
.friendly-result-table .set-input {
width: 4.5rem;
text-align: center;
}
.friendly-result-score-cell,
.score-value {
border-radius: 4px;
padding: 0.2rem 0.45rem;
}
.score-leading {
color: #0f7a3b;
background: #e8f7ee;
}
.score-trailing {
color: #b42318;
background: #fff0ee;
}
.score-even {
color: #175cd3;
background: #eef4ff;
}
.friendly-result-error {
color: #b00020;
font-weight: 600;

View File

@@ -1,3 +1,4 @@
import java.util.Properties
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
val backendBaseUrlForRelease = providers.gradleProperty("backendBaseUrl")
@@ -12,6 +13,22 @@ val socketBaseUrl = providers.gradleProperty("socketBaseUrl")
.orElse("wss://tt-tagebuch.de:3051")
.get()
val signingPropertiesFile = rootProject.file("signing.properties")
val signingProperties = Properties().apply {
if (signingPropertiesFile.isFile) {
signingPropertiesFile.inputStream().use(::load)
}
}
fun signingValue(name: String, envName: String): String? =
signingProperties.getProperty(name)?.takeIf { it.isNotBlank() }
?: System.getenv(envName)?.takeIf { it.isNotBlank() }
val releaseStoreFile = signingValue("storeFile", "TTT_RELEASE_STORE_FILE")
val releaseStorePassword = signingValue("storePassword", "TTT_RELEASE_STORE_PASSWORD")
val releaseKeyAlias = signingValue("keyAlias", "TTT_RELEASE_KEY_ALIAS")
val releaseKeyPassword = signingValue("keyPassword", "TTT_RELEASE_KEY_PASSWORD")
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidApplication)
@@ -70,6 +87,14 @@ android {
buildFeatures {
buildConfig = true
}
signingConfigs {
create("releaseSigning") {
if (releaseStoreFile != null) storeFile = file(releaseStoreFile)
if (releaseStorePassword != null) storePassword = releaseStorePassword
if (releaseKeyAlias != null) keyAlias = releaseKeyAlias
if (releaseKeyPassword != null) keyPassword = releaseKeyPassword
}
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
@@ -77,6 +102,9 @@ android {
}
buildTypes {
getByName("release") {
if (releaseStoreFile != null && releaseStorePassword != null && releaseKeyAlias != null && releaseKeyPassword != null) {
signingConfig = signingConfigs.getByName("releaseSigning")
}
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),

View File

@@ -1596,6 +1596,8 @@ private fun DiaryListScreen(
var newDateGroupMenuExpanded by remember { mutableStateOf(false) }
var selectedNewDateGroupId by remember { mutableStateOf<Int?>(null) }
var quickCreateBusy by remember { mutableStateOf(false) }
var autoSelectedEntryForClubId by rememberSaveable(clubId) { mutableStateOf<Int?>(null) }
var suppressAutoSelect by rememberSaveable(clubId) { mutableStateOf(false) }
val diaryDatesNormKey = remember(diaryState.dates) {
diaryState.dates.map { it.date.take(10).trim() }.sorted().joinToString("|")
@@ -1644,35 +1646,35 @@ private fun DiaryListScreen(
LaunchedEffect(diaryState.dates, selectedEntryId) {
try {
Log.d("DiaryListDebug", "LaunchedEffect: selectedEntryId=$selectedEntryId dates=${diaryState.dates.map { it.id }}")
if (selectedEntryId != null) {
val found = diaryState.dates.any { it.id == selectedEntryId }
Log.d("DiaryListDebug", "selectedEntryId present in dates? $found")
if (found) {
// Force parent/state sync in case order of events produced a stale state.
onSelectedEntryId(selectedEntryId)
}
} else {
} else if (!suppressAutoSelect && autoSelectedEntryForClubId == null) {
// No selection set — if we have dates, default to the first one so details show immediately.
if (diaryState.dates.isNotEmpty()) {
val firstId = diaryState.dates.first().id
Log.d("DiaryListDebug", "No selectedEntryId - defaulting to first date id=$firstId")
autoSelectedEntryForClubId = firstId
onSelectedEntryId(firstId)
}
}
} catch (t: Throwable) {
Log.d("DiaryListDebug", "error in debug effect: ${t.message}")
// ignore
}
}
val selectedEntry = diaryState.dates.firstOrNull { it.id == selectedEntryId }
if (selectedEntry != null) {
Log.d("DiaryListDebug", "selectedEntry found -> id=${selectedEntry.id} date=${selectedEntry.date}")
DiaryDetailScreen(
clubId = clubId,
entry = selectedEntry,
dependencies = dependencies,
onBack = { onSelectedEntryId(null) },
onBack = {
suppressAutoSelect = true
onSelectedEntryId(null)
},
onOpenMemberPortraitCrop = onOpenMemberPortraitCrop,
onOpenMembersGallery = onOpenMembersGallery,
)

View File

@@ -77,7 +77,6 @@ internal fun ScheduleScreen(dependencies: AppDependencies, friendlyOnly: Boolean
val cm = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
cm.setPrimaryClip(ClipData.newPlainText("tt_tagebuch", value))
}
var teamMenu by remember { mutableStateOf(false) }
var otherTeamMenu by remember { mutableStateOf(false) }
var detailMatch by remember { mutableStateOf<ScheduleMatchDto?>(null) }
@@ -85,8 +84,38 @@ internal fun ScheduleScreen(dependencies: AppDependencies, friendlyOnly: Boolean
var friendlyEditMatch by remember { mutableStateOf<ScheduleMatchDto?>(null) }
var showFriendlyCreate by remember { mutableStateOf(false) }
var friendlyResultMatch by remember { mutableStateOf<ScheduleMatchDto?>(null) }
var friendlyHomeMembers by remember { mutableStateOf(emptyList<FriendlyMemberOption>()) }
var friendlyGuestMembers by remember { mutableStateOf(emptyList<FriendlyMemberOption>()) }
var playerError by remember { mutableStateOf<String?>(null) }
var playerSaving by remember { mutableStateOf(false) }
var playerDialogMembers by remember { mutableStateOf(emptyList<de.tsschulz.tt_tagebuch.shared.api.models.Member>()) }
suspend fun loadFriendlyMembersFor(match: ScheduleMatchDto?) {
fun de.tsschulz.tt_tagebuch.shared.api.models.Member.toOption() = FriendlyMemberOption(id, "$firstName $lastName".trim())
if (match?.isSharedFriendly == true) {
friendlyHomeMembers = dependencies.matchesApi.listSharedFriendlyMembers(clubId, match.id, "home").filter { it.active }.map { it.toOption() }.sortedBy { it.name.lowercase() }
friendlyGuestMembers = dependencies.matchesApi.listSharedFriendlyMembers(clubId, match.id, "guest").filter { it.active }.map { it.toOption() }.sortedBy { it.name.lowercase() }
} else {
dependencies.membersManager.loadMembers(clubId)
val options = dependencies.matchesApi.listFriendlyMembers(clubId).filter { it.active }.map { it.toOption() }.sortedBy { it.name.lowercase() }
friendlyHomeMembers = options
friendlyGuestMembers = emptyList()
}
}
suspend fun loadPlayerDialogMembersFor(match: ScheduleMatchDto) {
playerDialogMembers = if (match.isFriendly && match.isSharedFriendly) {
val ownSide = if (match.homeClubId == clubId) "home" else "guest"
dependencies.matchesApi.listSharedFriendlyMembers(clubId, match.id, ownSide)
.filter { it.active }
.sortedBy { "${it.firstName} ${it.lastName}".lowercase() }
} else if (match.isFriendly) {
dependencies.matchesApi.listFriendlyMembers(clubId).filter { it.active }.sortedBy { "${it.firstName} ${it.lastName}".lowercase() }
} else {
dependencies.membersManager.loadMembers(clubId)
dependencies.membersManager.state.value.members.filter { it.active }.sortedBy { "${it.firstName} ${it.lastName}".lowercase() }
}
}
var readyIds by remember { mutableStateOf(emptyList<Int>()) }
var plannedIds by remember { mutableStateOf(emptyList<Int>()) }
@@ -393,13 +422,13 @@ internal fun ScheduleScreen(dependencies: AppDependencies, friendlyOnly: Boolean
friendlyResultMatch = m
detailMatch = null
},
) { Text("Ergebnis") }
) { Text(if (isFriendlyMatchLocked(m)) "Ansehen" else "Ergebnis") }
TextButton(
onClick = {
friendlyEditMatch = m
detailMatch = null
},
) { Text("Bearbeiten") }
) { Text(if (isFriendlyMatchLocked(m)) "Details" else "Bearbeiten") }
} else {
TextButton(
onClick = {
@@ -421,11 +450,16 @@ internal fun ScheduleScreen(dependencies: AppDependencies, friendlyOnly: Boolean
}
if (showFriendlyCreate || friendlyEditMatch != null) {
LaunchedEffect(friendlyEditMatch?.id, showFriendlyCreate) {
loadFriendlyMembersFor(friendlyEditMatch)
}
FriendlyMatchEditDialog(
match = friendlyEditMatch,
clubName = clubState.clubs.find { it.id == clubId }?.name.orEmpty(),
memberOptions = membersState.members.filter { it.active }.map { FriendlyMemberOption(it.id, "${it.firstName} ${it.lastName}".trim()) },
onLoadMembers = { scope.launch { dependencies.membersManager.loadMembers(clubId) } },
homeMemberOptions = friendlyFilteredMembers(friendlyEditMatch, friendlyHomeMembers),
guestMemberOptions = friendlyFilteredMembers(friendlyEditMatch, friendlyGuestMembers),
readonly = isFriendlyMatchLocked(friendlyEditMatch),
onLoadMembers = { scope.launch { loadFriendlyMembersFor(friendlyEditMatch) } },
onDismiss = {
showFriendlyCreate = false
friendlyEditMatch = null
@@ -433,6 +467,7 @@ internal fun ScheduleScreen(dependencies: AppDependencies, friendlyOnly: Boolean
onSave = { body ->
scope.launch {
if (friendlyEditMatch != null) {
if (isFriendlyMatchLocked(friendlyEditMatch)) return@launch
dependencies.scheduleManager.updateFriendlyMatch(clubId, friendlyEditMatch!!.id, body)
} else {
dependencies.scheduleManager.createFriendlyMatch(clubId, body)
@@ -444,6 +479,7 @@ internal fun ScheduleScreen(dependencies: AppDependencies, friendlyOnly: Boolean
onDelete = if (friendlyEditMatch != null) {
{
scope.launch {
if (isFriendlyMatchLocked(friendlyEditMatch)) return@launch
dependencies.scheduleManager.deleteFriendlyMatch(clubId, friendlyEditMatch!!.id)
friendlyEditMatch = null
}
@@ -453,12 +489,15 @@ internal fun ScheduleScreen(dependencies: AppDependencies, friendlyOnly: Boolean
}
friendlyResultMatch?.let { match ->
LaunchedEffect(match.id) { loadFriendlyMembersFor(match) }
FriendlyResultDialog(
match = match,
memberOptions = membersState.members.filter { it.active }.map { FriendlyMemberOption(it.id, "${it.firstName} ${it.lastName}".trim()) },
memberOptions = friendlyHomeMembers + friendlyGuestMembers,
readonly = isFriendlyMatchLocked(match),
onDismiss = { friendlyResultMatch = null },
onSave = { body ->
scope.launch {
if (isFriendlyMatchLocked(match)) return@launch
dependencies.scheduleManager.updateFriendlyMatch(clubId, match.id, body)
val updated = body.toMatchLike(match)
friendlyResultMatch = updated
@@ -466,6 +505,7 @@ internal fun ScheduleScreen(dependencies: AppDependencies, friendlyOnly: Boolean
},
onComplete = { body ->
scope.launch {
if (isFriendlyMatchLocked(match)) return@launch
dependencies.scheduleManager.updateFriendlyMatch(clubId, match.id, body.copy(isCompleted = true))
friendlyResultMatch = null
}
@@ -475,7 +515,7 @@ internal fun ScheduleScreen(dependencies: AppDependencies, friendlyOnly: Boolean
playerMatch?.let { m ->
LaunchedEffect(m.id, clubId) {
dependencies.membersManager.loadMembers(clubId)
loadPlayerDialogMembersFor(m)
}
AlertDialog(
onDismissRequest = { if (!playerSaving) playerMatch = null },
@@ -483,8 +523,8 @@ internal fun ScheduleScreen(dependencies: AppDependencies, friendlyOnly: Boolean
text = {
Column(modifier = Modifier.heightIn(max = 400.dp)) {
playerError?.let { Text(it, color = MaterialTheme.colors.error) }
val memberList = membersState.members.filter { it.active }
if (membersState.isLoading) {
val memberList = playerDialogMembers
if (membersState.isLoading && memberList.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.padding(16.dp))
} else {
val scroll = rememberScrollState()
@@ -528,18 +568,21 @@ internal fun ScheduleScreen(dependencies: AppDependencies, friendlyOnly: Boolean
},
confirmButton = {
TextButton(
enabled = !playerSaving,
enabled = !playerSaving && !isFriendlyMatchLocked(m),
onClick = {
scope.launch {
playerSaving = true
playerError = null
runCatching {
val visibleIds = playerDialogMembers.map { it.id }.toSet()
fun mergeVisible(existing: List<Int>, selected: List<Int>): List<Int> =
(existing.filter { it !in visibleIds } + selected.filter { it in visibleIds }).distinct()
dependencies.scheduleManager.updateMatchPlayersForMatch(
clubId = clubId,
match = m,
ready = readyIds,
planned = plannedIds,
played = playedIds,
ready = mergeVisible(m.playersReady, readyIds),
planned = mergeVisible(m.playersPlanned, plannedIds),
played = mergeVisible(m.playersPlayed, playedIds),
)
playerMatch = null
}.onFailure { playerError = it.message ?: tr("schedule.errorSavingPlayerSelection", "Speichern fehlgeschlagen") }
@@ -563,7 +606,9 @@ private data class FriendlyMemberOption(val id: Int, val name: String)
private fun FriendlyMatchEditDialog(
match: ScheduleMatchDto?,
clubName: String,
memberOptions: List<FriendlyMemberOption>,
homeMemberOptions: List<FriendlyMemberOption>,
guestMemberOptions: List<FriendlyMemberOption>,
readonly: Boolean,
onLoadMembers: () -> Unit,
onDismiss: () -> Unit,
onSave: (FriendlyMatchSaveBody) -> Unit,
@@ -578,28 +623,72 @@ private fun FriendlyMatchEditDialog(
var winningSetsText by remember(match?.id) { mutableStateOf((match?.winningSets ?: 3).toString()) }
var homeParticipants by remember(match?.id) { mutableStateOf(match?.homeParticipants ?: emptyList()) }
var guestParticipants by remember(match?.id) { mutableStateOf(match?.guestParticipants ?: emptyList()) }
var resultRows by remember(match?.id) { mutableStateOf(match?.resultDetails ?: emptyList()) }
var error by remember { mutableStateOf<String?>(null) }
fun doubleRows(): List<FriendlyResultRowDto> {
val template = friendlyResultTemplate(matchSystem, homeParticipants.size, guestParticipants.size, match?.doublesCount ?: 4, match?.singlesCount ?: 12)
val doubleIds = template.filter { it.type == "double" }.map { it.id }
val existingById = resultRows.filter { it.id.isNotBlank() }.associateBy { it.id }
val singles = resultRows.filter { it.type != "double" }
val normalized = doubleIds.map { id ->
val row = existingById[id] ?: FriendlyResultRowDto(id = id, type = "double", sets = List(5) { "" })
row.copy(id = id, type = "double", sets = List(5) { row.sets.getOrNull(it).orEmpty() })
}
resultRows = normalized + singles
return normalized
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(if (match == null) "Freundschaftsspiel anlegen" else "Freundschaftsspiel bearbeiten") },
text = {
Column(Modifier.verticalScroll(rememberScrollState())) {
error?.let { Text(it, color = MaterialTheme.colors.error) }
OutlinedTextField(date, { date = it }, label = { Text("Datum") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(time, { time = it }, label = { Text("Uhrzeit") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(homeTeam, { homeTeam = it }, label = { Text("Heimteam") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(guestTeam, { guestTeam = it }, label = { Text("Gastteam") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(matchSystem, { matchSystem = it }, label = { Text("Spielsystem") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(winningSetsText, { winningSetsText = it.filter(Char::isDigit) }, label = { Text("Gewinnsätze") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(date, { date = it }, label = { Text("Datum") }, enabled = !readonly, modifier = Modifier.fillMaxWidth())
OutlinedTextField(time, { time = it }, label = { Text("Uhrzeit") }, enabled = !readonly, modifier = Modifier.fillMaxWidth())
OutlinedTextField(homeTeam, { homeTeam = it }, label = { Text("Heimteam") }, enabled = !readonly, modifier = Modifier.fillMaxWidth())
OutlinedTextField(guestTeam, { guestTeam = it }, label = { Text("Gastteam") }, enabled = !readonly, modifier = Modifier.fillMaxWidth())
OutlinedTextField(matchSystem, { matchSystem = it }, label = { Text("Spielsystem") }, enabled = !readonly, modifier = Modifier.fillMaxWidth())
OutlinedTextField(winningSetsText, { winningSetsText = it.filter(Char::isDigit) }, label = { Text("Gewinnsätze") }, enabled = !readonly, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.height(8.dp))
FriendlyParticipantEditor("Heim-Aufstellung", memberOptions, homeParticipants) { homeParticipants = it }
FriendlyParticipantEditor("Heim-Aufstellung", homeMemberOptions, homeParticipants, allowMembers = true, allowManual = false, readonly = readonly) { homeParticipants = it }
Spacer(Modifier.height(8.dp))
FriendlyParticipantEditor("Gast-Aufstellung", memberOptions, guestParticipants) { guestParticipants = it }
FriendlyParticipantEditor("Gast-Aufstellung", guestMemberOptions, guestParticipants, allowMembers = guestMemberOptions.isNotEmpty(), allowManual = guestMemberOptions.isEmpty(), readonly = readonly) { guestParticipants = it }
val doubles = doubleRows()
if (doubles.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text("Doppel", fontWeight = FontWeight.SemiBold)
doubles.forEachIndexed { index, row ->
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp), elevation = 1.dp) {
Column(Modifier.padding(8.dp)) {
Text("Doppel ${index + 1}", fontWeight = FontWeight.SemiBold)
FriendlyResultPlayerSelector(
label = "Heim",
row = row,
fieldValue = row.homeName,
sideLabels = friendlyResultSideLabels(homeParticipants, homeMemberOptions),
rows = doubles,
readonly = readonly,
onChange = { value -> resultRows = resultRows.map { if (it.id == row.id) it.copy(homeName = value) else it } },
)
FriendlyResultPlayerSelector(
label = "Gast",
row = row,
fieldValue = row.guestName,
sideLabels = friendlyResultSideLabels(guestParticipants, guestMemberOptions),
rows = doubles,
readonly = readonly,
onChange = { value -> resultRows = resultRows.map { if (it.id == row.id) it.copy(guestName = value) else it } },
)
}
}
}
}
}
},
confirmButton = {
TextButton(
if (!readonly) TextButton(
onClick = {
val winningSets = winningSetsText.toIntOrNull()?.takeIf { it > 0 } ?: 3
if (date.isBlank() || homeTeam.isBlank() || guestTeam.isBlank()) {
@@ -619,7 +708,7 @@ private fun FriendlyMatchEditDialog(
homeMatchPoints = match?.homeMatchPoints ?: 0,
guestMatchPoints = match?.guestMatchPoints ?: 0,
isCompleted = match?.isCompleted ?: false,
resultDetails = match?.resultDetails ?: emptyList(),
resultDetails = resultRows,
),
)
},
@@ -627,7 +716,7 @@ private fun FriendlyMatchEditDialog(
},
dismissButton = {
Row {
onDelete?.let {
if (!readonly) onDelete?.let {
TextButton(onClick = it) { Text("Löschen") }
}
TextButton(onClick = onDismiss) { Text("Abbrechen") }
@@ -641,18 +730,21 @@ private fun FriendlyParticipantEditor(
title: String,
members: List<FriendlyMemberOption>,
participants: List<FriendlyParticipantDto>,
allowMembers: Boolean = true,
allowManual: Boolean = true,
readonly: Boolean = false,
onChange: (List<FriendlyParticipantDto>) -> Unit,
) {
var menuOpen by remember { mutableStateOf(false) }
var manualName by remember { mutableStateOf("") }
Column {
Text(title, fontWeight = FontWeight.SemiBold)
Box(Modifier.fillMaxWidth()) {
if (!readonly && allowMembers) Box(Modifier.fillMaxWidth()) {
OutlinedButton(onClick = { menuOpen = true }, modifier = Modifier.fillMaxWidth()) {
Text("Mitglied hinzufügen")
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
members.forEach { member ->
members.filter { member -> participants.none { it.type == "member" && it.memberId == member.id } }.forEach { member ->
DropdownMenuItem(
onClick = {
menuOpen = false
@@ -664,7 +756,7 @@ private fun FriendlyParticipantEditor(
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
if (!readonly && allowManual) Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
manualName,
{ manualName = it },
@@ -685,7 +777,7 @@ private fun FriendlyParticipantEditor(
participants.forEachIndexed { index, participant ->
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(participantLabel(participant, members), modifier = Modifier.weight(1f))
TextButton(onClick = { onChange(participants.filterIndexed { i, _ -> i != index }) }) { Text("x") }
if (!readonly) TextButton(onClick = { onChange(participants.filterIndexed { i, _ -> i != index }) }) { Text("x") }
}
}
}
@@ -695,6 +787,7 @@ private fun FriendlyParticipantEditor(
private fun FriendlyResultDialog(
match: ScheduleMatchDto,
memberOptions: List<FriendlyMemberOption>,
readonly: Boolean,
onDismiss: () -> Unit,
onSave: (FriendlyMatchSaveBody) -> Unit,
onComplete: (FriendlyMatchSaveBody) -> Unit,
@@ -731,7 +824,8 @@ private fun FriendlyResultDialog(
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp), elevation = 1.dp) {
Column(Modifier.padding(8.dp)) {
Text("${index + 1}. ${if (row.type == "double") "Doppel" else "Einzel"}", fontWeight = FontWeight.SemiBold)
Text("${row.homeName} : ${row.guestName}", style = MaterialTheme.typography.caption)
Text("Heim: ${row.homeName.ifBlank { "-" }}", style = MaterialTheme.typography.caption)
Text("Gast: ${row.guestName.ifBlank { "-" }}", style = MaterialTheme.typography.caption)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
val state = friendlyRowState(row, match.winningSets)
(0 until 5).forEach { setIndex ->
@@ -748,13 +842,13 @@ private fun FriendlyResultDialog(
}
},
label = { Text("${setIndex + 1}") },
enabled = !disabled,
enabled = !readonly && !disabled,
modifier = Modifier.weight(1f),
singleLine = true,
)
}
}
OutlinedButton(
if (!readonly) OutlinedButton(
onClick = {
val normalized = normalizeFriendlyRow(row, match.winningSets)
if (normalized == null) {
@@ -773,11 +867,134 @@ private fun FriendlyResultDialog(
}
}
},
confirmButton = { TextButton(onClick = { onComplete(body(true)) }) { Text("Abschließen") } },
confirmButton = { if (!readonly) TextButton(onClick = { onComplete(body(true)) }) { Text("Abschließen") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Schließen") } },
)
}
@Composable
private fun FriendlyResultPlayerSelector(
label: String,
row: FriendlyResultRowDto,
fieldValue: String,
sideLabels: List<String>,
rows: List<FriendlyResultRowDto>,
readonly: Boolean,
onChange: (String) -> Unit,
) {
if (row.type == "double") {
Column {
FriendlyResultNameMenu(
label = "$label 1",
value = friendlyDoublePart(fieldValue, 0),
options = friendlyResultNameOptions(sideLabels, rows, row, label, fieldValue, 0),
readonly = readonly,
onChange = { value -> onChange(friendlySetDoublePart(fieldValue, 0, value)) },
)
FriendlyResultNameMenu(
label = "$label 2",
value = friendlyDoublePart(fieldValue, 1),
options = friendlyResultNameOptions(sideLabels, rows, row, label, fieldValue, 1),
readonly = readonly,
onChange = { value -> onChange(friendlySetDoublePart(fieldValue, 1, value)) },
)
}
} else {
FriendlyResultNameMenu(
label = label,
value = fieldValue,
options = friendlyResultNameOptions(sideLabels, rows, row, label, fieldValue, null),
readonly = readonly,
onChange = onChange,
)
}
}
@Composable
private fun FriendlyResultNameMenu(
label: String,
value: String,
options: List<String>,
readonly: Boolean,
onChange: (String) -> Unit,
) {
var open by remember { mutableStateOf(false) }
Box(Modifier.fillMaxWidth()) {
OutlinedButton(
enabled = !readonly,
onClick = { open = true },
modifier = Modifier.fillMaxWidth(),
) { Text(value.ifBlank { label }) }
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
DropdownMenuItem(onClick = { open = false; onChange("") }) { Text("-") }
options.forEach { option ->
DropdownMenuItem(onClick = { open = false; onChange(option) }) { Text(option) }
}
}
}
}
private fun friendlyResultSideLabels(participants: List<FriendlyParticipantDto>, members: List<FriendlyMemberOption>): List<String> =
participants.map { participantLabel(it, members) }.filter { it.isNotBlank() }
private fun friendlyDoublePart(value: String, index: Int): String =
value.split("/").map { it.trim() }.getOrNull(index).orEmpty()
private fun friendlySetDoublePart(value: String, index: Int, part: String): String {
val parts = mutableListOf(friendlyDoublePart(value, 0), friendlyDoublePart(value, 1))
parts[index] = part
return parts.filter { it.isNotBlank() }.joinToString(" / ")
}
private fun friendlyResultNameOptions(
labels: List<String>,
rows: List<FriendlyResultRowDto>,
currentRow: FriendlyResultRowDto,
label: String,
currentValue: String,
doubleIndex: Int?,
): List<String> {
val fieldSelector: (FriendlyResultRowDto) -> String = if (label.startsWith("Heim")) { row -> row.homeName } else { row -> row.guestName }
val used = mutableSetOf<String>()
rows.filter { it != currentRow }.forEach { row ->
val value = fieldSelector(row)
if (row.type == "double") {
friendlyDoublePart(value, 0).takeIf { it.isNotBlank() }?.let { used.add(it) }
friendlyDoublePart(value, 1).takeIf { it.isNotBlank() }?.let { used.add(it) }
} else if (value.isNotBlank()) {
used.add(value)
}
}
val current = if (doubleIndex == null) currentValue else friendlyDoublePart(currentValue, doubleIndex)
if (doubleIndex != null) {
friendlyDoublePart(currentValue, if (doubleIndex == 0) 1 else 0).takeIf { it.isNotBlank() }?.let { used.add(it) }
}
return labels.filter { it == current || it !in used }
}
private fun isFriendlyMatchLocked(match: ScheduleMatchDto?): Boolean {
if (match?.isFriendly != true) return false
if (match.isLocked) return true
val date = match.date?.take(10) ?: return false
return runCatching {
val endsAt = java.time.LocalDate.parse(date).atTime(23, 59, 59)
!endsAt.isAfter(java.time.LocalDateTime.now())
}.getOrDefault(false)
}
private fun friendlyEligibleMemberIds(match: ScheduleMatchDto?): Set<Int>? {
fun ids(value: List<Int>) = value.toSet()
val ready = ids(match?.playersReady ?: emptyList())
val planned = ids(match?.playersPlanned ?: emptyList())
if (ready.isEmpty() || planned.isEmpty()) return null
return ready.intersect(planned)
}
private fun friendlyFilteredMembers(match: ScheduleMatchDto?, members: List<FriendlyMemberOption>): List<FriendlyMemberOption> {
val eligible = friendlyEligibleMemberIds(match) ?: return members
return members.filter { it.id in eligible }
}
private fun FriendlyMatchSaveBody.toMatchLike(match: ScheduleMatchDto): ScheduleMatchDto =
match.copy(
homeMatchPoints = homeMatchPoints,
@@ -793,26 +1010,113 @@ private fun participantLabel(participant: FriendlyParticipantDto, members: List<
return listOf(participant.firstName, participant.lastName).filter { it.isNotBlank() }.joinToString(" ")
}
private data class FriendlyResultTemplateRow(
val id: String,
val type: String,
val home: String,
val guest: String,
)
private fun friendlyResultTemplate(
matchSystem: String?,
homeCount: Int,
guestCount: Int,
fallbackDoublesCount: Int,
fallbackSinglesCount: Int,
): List<FriendlyResultTemplateRow> {
val system = matchSystem.orEmpty().trim().lowercase()
fun d(id: String, home: String, guest: String) = FriendlyResultTemplateRow(id, "double", home, guest)
fun s(id: String, home: String, guest: String) = FriendlyResultTemplateRow(id, "single", home, guest)
if ("bundessystem" in system) return listOf(
d("d-1", "D1", "D1"), d("d-2", "D2", "D2"),
s("s-1", "A1", "B1"), s("s-2", "A2", "B2"), s("s-3", "A3", "B3"), s("s-4", "A4", "B4"),
s("s-5", "A1", "B2"), s("s-6", "A2", "B1"), s("s-7", "A3", "B4"), s("s-8", "A4", "B3"),
)
if ("werner" in system) return listOf(
d("d-1", "D1", "D1"), d("d-2", "D2", "D2"),
s("s-1", "A1", "B2"), s("s-2", "A2", "B1"), s("s-3", "A3", "B4"), s("s-4", "A4", "B3"),
s("s-5", "A1", "B1"), s("s-6", "A2", "B2"), s("s-7", "A3", "B3"), s("s-8", "A4", "B4"),
)
if ("sechser" in system) return listOf(
d("d-1", "D1", "D1"), d("d-2", "D2", "D2"), d("d-3", "D3", "D3"),
s("s-1", "A1", "B2"), s("s-2", "A2", "B1"), s("s-3", "A1", "B1"), s("s-4", "A2", "B2"),
s("s-5", "A3", "B4"), s("s-6", "A4", "B3"), s("s-7", "A3", "B3"), s("s-8", "A4", "B4"),
s("s-9", "A5", "B6"), s("s-10", "A6", "B5"), s("s-11", "A5", "B5"), s("s-12", "A6", "B6"),
d("d-4", "D1", "D1"),
)
if ("europaliga" in system) return listOf(
d("d-1", "D1", "D1"), d("d-2", "D2", "D2"), d("d-3", "D3", "D3"),
s("s-1", "A1", "B1"), s("s-2", "A2", "B2"), s("s-3", "A1", "B2"), s("s-4", "A2", "B1"),
s("s-5", "A3", "B3"), s("s-6", "A4", "B4"), s("s-7", "A3", "B4"), s("s-8", "A4", "B3"),
s("s-9", "A5", "B5"), s("s-10", "A6", "B6"), s("s-11", "A5", "B6"), s("s-12", "A6", "B5"),
)
if ("corbillon" in system) return listOf(
s("s-1", "A1", "B1"), s("s-2", "A2", "B2"), d("d-1", "D1", "D1"), s("s-3", "A1", "B2"), s("s-4", "A2", "B1"),
)
if ("modifiziertes swaythling" in system) return listOf(
s("s-1", "A1", "B2"), s("s-2", "A2", "B1"), s("s-3", "A3", "B3"), d("d-1", "D1", "D1"),
s("s-4", "A1", "B1"), s("s-5", "A3", "B2"), s("s-6", "A2", "B3"),
)
if ("swaythling" in system) return listOf(
s("s-1", "A1", "B1"), s("s-2", "A2", "B2"), s("s-3", "A3", "B3"), s("s-4", "A1", "B2"), s("s-5", "A2", "B1"),
)
if ("braunschweiger" in system) {
if (homeCount >= 4 && guestCount >= 4) return listOf(
d("d-1", "D1", "D1"), d("d-2", "D2", "D2"),
s("s-1", "A1", "B1"), s("s-2", "A2", "B2"), s("s-3", "A3", "B3"), s("s-4", "A4", "B4"),
s("s-5", "A1", "B2"), s("s-6", "A2", "B1"), s("s-7", "A3", "B4"), s("s-8", "A4", "B3"),
)
if (homeCount >= 4 && guestCount <= 3) return listOf(
d("d-1", "D1", "D1"),
s("s-1", "A3", "B3"), s("s-2", "A1", "B2"), s("s-3", "A2", "B1"), s("s-4", "A4", "B2"),
s("s-5", "A1", "B1"), s("s-6", "A4", "B3"), s("s-7", "A2", "B2"), s("s-8", "A1", "B3"), s("s-9", "A3", "B1"),
)
if (homeCount <= 3 && guestCount >= 4) return listOf(
d("d-1", "D1", "D1"),
s("s-1", "A3", "B3"), s("s-2", "A2", "B1"), s("s-3", "A1", "B2"), s("s-4", "A2", "B4"),
s("s-5", "A1", "B1"), s("s-6", "A3", "B4"), s("s-7", "A2", "B2"), s("s-8", "A3", "B1"), s("s-9", "A1", "B3"),
)
return listOf(
d("d-1", "D1", "D1"),
s("s-1", "A1", "B2"), s("s-2", "A2", "B1"), s("s-3", "A3", "B2"), s("s-4", "A2", "B3"),
s("s-5", "A1", "B1"), s("s-6", "A3", "B3"), s("s-7", "A2", "B2"), s("s-8", "A3", "B1"), s("s-9", "A1", "B3"),
)
}
val homeSlots = homeCount.coerceAtLeast(1)
val guestSlots = guestCount.coerceAtLeast(1)
return buildList {
repeat(fallbackDoublesCount.coerceAtLeast(0)) { add(d("d-${it + 1}", "D${it + 1}", "D${it + 1}")) }
repeat(fallbackSinglesCount.coerceAtLeast(0)) { add(s("s-${it + 1}", "A${(it % homeSlots) + 1}", "B${(it % guestSlots) + 1}")) }
}
}
private fun buildFriendlyResultRows(match: ScheduleMatchDto, members: List<FriendlyMemberOption>): List<FriendlyResultRowDto> {
val existing = match.resultDetails
val generated = generateFriendlyResultRows(match, members)
if (existing.isNotEmpty()) {
return existing.mapIndexed { index, row ->
row.copy(
homeName = row.homeName.ifBlank { generated.getOrNull(index)?.homeName.orEmpty() },
guestName = row.guestName.ifBlank { generated.getOrNull(index)?.guestName.orEmpty() },
sets = List(5) { row.sets.getOrNull(it).orEmpty() },
if (existing.isEmpty()) return generated
val existingById = existing.filter { it.id.isNotBlank() }.associateBy { it.id }
return generated.mapIndexed { index, generatedRow ->
val existingRow = existingById[generatedRow.id] ?: existing.getOrNull(index)
if (existingRow == null) {
generatedRow
} else {
generatedRow.copy(
sets = List(5) { existingRow.sets.getOrNull(it) ?: generatedRow.sets.getOrNull(it).orEmpty() },
completed = existingRow.completed,
)
}
}
return generated
}
private fun generateFriendlyResultRows(match: ScheduleMatchDto, members: List<FriendlyMemberOption>): List<FriendlyResultRowDto> {
val home = match.homeParticipants.map { participantLabel(it, members) }.filter { it.isNotBlank() }
val guest = match.guestParticipants.map { participantLabel(it, members) }.filter { it.isNotBlank() }
fun single(list: List<String>, index: Int): String = list.getOrNull(index % kotlin.math.max(list.size, 1)).orEmpty()
fun double(list: List<String>, index: Int): String {
fun player(list: List<String>, code: String): String {
val number = Regex("[AB](\\d+)", RegexOption.IGNORE_CASE).find(code)?.groupValues?.getOrNull(1)?.toIntOrNull() ?: return ""
return list.getOrNull(number - 1).orEmpty()
}
fun fallbackDouble(list: List<String>, index: Int): String {
if (list.isEmpty()) return ""
if (list.size == 1) return list.first()
val pairs = listOf(0 to 1, 2 to 3, 0 to 2, 1 to 3)
@@ -821,13 +1125,20 @@ private fun generateFriendlyResultRows(match: ScheduleMatchDto, members: List<Fr
val b = list[pair.second % list.size]
return if (a == b) a else "$a / $b"
}
return buildList {
repeat(match.doublesCount.coerceAtLeast(0)) { i ->
add(FriendlyResultRowDto(id = "d-${i + 1}", type = "double", homeName = double(home, i), guestName = double(guest, i), sets = List(5) { "" }))
}
repeat(match.singlesCount.coerceAtLeast(0)) { i ->
add(FriendlyResultRowDto(id = "s-${i + 1}", type = "single", homeName = single(home, i), guestName = single(guest, i), sets = List(5) { "" }))
}
fun doubleName(list: List<String>, side: String, code: String): String {
val number = Regex("D[A-Z]?(\\d+)", RegexOption.IGNORE_CASE).find(code)?.groupValues?.getOrNull(1)?.toIntOrNull() ?: 1
val existing = match.resultDetails.find { it.id == "d-$number" }
val value = if (side == "guest") existing?.guestName else existing?.homeName
return value?.takeIf { it.isNotBlank() } ?: fallbackDouble(list, number - 1)
}
return friendlyResultTemplate(match.matchSystem, home.size, guest.size, match.doublesCount, match.singlesCount).map { row ->
FriendlyResultRowDto(
id = row.id,
type = row.type,
homeName = if (row.type == "double") doubleName(home, "home", row.home) else player(home, row.home),
guestName = if (row.type == "double") doubleName(guest, "guest", row.guest) else player(guest, row.guest),
sets = List(5) { "" },
)
}
}
@@ -839,7 +1150,7 @@ private fun normalizeFriendlySet(value: String): String? {
if (parts.size != 2) return null
val a = parts[0].toIntOrNull() ?: return null
val b = parts[1].toIntOrNull() ?: return null
if (a < 0 || b < 0 || (a < 11 && b < 11) || kotlin.math.abs(a - b) < 2) return null
if (a < 0 || b < 0 || kotlin.math.max(a, b) < 11 || kotlin.math.abs(a - b) < 2) return null
return "$a:$b"
}
val losing = raw.removePrefix("-").toIntOrNull() ?: return null

View File

@@ -1,7 +1,7 @@
[versions]
# composeApp (Play Store / „Über die App“-Build)
appVersionCode = "21"
appVersionName = "1.7.1"
appVersionCode = "24"
appVersionName = "1.7.4"
agp = "9.2.1"
android-compileSdk = "35"
android-minSdk = "24"

View File

@@ -2,6 +2,7 @@ package de.tsschulz.tt_tagebuch.shared.api
import de.tsschulz.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tsschulz.tt_tagebuch.shared.api.models.LeaguePlayerStatDto
import de.tsschulz.tt_tagebuch.shared.api.models.Member
import de.tsschulz.tt_tagebuch.shared.api.models.LeagueTableRowDto
import de.tsschulz.tt_tagebuch.shared.api.models.FriendlyMatchSaveBody
import de.tsschulz.tt_tagebuch.shared.api.models.FriendlyMatchInvitationCreateBody
@@ -56,6 +57,14 @@ class MatchesApi(
return client.http.get("/api/friendly-matches/shared/$clubId").body()
}
suspend fun listFriendlyMembers(clubId: Int): List<Member> {
return client.http.get("/api/friendly-matches/$clubId/members/list").body()
}
suspend fun listSharedFriendlyMembers(clubId: Int, matchId: Int, side: String): List<Member> {
return client.http.get("/api/friendly-matches/shared/$clubId/$matchId/members/$side").body()
}
suspend fun createFriendlyMatch(clubId: Int, body: FriendlyMatchSaveBody): ScheduleMatchDto {
return client.http.post("/api/friendly-matches/$clubId") {
setBody(body)

View File

@@ -100,6 +100,7 @@ data class ScheduleMatchDto(
val homeMatchPoints: Int = 0,
val guestMatchPoints: Int = 0,
val isCompleted: Boolean = false,
val isLocked: Boolean = false,
val pdfUrl: String? = null,
val matchSystem: String? = null,
val singlesCount: Int = 12,