diff --git a/.gitignore b/.gitignore index 7cfc38a0..b83582fa 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ mobile-app/build/ mobile-app/composeApp/build/ mobile-app/shared/build/ mobile-app/local.properties +mobile-app/signing.properties diff --git a/backend/controllers/clubVenueController.js b/backend/controllers/clubVenueController.js new file mode 100644 index 00000000..74f146c5 --- /dev/null +++ b/backend/controllers/clubVenueController.js @@ -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); + } +}; diff --git a/backend/controllers/friendlyMatchSharedController.js b/backend/controllers/friendlyMatchSharedController.js index 205e4cfa..64d70328 100644 --- a/backend/controllers/friendlyMatchSharedController.js +++ b/backend/controllers/friendlyMatchSharedController.js @@ -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( diff --git a/backend/migrations/20260605_create_club_venue.sql b/backend/migrations/20260605_create_club_venue.sql new file mode 100644 index 00000000..1db77776 --- /dev/null +++ b/backend/migrations/20260605_create_club_venue.sql @@ -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 +); diff --git a/backend/models/ClubVenue.js b/backend/models/ClubVenue.js new file mode 100644 index 00000000..2234cfb3 --- /dev/null +++ b/backend/models/ClubVenue.js @@ -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; diff --git a/backend/models/index.js b/backend/models/index.js index 4ce0deda..94abf307 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -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, }; diff --git a/backend/routes/clubVenueRoutes.js b/backend/routes/clubVenueRoutes.js new file mode 100644 index 00000000..b12a92dd --- /dev/null +++ b/backend/routes/clubVenueRoutes.js @@ -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; diff --git a/backend/routes/friendlyMatchSharedRoutes.js b/backend/routes/friendlyMatchSharedRoutes.js index 51556334..033c5d5d 100644 --- a/backend/routes/friendlyMatchSharedRoutes.js +++ b/backend/routes/friendlyMatchSharedRoutes.js @@ -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); diff --git a/backend/server.js b/backend/server.js index ca9e040f..c23f52af 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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); diff --git a/backend/services/clubVenueService.js b/backend/services/clubVenueService.js new file mode 100644 index 00000000..22c33e1d --- /dev/null +++ b/backend/services/clubVenueService.js @@ -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(); diff --git a/backend/services/friendlyMatchService.js b/backend/services/friendlyMatchService.js index c7839cbc..5d2302fb 100644 --- a/backend/services/friendlyMatchService.js +++ b/backend/services/friendlyMatchService.js @@ -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); diff --git a/backend/services/friendlyMatchSharedService.js b/backend/services/friendlyMatchSharedService.js index 396a8877..99951191 100644 --- a/backend/services/friendlyMatchSharedService.js +++ b/backend/services/friendlyMatchSharedService.js @@ -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), }; } diff --git a/frontend/src/components/schedule/FriendlyParticipantsColumn.vue b/frontend/src/components/schedule/FriendlyParticipantsColumn.vue index b377859b..c08bda7a 100644 --- a/frontend/src/components/schedule/FriendlyParticipantsColumn.vue +++ b/frontend/src/components/schedule/FriendlyParticipantsColumn.vue @@ -1,23 +1,24 @@ @@ -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; } } diff --git a/frontend/src/views/ScheduleView.vue b/frontend/src/views/ScheduleView.vue index df49d36b..c1915813 100644 --- a/frontend/src/views/ScheduleView.vue +++ b/frontend/src/views/ScheduleView.vue @@ -204,7 +204,7 @@ {{ isOurClubPlayingHome(match) ? $t('schedule.homeLabel') || $t('schedule.homeGame') : $t('schedule.awayLabel') || $t('schedule.away') }} - @@ -212,8 +212,8 @@
- - + +
@@ -376,6 +377,7 @@ @@ -383,6 +385,7 @@ @@ -396,7 +399,7 @@
- +
@@ -413,7 +416,7 @@
Spielstand: - {{ friendlyResultScore.home }}:{{ friendlyResultScore.guest }} + {{ friendlyResultScore.home }}:{{ friendlyResultScore.guest }}
@@ -428,6 +431,8 @@ + + @@ -435,29 +440,39 @@ - - + + + + + + + + + + + +
Satz 3 Satz 4 Satz 5SätzePunkte Status
{{ index + 1 }} {{ row.type === 'double' ? 'Doppel' : 'Einzel' }}{{ row.homeName || '-' }}{{ row.guestName || '-' }} {{ friendlyRowSetScore(row) }}{{ friendlyRowPointScore(row) }} -
Gesamt{{ friendlyResultSetScore.home }}:{{ friendlyResultSetScore.guest }}{{ friendlyResultScore.home }}:{{ friendlyResultScore.guest }}
{{ friendlyResultDialog.error }}
- - + +
@@ -545,44 +560,94 @@ >
- - - - + + + + - + + +
+ Spiellokal + {{ friendlyVenueSummary }} +
+
+

Doppel

+
+ Doppel {{ index + 1 }} +
+ Heim + + +
+
+ Gast + + +
+
+
+
- - + +
@@ -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; diff --git a/mobile-app/composeApp/build.gradle.kts b/mobile-app/composeApp/build.gradle.kts index 5ea77641..a873dcf7 100644 --- a/mobile-app/composeApp/build.gradle.kts +++ b/mobile-app/composeApp/build.gradle.kts @@ -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"), diff --git a/mobile-app/composeApp/release/composeApp-release.aab b/mobile-app/composeApp/release/composeApp-release.aab index f68169c2..754e8850 100644 Binary files a/mobile-app/composeApp/release/composeApp-release.aab and b/mobile-app/composeApp/release/composeApp-release.aab differ diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt index ba3893a4..be147b81 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt @@ -1596,6 +1596,8 @@ private fun DiaryListScreen( var newDateGroupMenuExpanded by remember { mutableStateOf(false) } var selectedNewDateGroupId by remember { mutableStateOf(null) } var quickCreateBusy by remember { mutableStateOf(false) } + var autoSelectedEntryForClubId by rememberSaveable(clubId) { mutableStateOf(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, ) diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/ScheduleScreen.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/ScheduleScreen.kt index 88d50cc4..6a1efeb3 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/ScheduleScreen.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/ScheduleScreen.kt @@ -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(null) } @@ -85,8 +84,38 @@ internal fun ScheduleScreen(dependencies: AppDependencies, friendlyOnly: Boolean var friendlyEditMatch by remember { mutableStateOf(null) } var showFriendlyCreate by remember { mutableStateOf(false) } var friendlyResultMatch by remember { mutableStateOf(null) } + var friendlyHomeMembers by remember { mutableStateOf(emptyList()) } + var friendlyGuestMembers by remember { mutableStateOf(emptyList()) } var playerError by remember { mutableStateOf(null) } var playerSaving by remember { mutableStateOf(false) } + var playerDialogMembers by remember { mutableStateOf(emptyList()) } + + 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()) } var plannedIds by remember { mutableStateOf(emptyList()) } @@ -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, selected: List): List = + (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, + homeMemberOptions: List, + guestMemberOptions: List, + 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(null) } + fun doubleRows(): List { + 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, participants: List, + allowMembers: Boolean = true, + allowManual: Boolean = true, + readonly: Boolean = false, onChange: (List) -> 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, + 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, + rows: List, + 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, + 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, members: List): List = + 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, + rows: List, + currentRow: FriendlyResultRowDto, + label: String, + currentValue: String, + doubleIndex: Int?, +): List { + val fieldSelector: (FriendlyResultRowDto) -> String = if (label.startsWith("Heim")) { row -> row.homeName } else { row -> row.guestName } + val used = mutableSetOf() + 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? { + fun ids(value: List) = 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): List { + 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 { + 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): List { 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): List { 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, index: Int): String = list.getOrNull(index % kotlin.math.max(list.size, 1)).orEmpty() - fun double(list: List, index: Int): String { + fun player(list: List, 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, 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 - 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, 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 diff --git a/mobile-app/gradle/libs.versions.toml b/mobile-app/gradle/libs.versions.toml index 9000c399..e9561339 100644 --- a/mobile-app/gradle/libs.versions.toml +++ b/mobile-app/gradle/libs.versions.toml @@ -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" diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/MatchesApi.kt b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/MatchesApi.kt index a6d30181..b994a049 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/MatchesApi.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/MatchesApi.kt @@ -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 { + return client.http.get("/api/friendly-matches/$clubId/members/list").body() + } + + suspend fun listSharedFriendlyMembers(clubId: Int, matchId: Int, side: String): List { + 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) diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/models/Schedule.kt b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/models/Schedule.kt index 0c2858b5..c87e87be 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/models/Schedule.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/models/Schedule.kt @@ -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,