Freundschaftsspiele korrigiert
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 52s
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 52s
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,3 +17,4 @@ mobile-app/build/
|
||||
mobile-app/composeApp/build/
|
||||
mobile-app/shared/build/
|
||||
mobile-app/local.properties
|
||||
mobile-app/signing.properties
|
||||
|
||||
52
backend/controllers/clubVenueController.js
Normal file
52
backend/controllers/clubVenueController.js
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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(
|
||||
|
||||
15
backend/migrations/20260605_create_club_venue.sql
Normal file
15
backend/migrations/20260605_create_club_venue.sql
Normal 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
|
||||
);
|
||||
54
backend/models/ClubVenue.js
Normal file
54
backend/models/ClubVenue.js
Normal 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;
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
18
backend/routes/clubVenueRoutes.js
Normal file
18
backend/routes/clubVenueRoutes.js
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
58
backend/services/clubVenueService.js
Normal file
58
backend/services/clubVenueService.js
Normal 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();
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Binary file not shown.
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user