From 6c634399d74ea4abded5dbef7dc1063d5d3017ea Mon Sep 17 00:00:00 2001 From: Razmig Sarkissian Date: Tue, 14 Oct 2025 09:08:05 +0200 Subject: [PATCH 01/12] fix stuff headmanager --- PadelClub/Views/Round/RoundSettingsView.swift | 9 +- .../Screen/Components/HeadManagerView.swift | 51 +++- .../Screen/TableStructureView.swift | 236 ++++++++++-------- 3 files changed, 166 insertions(+), 130 deletions(-) diff --git a/PadelClub/Views/Round/RoundSettingsView.swift b/PadelClub/Views/Round/RoundSettingsView.swift index d742100..940a091 100644 --- a/PadelClub/Views/Round/RoundSettingsView.swift +++ b/PadelClub/Views/Round/RoundSettingsView.swift @@ -160,14 +160,7 @@ struct RoundSettingsView: View { } private func _removeRound(_ lastRound: Round) async { - await MainActor.run { - let teams = lastRound.seeds() - teams.forEach { team in - team.resetBracketPosition() - } - tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: teams) - tournamentStore?.rounds.delete(instance: lastRound) - } + await tournament.removeRound(lastRound) } } diff --git a/PadelClub/Views/Tournament/Screen/Components/HeadManagerView.swift b/PadelClub/Views/Tournament/Screen/Components/HeadManagerView.swift index d53e10b..3fa4ed0 100644 --- a/PadelClub/Views/Tournament/Screen/Components/HeadManagerView.swift +++ b/PadelClub/Views/Tournament/Screen/Components/HeadManagerView.swift @@ -79,7 +79,7 @@ struct HeadManagerView: View { valueToAppend = theory } else { let lastValue = teamsPerRound.last ?? 0 - var newValueToAppend = theory + var newValueToAppend = theory == 0 ? maxAssignable / 2 : theory if theory > maxAssignable || theory < 0 { newValueToAppend = valueToAppend / 2 } @@ -102,33 +102,62 @@ struct HeadManagerView: View { Picker(selection: $selectedSeedRound) { Text("Choisir").tag(nil as Int?) ForEach(seedRepartition.indices, id: \.self) { idx in - Text(RoundRule.roundName(fromRoundIndex: idx)).tag(idx) + Text(RoundRule.roundName(fromRoundIndex: idx, displayStyle: .short)).tag(idx) } } label: { - Text("Tour contenant la meilleure tête de série") + Text("Tour de la tête de série n°1") } .onChange(of: selectedSeedRound) { seedRepartition = Self.place(heads: heads, teamsInBracket: teamsInBracket, initialSeedRound: selectedSeedRound) } - } footer: { - FooterButtonView("remise à zéro") { - selectedSeedRound = nil - } } Section { LabeledContent { - Text(teamsInBracket.formatted()) + Text(heads.formatted()) } label: { - Text("Effectif du tableau") + Text("Équipes à placer en tableau") } + if (teamsInBracket - heads) > 0 { + LabeledContent { + Text((teamsInBracket - heads).formatted()) + } label: { + Text("Qualifiés entrants") + } + } LabeledContent { Text(leftToPlace.formatted()) } label: { Text("Restant à placer") } + + LabeledContent { + let matchCount = seedRepartition.enumerated().map { (index, value) in + var result = 0 + var count = value + if count == 0, let selectedSeedRound, index < selectedSeedRound { + let t = RoundRule.numberOfMatches(forRoundIndex: index) + result = RoundRule.cumulatedNumberOfMatches(forTeams: t * 2) + } else { + if index == seedRepartition.count - 1 { + count += (teamsInBracket - heads) + } else if index == seedRepartition.count - 2 { + count += ((seedRepartition[index + 1] + (teamsInBracket - heads)) / 2) + } else { + count += (seedRepartition[index + 1]) + } + result = RoundRule.cumulatedNumberOfMatches(forTeams: count) + } + + return result + } + .reduce(0, +) + Text(matchCount.formatted()) + } label: { + Text("Matchs estimés") + } } Section { @@ -155,13 +184,13 @@ struct HeadManagerView: View { if headsLeft - maxAssignable > 0 { valueToAppend = valueToAppend - (headsLeft - maxAssignable) } - print("Appending to seedRepartition: headsLeft=\(headsLeft), maxAssignable=\(maxAssignable), valueToAppend=\(valueToAppend), current seedRepartition=\(seedRepartition)") +// print("Appending to seedRepartition: headsLeft=\(headsLeft), maxAssignable=\(maxAssignable), valueToAppend=\(valueToAppend), current seedRepartition=\(seedRepartition)") seedRepartition.append(valueToAppend) } } } } - .navigationTitle("Têtes de série") + .navigationTitle("Répartition") .toolbar { ToolbarItem(placement: .topBarTrailing) { ButtonValidateView(title: "Valider") { diff --git a/PadelClub/Views/Tournament/Screen/TableStructureView.swift b/PadelClub/Views/Tournament/Screen/TableStructureView.swift index 21f0542..f6d10ee 100644 --- a/PadelClub/Views/Tournament/Screen/TableStructureView.swift +++ b/PadelClub/Views/Tournament/Screen/TableStructureView.swift @@ -267,13 +267,13 @@ struct TableStructureView: View { } } - if groupStageCount > 0 { - LabeledContent { - Text(tf.formatted()) - } label: { - Text("Effectif") - } - } +// if groupStageCount > 0 { +// LabeledContent { +// Text(tf.formatted()) +// } label: { +// Text("Effectif tableau") +// } +// } } else { LabeledContent { let mp1 = teamsPerGroupStage * (teamsPerGroupStage - 1) / 2 * groupStageCount @@ -283,16 +283,30 @@ struct TableStructureView: View { Text("Total de matchs") } } - } footer: { - if tsPure > 0 && structurePreset != .doubleGroupStage, groupStageCount > 0 { - if tsPure > teamCount / 2 { - Text("Le nombre de têtes de série ne devrait pas être supérieur à la moitié de l'effectif.").foregroundStyle(.red) - } else if tsPure < teamCount / 8 { - Text("À partir du moment où vous avez des têtes de série, leur nombre ne devrait pas être inférieur à 1/8ème de l'effectif.").foregroundStyle(.red) - } else if tsPure < qualifiedFromGroupStage + groupStageAdditionalQualified { - Text("Le nombre de têtes de série ne devrait pas être inférieur au nombre de paires qualifiées sortantes.").foregroundStyle(.red) + + LabeledContent { + FooterButtonView("configurer") { + showSeedRepartition = true + } + } label: { + if tournament.state() == .build { + Text("Répartition des équipes") + } else if selectedTournament != nil { + Text("La configuration du tournoi séléctionné sera utilisée.") + } else { + Text(_seeds()) } } + .onAppear { + if seedRepartition.isEmpty && tournament.state() == .initial && selectedTournament == nil { + seedRepartition = HeadManagerView.place(heads: tsPure, teamsInBracket: tf, initialSeedRound: nil) + } + } + + } footer: { + if tsPure > 0 && structurePreset != .doubleGroupStage, groupStageCount > 0, tsPure < qualifiedFromGroupStage + groupStageAdditionalQualified { + Text("Le nombre de têtes de série ne devrait pas être inférieur au nombre de paires qualifiées sortantes.").foregroundStyle(.red) + } } if structurePreset.hasWildcards() && tournament.level.wildcardArePossible() { @@ -303,27 +317,6 @@ struct TableStructureView: View { } } - if tournament.state() != .build { - Section { - LabeledContent { - Image(systemName: seedRepartition.isEmpty ? "xmark" : "checkmark") - } label: { - FooterButtonView("Configuration du tableau") { - showSeedRepartition = true - } - .disabled(selectedTournament != nil) - } - } footer: { - if seedRepartition.isEmpty { - Text("Aucune répartition n'a été indiqué, vous devrez réserver ou placer les têtes de séries dans le tableau manuellement.") - } else { - FooterButtonView("Supprimer la configuration", role: .destructive) { - seedRepartition = [] - } - } - } - } - if tournament.rounds().isEmpty && tournament.state() == .build { Section { RowButtonView("Ajouter un tableau", role: .destructive) { @@ -336,6 +329,12 @@ struct TableStructureView: View { if tournament.state() != .initial { + if seedRepartition.isEmpty == false { + RowButtonView("Modifier la répartition des équipes en tableau", role: .destructive, confirmationMessage: "Cette action va effacer le répartition actuelle des équipes dans le tableau.") { + await _handleSeedRepartition() + } + } + Section { RowButtonView("Sauver sans reconstuire l'existant") { _saveWithoutRebuild() @@ -370,13 +369,6 @@ struct TableStructureView: View { } } .toolbarBackground(.visible, for: .navigationBar) - .onChange(of: teamCount) { - if teamCount != tournament.teamCount { - updatedElements.insert(.teamCount) - } else { - updatedElements.remove(.teamCount) - } - } .sheet(isPresented: $showSeedRepartition, content: { NavigationStack { HeadManagerView(teamsInBracket: tf, heads: tsPure, initialSeedRepartition: seedRepartition) { seedRepartition in @@ -384,6 +376,14 @@ struct TableStructureView: View { } } }) + .onChange(of: teamCount) { + if teamCount != tournament.teamCount { + updatedElements.insert(.teamCount) + } else { + updatedElements.remove(.teamCount) + } + _verifyValueIntegrity() + } .onChange(of: groupStageCount) { if groupStageCount != tournament.groupStageCount { updatedElements.insert(.groupStageCount) @@ -394,25 +394,31 @@ struct TableStructureView: View { if structurePreset.isFederalPreset(), groupStageCount == 0 { teamCount = structurePreset.tableDimension() } + _verifyValueIntegrity() } .onChange(of: teamsPerGroupStage) { if teamsPerGroupStage != tournament.teamsPerGroupStage { updatedElements.insert(.teamsPerGroupStage) } else { updatedElements.remove(.teamsPerGroupStage) - } } + } + _verifyValueIntegrity() + } .onChange(of: qualifiedPerGroupStage) { if qualifiedPerGroupStage != tournament.qualifiedPerGroupStage { updatedElements.insert(.qualifiedPerGroupStage) } else { updatedElements.remove(.qualifiedPerGroupStage) - } } + } + _verifyValueIntegrity() + } .onChange(of: groupStageAdditionalQualified) { if groupStageAdditionalQualified != tournament.groupStageAdditionalQualified { updatedElements.insert(.groupStageAdditionalQualified) } else { updatedElements.remove(.groupStageAdditionalQualified) } + _verifyValueIntegrity() } .toolbar { if tournament.state() != .initial { @@ -484,6 +490,19 @@ struct TableStructureView: View { } } + + private func _seeds() -> String { + if seedRepartition.isEmpty || seedRepartition.reduce(0, +) == 0 { + return "Aucune configuration" + } + return seedRepartition.enumerated().compactMap { (index, count) in + if count > 0 { + return RoundRule.roundName(fromRoundIndex: index) + " : \(count)" + } else { + return nil + } + }.joined(separator: ", ") + } private func _reset() { tournament.removeWildCards() @@ -573,12 +592,6 @@ struct TableStructureView: View { } tournament.deleteAndBuildEverything(preset: structurePreset) - if seedRepartition.count > 0 { - while tournament.rounds().count < seedRepartition.count { - await tournament.addNewRound(tournament.rounds().count) - } - } - if let selectedTournament { let oldTournamentStart = selectedTournament.startDate let newTournamentStart = tournament.startDate @@ -614,66 +627,10 @@ struct TableStructureView: View { } tournament.tournamentStore?.matches.addOrUpdate(contentOfs: tournament._allMatchesIncludingDisabled()) - } else { - for (index, seedCount) in seedRepartition.enumerated() { - if let round = tournament.rounds().first(where: { $0.index == index }) { - let baseIndex = RoundRule.baseIndex(forRoundIndex: round.index) - let numberOfMatches = RoundRule.numberOfMatches(forRoundIndex: round.index) - let playedMatches = round.playedMatches().map { $0.index - baseIndex } - let allMatches = round._matches() - let seedSorted = frenchUmpireOrder(for: numberOfMatches).filter({ index in - playedMatches.contains(index) - }).prefix(seedCount) - for (index, value) in seedSorted.enumerated() { - let isOpponentTurn = index >= playedMatches.count - if let match = allMatches[safe:value] { - if match.index - baseIndex < numberOfMatches / 2 { - if isOpponentTurn { - match.previousMatch(.two)?.disableMatch() - } else { - match.previousMatch(.one)?.disableMatch() - } - } else { - if isOpponentTurn { - match.previousMatch(.one)?.disableMatch() - } else { - match.previousMatch(.two)?.disableMatch() - } - } - } - } - - if seedCount > 0 { - tournament.tournamentStore?.matches.addOrUpdate(contentOfs: round._matches()) - tournament.tournamentStore?.matches.addOrUpdate(contentOfs: round.allLoserRoundMatches()) - round.deleteLoserBracket() - round.buildLoserBracket() - round.loserRounds().forEach { loserRound in - loserRound.disableUnplayedLoserBracketMatches() - } - } - } - - } -// if initialSeedRound > 0 { -// if let round = tournament.rounds().first(where: { $0.index == initialSeedRound }) { -// let seedSorted = frenchUmpireOrder(for: RoundRule.numberOfMatches(forRoundIndex: round.index)) -// print(seedSorted) -// seedSorted.prefix(initialSeedCount).forEach { index in -// if let match = round._matches()[safe:index] { -// if match.indexInRound() < RoundRule.numberOfMatches(forRoundIndex: round.index) / 2 { -// match.previousMatch(.one)?.disableMatch() -// } else { -// match.previousMatch(.two)?.disableMatch() -// } -// } -// } -// -// if initialSeedCount > 0 { -// tournament.tournamentStore?.matches.addOrUpdate(contentOfs: tournament._allMatchesIncludingDisabled()) -// } -// } -// } + } + + if seedRepartition.count > 0 { + await _handleSeedRepartition() } } else if (rebuildEverything == false && requirements.contains(.groupStage)) { tournament.deleteGroupStages() @@ -693,8 +650,63 @@ struct TableStructureView: View { } } + private func _handleSeedRepartition() async { + while tournament.rounds().count < seedRepartition.count { + await tournament.addNewRound(tournament.rounds().count) + } + + if seedRepartition.reduce(0, +) > 0 { + let rounds = tournament.rounds() + let roundsToDelete = rounds.suffix(rounds.count - seedRepartition.count) + for round in roundsToDelete { + await tournament.removeRound(round) + } + } + + for (index, seedCount) in seedRepartition.enumerated() { + if let round = tournament.rounds().first(where: { $0.index == index }) { + let baseIndex = RoundRule.baseIndex(forRoundIndex: round.index) + let numberOfMatches = RoundRule.numberOfMatches(forRoundIndex: round.index) + let playedMatches = round.playedMatches().map { $0.index - baseIndex } + let allMatches = round._matches() + let seedSorted = frenchUmpireOrder(for: numberOfMatches).filter({ index in + playedMatches.contains(index) + }).prefix(seedCount) + for (index, value) in seedSorted.enumerated() { + let isOpponentTurn = index >= playedMatches.count + if let match = allMatches[safe:value] { + if match.index - baseIndex < numberOfMatches / 2 { + if isOpponentTurn { + match.previousMatch(.two)?.disableMatch() + } else { + match.previousMatch(.one)?.disableMatch() + } + } else { + if isOpponentTurn { + match.previousMatch(.one)?.disableMatch() + } else { + match.previousMatch(.two)?.disableMatch() + } + } + } + } + + if seedCount > 0 { + tournament.tournamentStore?.matches.addOrUpdate(contentOfs: round._matches()) + tournament.tournamentStore?.matches.addOrUpdate(contentOfs: round.allLoserRoundMatches()) + round.deleteLoserBracket() + round.buildLoserBracket() + round.loserRounds().forEach { loserRound in + loserRound.disableUnplayedLoserBracketMatches() + } + } + } + } + } + private func _updatePreset() { if let selectedTournament { + seedRepartition = [] teamCount = selectedTournament.teamCount groupStageCount = selectedTournament.groupStageCount teamsPerGroupStage = selectedTournament.teamsPerGroupStage @@ -709,6 +721,7 @@ struct TableStructureView: View { groupStageAdditionalQualified = 0 buildWildcards = tournament.level.wildcardArePossible() } + _verifyValueIntegrity() } private func _verifyValueIntegrity() { @@ -754,6 +767,7 @@ struct TableStructureView: View { } } + seedRepartition = HeadManagerView.place(heads: tsPure, teamsInBracket: tf, initialSeedRound: nil) } } From b41e8064d7771eb1a197da835becd8c5c3852c7c Mon Sep 17 00:00:00 2001 From: Razmig Sarkissian Date: Tue, 14 Oct 2025 11:35:18 +0200 Subject: [PATCH 02/12] some fixes --- .../Views/Calling/BracketCallingView.swift | 2 +- .../CallMessageCustomizationView.swift | 1 - PadelClub/Views/Planning/PlanningView.swift | 52 +++++++++++++------ .../Screen/TableStructureView.swift | 21 +++++++- 4 files changed, 57 insertions(+), 19 deletions(-) diff --git a/PadelClub/Views/Calling/BracketCallingView.swift b/PadelClub/Views/Calling/BracketCallingView.swift index 50e1583..e203388 100644 --- a/PadelClub/Views/Calling/BracketCallingView.swift +++ b/PadelClub/Views/Calling/BracketCallingView.swift @@ -106,7 +106,7 @@ struct BracketCallingView: View { ForEach(filteredRounds()) { round in let seeds = seeds(forRoundIndex: round.index) - let startDate = round.startDate ?? round.playedMatches().first?.startDate + let startDate = ([round.startDate] + round.playedMatches().map { $0.startDate }).compacted().min() let callSeeds = seeds.filter({ tournament.isStartDateIsDifferentThanCallDate($0, expectedSummonDate: startDate) == false }) if seeds.isEmpty == false { Section { diff --git a/PadelClub/Views/Calling/CallMessageCustomizationView.swift b/PadelClub/Views/Calling/CallMessageCustomizationView.swift index 0ef3dfb..1c9c7ef 100644 --- a/PadelClub/Views/Calling/CallMessageCustomizationView.swift +++ b/PadelClub/Views/Calling/CallMessageCustomizationView.swift @@ -236,7 +236,6 @@ struct CallMessageCustomizationView: View { let hasBeenCreated: Bool = eventClub.hasBeenCreated(by: StoreCenter.main.userId) Section { TextField("Nom du club", text: $customClubName, axis: .vertical) - .lineLimit(2) .autocorrectionDisabled() .focused($focusedField, equals: .clubName) .onSubmit { diff --git a/PadelClub/Views/Planning/PlanningView.swift b/PadelClub/Views/Planning/PlanningView.swift index c4fe24e..b2afebe 100644 --- a/PadelClub/Views/Planning/PlanningView.swift +++ b/PadelClub/Views/Planning/PlanningView.swift @@ -728,9 +728,6 @@ struct PlanningView: View { } } - private func _eventCourtCount() -> Int { timeSlots.first?.value.first?.currentTournament()?.eventObject()?.eventCourtCount() ?? 2 - } - private func _save() { let groupByTournaments = allMatches.grouped { match in match.currentTournament() @@ -749,16 +746,27 @@ struct PlanningView: View { Button("Tirer au sort") { _removeCourts() - - let eventCourtCount = _eventCourtCount() - + for slot in timeSlots { - var courtsAvailable = Array(0...eventCourtCount) let matches = slot.value - matches.forEach { match in - if let rand = courtsAvailable.randomElement() { + var courtsByTournament: [String: Set] = [:] + for match in matches { + if let tournament = match.currentTournament(), + let available = tournament.matchScheduler()?.courtsAvailable { + courtsByTournament[tournament.id, default: []].formUnion(available) + } + } + + for match in matches { + guard let tournament = match.currentTournament() else { continue } + // Get current set of available courts for this tournament id + guard var courts = courtsByTournament[tournament.id], !courts.isEmpty else { continue } + // Pick a random court + if let rand = courts.randomElement() { match.courtIndex = rand - courtsAvailable.remove(elements: [rand]) + // Remove from local copy and assign back into the dictionary + courts.remove(rand) + courtsByTournament[tournament.id] = courts } } } @@ -768,16 +776,27 @@ struct PlanningView: View { Button("Fixer par ordre croissant") { _removeCourts() - - let eventCourtCount = _eventCourtCount() - + for slot in timeSlots { - var courtsAvailable = Array(0..] = [:] + for match in matches { + if let tournament = match.currentTournament(), + let available = tournament.matchScheduler()?.courtsAvailable { + courtsByTournament[tournament.id, default: []].formUnion(available.sorted()) + } + } + for i in 0.. Date: Tue, 14 Oct 2025 16:12:47 +0200 Subject: [PATCH 03/12] fix stuff --- .../Tournament/Screen/Components/HeadManagerView.swift | 6 ++++-- .../Components/TournamentMatchFormatsSettingsView.swift | 8 +++----- .../Views/Tournament/Screen/TableStructureView.swift | 4 ++++ 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/PadelClub/Views/Tournament/Screen/Components/HeadManagerView.swift b/PadelClub/Views/Tournament/Screen/Components/HeadManagerView.swift index 3fa4ed0..5e5ccf4 100644 --- a/PadelClub/Views/Tournament/Screen/Components/HeadManagerView.swift +++ b/PadelClub/Views/Tournament/Screen/Components/HeadManagerView.swift @@ -62,7 +62,9 @@ struct HeadManagerView: View { } while leftToPlace(heads: heads, teamsPerRound: teamsPerRound) > 0 { // maxAssignable: On retire toutes les équipes placées dans les tours précédents, pondérées par leur propagation (puissance du tour) - let headsLeft = heads - teamsPerRound.reduce(0, +) + let alreadyPut = teamsPerRound.reduce(0, +) + let headsLeft = alreadyPut == 0 ? teamsInBracket : heads - alreadyPut + // Calculate how many teams from previous rounds propagate to this round let currentRound = teamsPerRound.count var previousTeams = 0 @@ -150,7 +152,7 @@ struct HeadManagerView: View { } result = RoundRule.cumulatedNumberOfMatches(forTeams: count) } - + print(index, value, result, count) return result } .reduce(0, +) diff --git a/PadelClub/Views/Tournament/Screen/Components/TournamentMatchFormatsSettingsView.swift b/PadelClub/Views/Tournament/Screen/Components/TournamentMatchFormatsSettingsView.swift index d4280a0..3dcc1a4 100644 --- a/PadelClub/Views/Tournament/Screen/Components/TournamentMatchFormatsSettingsView.swift +++ b/PadelClub/Views/Tournament/Screen/Components/TournamentMatchFormatsSettingsView.swift @@ -25,12 +25,10 @@ struct TournamentMatchFormatsSettingsView: View { var body: some View { @Bindable var tournament = tournament List { - if confirmUpdate { - RowButtonView("Modifier les matchs existants", role: .destructive) { - _updateAllFormat() - } + RowButtonView("Modifier les matchs existants", role: .destructive) { + _updateAllFormat() } - + TournamentFormatSelectionView() Section { diff --git a/PadelClub/Views/Tournament/Screen/TableStructureView.swift b/PadelClub/Views/Tournament/Screen/TableStructureView.swift index bc20363..82f3436 100644 --- a/PadelClub/Views/Tournament/Screen/TableStructureView.swift +++ b/PadelClub/Views/Tournament/Screen/TableStructureView.swift @@ -691,6 +691,10 @@ struct TableStructureView: View { let seedSorted = frenchUmpireOrder(for: numberOfMatches).filter({ index in playedMatches.contains(index) }).prefix(seedCount) + + if playedMatches.count == numberOfMatches && seedCount == numberOfMatches * 2 { + continue + } for (index, value) in seedSorted.enumerated() { let isOpponentTurn = index >= playedMatches.count if let match = allMatches[safe:value] { From a3880b04bd977b82f40fbe3ce897b627f795da86 Mon Sep 17 00:00:00 2001 From: Razmig Sarkissian Date: Tue, 14 Oct 2025 23:56:35 +0200 Subject: [PATCH 04/12] fix head manager match count --- .../Views/Tournament/Screen/Components/HeadManagerView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PadelClub/Views/Tournament/Screen/Components/HeadManagerView.swift b/PadelClub/Views/Tournament/Screen/Components/HeadManagerView.swift index 5e5ccf4..f40cb82 100644 --- a/PadelClub/Views/Tournament/Screen/Components/HeadManagerView.swift +++ b/PadelClub/Views/Tournament/Screen/Components/HeadManagerView.swift @@ -63,7 +63,7 @@ struct HeadManagerView: View { while leftToPlace(heads: heads, teamsPerRound: teamsPerRound) > 0 { // maxAssignable: On retire toutes les équipes placées dans les tours précédents, pondérées par leur propagation (puissance du tour) let alreadyPut = teamsPerRound.reduce(0, +) - let headsLeft = alreadyPut == 0 ? teamsInBracket : heads - alreadyPut + let headsLeft = heads - alreadyPut // Calculate how many teams from previous rounds propagate to this round let currentRound = teamsPerRound.count @@ -152,7 +152,7 @@ struct HeadManagerView: View { } result = RoundRule.cumulatedNumberOfMatches(forTeams: count) } - print(index, value, result, count) +// print(index, value, result, count) return result } .reduce(0, +) From 43f5ac97a4888df07597b747deaa65f8e133809c Mon Sep 17 00:00:00 2001 From: Razmig Sarkissian Date: Wed, 15 Oct 2025 07:44:17 +0200 Subject: [PATCH 05/12] add helper footer fix player search view --- .../Navigation/Toolbox/ToolboxView.swift | 19 ++++++++++++++++--- .../Screen/TableStructureView.swift | 17 +++++++++++++++-- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/PadelClub/Views/Navigation/Toolbox/ToolboxView.swift b/PadelClub/Views/Navigation/Toolbox/ToolboxView.swift index 16f9890..04f955d 100644 --- a/PadelClub/Views/Navigation/Toolbox/ToolboxView.swift +++ b/PadelClub/Views/Navigation/Toolbox/ToolboxView.swift @@ -21,6 +21,7 @@ struct ToolboxView: View { @State private var tapCount = 0 @State private var lastTapTime: Date? = nil private let tapTimeThreshold: TimeInterval = 1.0 + @State private var displaySearchPlayer: Bool = false var lastDataSource: String? { dataStore.appSettings.lastDataSource @@ -69,9 +70,8 @@ struct ToolboxView: View { } Section { - NavigationLink { - SelectablePlayerListView(isPresented: false, lastDataSource: true) - .toolbar(.hidden, for: .tabBar) + Button { + displaySearchPlayer = true } label: { Label("Rechercher un joueur", systemImage: "person.fill.viewfinder") } @@ -121,6 +121,19 @@ struct ToolboxView: View { } } } + .sheet(isPresented: $displaySearchPlayer, content: { + NavigationStack { + SelectablePlayerListView(isPresented: false, lastDataSource: true) + .toolbar(.hidden, for: .tabBar) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Fermer") { + displaySearchPlayer = false + } + } + } + } + }) .onAppear { #if DEBUG self.showDebugViews = true diff --git a/PadelClub/Views/Tournament/Screen/TableStructureView.swift b/PadelClub/Views/Tournament/Screen/TableStructureView.swift index 82f3436..070fe94 100644 --- a/PadelClub/Views/Tournament/Screen/TableStructureView.swift +++ b/PadelClub/Views/Tournament/Screen/TableStructureView.swift @@ -330,8 +330,12 @@ struct TableStructureView: View { if tournament.state() != .initial { if seedRepartition.isEmpty == false { - RowButtonView("Modifier la répartition des équipes en tableau", role: .destructive, confirmationMessage: "Cette action va effacer le répartition actuelle des équipes dans le tableau.") { - await _handleSeedRepartition() + Section { + RowButtonView("Répartir les équipes en tableau", role: .destructive, confirmationMessage: "Cette action va effacer le répartition actuelle des équipes dans le tableau.") { + await _handleSeedRepartition() + } + } footer: { + Text("Cette action va effacer le répartition actuelle des équipes dans le tableau et la refaire, les manches seront ré-initialisées") } } @@ -339,24 +343,33 @@ struct TableStructureView: View { RowButtonView("Sauver sans reconstuire l'existant") { _saveWithoutRebuild() } + } footer: { + Text("Cette action sauve les paramètres du tournoi sans modifier vos poules / tableaux actuels.") + } Section { RowButtonView("Reconstruire les poules", role:.destructive) { await _save(rebuildEverything: false) } + } footer: { + Text("Cette action efface les poules existantes et les reconstruits, leurs données seront perdues.") } Section { RowButtonView("Tout refaire", role: .destructive) { await _save(rebuildEverything: true) } + } footer: { + Text("Cette action efface le tableau et les poules existantes et reconstruit tout de zéro, leurs données seront perdues.") } Section { RowButtonView("Remise-à-zéro", role: .destructive) { _reset() } + } footer: { + Text("Retourne à la structure initiale, comme si vous veniez de créer le tournoi. Les données existantes seront perdues.") } Section { From 8379eccfb6f617e61c0c5a587dafda437a6832fc Mon Sep 17 00:00:00 2001 From: Razmig Sarkissian Date: Wed, 15 Oct 2025 07:51:37 +0200 Subject: [PATCH 06/12] fix registion issues not displayed --- .../Tournament/Screen/InscriptionManagerView.swift | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/PadelClub/Views/Tournament/Screen/InscriptionManagerView.swift b/PadelClub/Views/Tournament/Screen/InscriptionManagerView.swift index a0c5470..5bd37aa 100644 --- a/PadelClub/Views/Tournament/Screen/InscriptionManagerView.swift +++ b/PadelClub/Views/Tournament/Screen/InscriptionManagerView.swift @@ -271,9 +271,7 @@ struct InscriptionManagerView: View { // await _refreshList(forced: true) // } .onAppear { - if tournament.enableOnlineRegistration == false || refreshStatus == true { - _setHash(currentSelectedSortedTeams: selectedSortedTeams) - } + _setHash(currentSelectedSortedTeams: selectedSortedTeams) } .onDisappear { _handleHashDiff(selectedSortedTeams: selectedSortedTeams) @@ -942,14 +940,9 @@ struct InscriptionManagerView: View { if tournament.enableOnlineRegistration { LabeledContent { Text(tournament.unsortedTeams().filter({ $0.hasRegisteredOnline() }).count.formatted()) - .font(.largeTitle) + .fontWeight(.bold) } label: { Text("Inscriptions en ligne") - if let refreshResult { - Text(refreshResult).foregroundStyle(.secondary) - } else { - Text(" ") - } } // RowButtonView("Rafraîchir les inscriptions en ligne") { From dbd970f87f00fb219da366f89321adbc9761714a Mon Sep 17 00:00:00 2001 From: Razmig Sarkissian Date: Wed, 15 Oct 2025 09:59:25 +0200 Subject: [PATCH 07/12] add custom club name option in tournament for calling teams --- .../Views/Calling/CallMessageCustomizationView.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/PadelClub/Views/Calling/CallMessageCustomizationView.swift b/PadelClub/Views/Calling/CallMessageCustomizationView.swift index 1c9c7ef..8b54d0e 100644 --- a/PadelClub/Views/Calling/CallMessageCustomizationView.swift +++ b/PadelClub/Views/Calling/CallMessageCustomizationView.swift @@ -31,7 +31,7 @@ struct CallMessageCustomizationView: View { self.tournament = tournament _customCallMessageBody = State(wrappedValue: DataStore.shared.user.summonsMessageBody ?? (DataStore.shared.user.summonsUseFullCustomMessage ? "" : ContactType.defaultCustomMessage)) _customCallMessageSignature = State(wrappedValue: DataStore.shared.user.getSummonsMessageSignature() ?? DataStore.shared.user.defaultSignature(tournament)) - _customClubName = State(wrappedValue: tournament.clubName ?? "Lieu du tournoi") + _customClubName = State(wrappedValue: tournament.customClubName ?? tournament.clubName ?? "Lieu du tournoi") _summonsAvailablePaymentMethods = State(wrappedValue: DataStore.shared.user.summonsAvailablePaymentMethods ?? ContactType.defaultAvailablePaymentMethods) } @@ -235,13 +235,13 @@ struct CallMessageCustomizationView: View { if let eventClub = tournament.eventObject()?.clubObject() { let hasBeenCreated: Bool = eventClub.hasBeenCreated(by: StoreCenter.main.userId) Section { - TextField("Nom du club", text: $customClubName, axis: .vertical) + TextField("Nom du club", text: $customClubName) .autocorrectionDisabled() .focused($focusedField, equals: .clubName) .onSubmit { - eventClub.name = customClubName + tournament.customClubName = customClubName.prefixTrimmed(100) do { - try dataStore.clubs.addOrUpdate(instance: eventClub) + try dataStore.tournaments.addOrUpdate(instance: tournament) } catch { Logger.error(error) } From 18228396bf21d25d65c116c3828e8d8d78f6b914 Mon Sep 17 00:00:00 2001 From: Razmig Sarkissian Date: Wed, 15 Oct 2025 10:10:59 +0200 Subject: [PATCH 08/12] build 2 --- PadelClub.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PadelClub.xcodeproj/project.pbxproj b/PadelClub.xcodeproj/project.pbxproj index 9838443..91699b7 100644 --- a/PadelClub.xcodeproj/project.pbxproj +++ b/PadelClub.xcodeproj/project.pbxproj @@ -3221,7 +3221,7 @@ CODE_SIGN_ENTITLEMENTS = PadelClub/PadelClub.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEFINES_MODULE = YES; DEVELOPMENT_ASSET_PATHS = "\"PadelClub/Preview Content\""; @@ -3269,7 +3269,7 @@ CODE_SIGN_ENTITLEMENTS = PadelClub/PadelClub.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEFINES_MODULE = YES; DEVELOPMENT_ASSET_PATHS = "\"PadelClub/Preview Content\""; DEVELOPMENT_TEAM = BQ3Y44M3Q6; From bd03321cc0bbbc8933736d55d1a06c92bab05bea Mon Sep 17 00:00:00 2001 From: Razmig Sarkissian Date: Thu, 16 Oct 2025 09:19:39 +0200 Subject: [PATCH 09/12] improve export data capability for teams / players --- .../PlayersWithoutContactView.swift | 4 +-- PadelClub/Views/Calling/SendToAllView.swift | 4 +-- PadelClub/Views/Cashier/CashierView.swift | 2 +- .../Views/GroupStage/GroupStageView.swift | 1 - PadelClub/Views/Match/MatchSetupView.swift | 1 - PadelClub/Views/Player/PlayerDetailView.swift | 2 +- .../Views/Shared/LearnMoreSheetView.swift | 2 +- PadelClub/Views/Team/EditingTeamView.swift | 2 +- .../Screen/InscriptionManagerView.swift | 29 ++++++++++++++----- 9 files changed, 30 insertions(+), 17 deletions(-) diff --git a/PadelClub/Views/Calling/Components/PlayersWithoutContactView.swift b/PadelClub/Views/Calling/Components/PlayersWithoutContactView.swift index 07b2747..d670b41 100644 --- a/PadelClub/Views/Calling/Components/PlayersWithoutContactView.swift +++ b/PadelClub/Views/Calling/Components/PlayersWithoutContactView.swift @@ -14,7 +14,7 @@ struct PlayersWithoutContactView: View { var body: some View { Section { - let withoutEmails = players.filter({ $0.email?.isEmpty == true || $0.email == nil }) + let withoutEmails = players.filter({ $0.hasMail() == false }) DisclosureGroup { ForEach(withoutEmails) { player in NavigationLink { @@ -32,7 +32,7 @@ struct PlayersWithoutContactView: View { } } - let withoutPhones = players.filter({ $0.phoneNumber?.isEmpty == true || $0.phoneNumber == nil || $0.phoneNumber?.isMobileNumber() == false }) + let withoutPhones = players.filter({ $0.hasMobilePhone() == false }) DisclosureGroup { ForEach(withoutPhones) { player in NavigationLink { diff --git a/PadelClub/Views/Calling/SendToAllView.swift b/PadelClub/Views/Calling/SendToAllView.swift index 41c82ce..3b464e8 100644 --- a/PadelClub/Views/Calling/SendToAllView.swift +++ b/PadelClub/Views/Calling/SendToAllView.swift @@ -273,9 +273,9 @@ struct SendToAllView: View { self._verifyUser { if contactMethod == 0 { - contactType = .message(date: nil, recipients: _teams().flatMap { $0.unsortedPlayers() }.compactMap { $0.phoneNumber }, body: finalMessage(), tournamentBuild: nil) + contactType = .message(date: nil, recipients: _teams().flatMap { $0.unsortedPlayers() }.flatMap { [$0.phoneNumber, $0.contactPhoneNumber] }.compactMap({ $0 }), body: finalMessage(), tournamentBuild: nil) } else { - contactType = .mail(date: nil, recipients: tournament.umpireMail(), bccRecipients: _teams().flatMap { $0.unsortedPlayers() }.compactMap { $0.email }, body: finalMessage(), subject: tournament.mailSubject(), tournamentBuild: nil) + contactType = .mail(date: nil, recipients: tournament.umpireMail(), bccRecipients: _teams().flatMap { $0.unsortedPlayers() }.flatMap { [$0.email, $0.contactEmail] }.compactMap({ $0 }), body: finalMessage(), subject: tournament.mailSubject(), tournamentBuild: nil) } } diff --git a/PadelClub/Views/Cashier/CashierView.swift b/PadelClub/Views/Cashier/CashierView.swift index 5433655..f175f52 100644 --- a/PadelClub/Views/Cashier/CashierView.swift +++ b/PadelClub/Views/Cashier/CashierView.swift @@ -18,7 +18,7 @@ struct ShareableObject { func sharedData() async -> Data? { let _players = players.filter({ cashierViewModel._shouldDisplayPlayer($0) }) .map { - [$0.pasteData()] + [$0.pasteData(type: .payment)] .compacted() .joined(separator: "\n") } diff --git a/PadelClub/Views/GroupStage/GroupStageView.swift b/PadelClub/Views/GroupStage/GroupStageView.swift index ac06bfa..d1956d3 100644 --- a/PadelClub/Views/GroupStage/GroupStageView.swift +++ b/PadelClub/Views/GroupStage/GroupStageView.swift @@ -245,7 +245,6 @@ struct GroupStageView: View { Text("#\(index + 1)") .font(.caption) TeamPickerView(groupStagePosition: index, pickTypeContext: .groupStage, teamPicked: { team in - print(team.pasteData()) team.groupStage = groupStage.id team.groupStagePosition = index groupStage._matches().forEach({ $0.updateTeamScores() }) diff --git a/PadelClub/Views/Match/MatchSetupView.swift b/PadelClub/Views/Match/MatchSetupView.swift index 67cd78c..364d912 100644 --- a/PadelClub/Views/Match/MatchSetupView.swift +++ b/PadelClub/Views/Match/MatchSetupView.swift @@ -65,7 +65,6 @@ struct MatchSetupView: View { HStack { let luckyLosers = walkOutSpot ? match.luckyLosers() : [] TeamPickerView(shouldConfirm: shouldConfirm, round: match.roundObject, pickTypeContext: matchTypeContext == .bracket ? .bracket : .loserBracket, luckyLosers: luckyLosers, teamPicked: { team in - print(team.pasteData()) if walkOutSpot || team.bracketPosition != nil || matchTypeContext == .loserBracket { match.setLuckyLoser(team: team, teamPosition: teamPosition) do { diff --git a/PadelClub/Views/Player/PlayerDetailView.swift b/PadelClub/Views/Player/PlayerDetailView.swift index 93a1688..2671928 100644 --- a/PadelClub/Views/Player/PlayerDetailView.swift +++ b/PadelClub/Views/Player/PlayerDetailView.swift @@ -372,7 +372,7 @@ struct PlayerDetailView: View { .toolbarBackground(.visible, for: .navigationBar) .toolbar { ToolbarItem(placement: .topBarTrailing) { - ShareLink(item: player.pasteData()) { + ShareLink(item: player.pasteData(type: .sharing)) { Label("Partager", systemImage: "square.and.arrow.up") } } diff --git a/PadelClub/Views/Shared/LearnMoreSheetView.swift b/PadelClub/Views/Shared/LearnMoreSheetView.swift index 47ab4ef..fbe4b00 100644 --- a/PadelClub/Views/Shared/LearnMoreSheetView.swift +++ b/PadelClub/Views/Shared/LearnMoreSheetView.swift @@ -28,7 +28,7 @@ struct LearnMoreSheetView: View { """) } actions: { - ShareLink(item: tournament.pasteDataForImporting().createFile(tournament.tournamentTitle(.short))) { + ShareLink(item: tournament.pasteDataForImporting(type: .sharing).createFile(tournament.tournamentTitle(.short))) { Text("Exporter les inscriptions") } diff --git a/PadelClub/Views/Team/EditingTeamView.swift b/PadelClub/Views/Team/EditingTeamView.swift index ab9b3f9..811607f 100644 --- a/PadelClub/Views/Team/EditingTeamView.swift +++ b/PadelClub/Views/Team/EditingTeamView.swift @@ -116,7 +116,7 @@ struct EditingTeamView: View { } } footer: { HStack { - CopyPasteButtonView(pasteValue: team.playersPasteData()) + CopyPasteButtonView(pasteValue: team.playersPasteData(type: .sharing)) Spacer() if team.isWildCard(), team.unsortedPlayers().isEmpty { TeamPickerView(pickTypeContext: .wildcard) { teamregistration in diff --git a/PadelClub/Views/Tournament/Screen/InscriptionManagerView.swift b/PadelClub/Views/Tournament/Screen/InscriptionManagerView.swift index 5bd37aa..96bc4e8 100644 --- a/PadelClub/Views/Tournament/Screen/InscriptionManagerView.swift +++ b/PadelClub/Views/Tournament/Screen/InscriptionManagerView.swift @@ -547,11 +547,25 @@ struct InscriptionManagerView: View { private func _sharingTeamsMenuView() -> some View { Menu { - ShareLink(item: teamPaste(), preview: .init("Inscriptions")) { - Text("En texte") + Menu { + ShareLink(item: teamPaste(.rawText, type: .sharing), preview: .init(ExportType.sharing.localizedString().capitalized)) { + Text("En texte") + } + ShareLink(item: teamPaste(.csv, type: .sharing), preview: .init(ExportType.sharing.localizedString().capitalized)) { + Text("En csv") + } + } label: { + Text("Pour diffusion") } - ShareLink(item: teamPaste(.csv), preview: .init("Inscriptions")) { - Text("En csv") + Menu { + ShareLink(item: teamPaste(.rawText, type: .payment), preview: .init(ExportType.payment.localizedString().capitalized)) { + Text("En texte") + } + ShareLink(item: teamPaste(.csv, type: .payment), preview: .init(ExportType.payment.localizedString().capitalized)) { + Text("En csv") + } + } label: { + Text("Pour encaissement") } } label: { Label("Exporter les paires", systemImage: "square.and.arrow.up") @@ -575,8 +589,8 @@ struct InscriptionManagerView: View { tournament.unsortedTeamsWithoutWO() } - func teamPaste(_ exportFormat: ExportFormat = .rawText) -> TournamentShareFile { - TournamentShareFile(tournament: tournament, exportFormat: exportFormat) + func teamPaste(_ exportFormat: ExportFormat = .rawText, type: ExportType) -> TournamentShareFile { + TournamentShareFile(tournament: tournament, exportFormat: exportFormat, type: type) } var unsortedPlayers: [PlayerRegistration] { @@ -1251,10 +1265,11 @@ struct TournamentGroupStageShareContent: Transferable { struct TournamentShareFile: Transferable { let tournament: Tournament let exportFormat: ExportFormat + let type: ExportType func shareFile() -> URL { print("Generating URL...") - return tournament.pasteDataForImporting(exportFormat).createFile(self.tournament.tournamentTitle()+"-inscriptions", exportFormat) + return tournament.pasteDataForImporting(exportFormat, type: type).createFile(self.tournament.tournamentTitle()+"-"+type.localizedString(), exportFormat) } static var transferRepresentation: some TransferRepresentation { From 035f8ccc9d5ff53959701c1f9ee5860e9d647612 Mon Sep 17 00:00:00 2001 From: Laurent Date: Thu, 16 Oct 2025 09:51:59 +0200 Subject: [PATCH 10/12] Adds CLAUDE.md file --- CLAUDE.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..afb1737 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,8 @@ +## Padel Club + +This is the main directory of a Swift app that helps padel tournament organizers. +The project is structured around three projects linked in the PadelClub.xcworkspace: +- PadelClub: this one, which mostly contains the UI for the project +- PadelClubData: the business logic for the app +- LeStorage: a local storage with a synchronization layer + From b5d5cd4aeb890349ddf8d8947a6ed815efdaca6b Mon Sep 17 00:00:00 2001 From: Razmig Sarkissian Date: Thu, 16 Oct 2025 12:58:49 +0200 Subject: [PATCH 11/12] add payment link api --- PadelClub.xcodeproj/project.pbxproj | 8 + PadelClub/Utils/Network/PaymentService.swift | 40 ++- PadelClub/Views/Team/EditingTeamView.swift | 19 ++ .../Views/Team/PaymentLinkManagerView.swift | 268 ++++++++++++++++++ .../Screen/InscriptionManagerView.swift | 59 ++-- 5 files changed, 358 insertions(+), 36 deletions(-) create mode 100644 PadelClub/Views/Team/PaymentLinkManagerView.swift diff --git a/PadelClub.xcodeproj/project.pbxproj b/PadelClub.xcodeproj/project.pbxproj index 91699b7..f907c1d 100644 --- a/PadelClub.xcodeproj/project.pbxproj +++ b/PadelClub.xcodeproj/project.pbxproj @@ -151,6 +151,9 @@ FF1F4B8A2BFA02A4000B4573 /* groupstage-template.html in Resources */ = {isa = PBXBuildFile; fileRef = FF1F4B772BFA0105000B4573 /* groupstage-template.html */; }; FF1F4B8B2BFA02A4000B4573 /* groupstageentrant-template.html in Resources */ = {isa = PBXBuildFile; fileRef = FF1F4B792BFA0105000B4573 /* groupstageentrant-template.html */; }; FF1F4B8C2BFA02A4000B4573 /* match-template.html in Resources */ = {isa = PBXBuildFile; fileRef = FF1F4B7D2BFA0105000B4573 /* match-template.html */; }; + FF2099042EA0D99A003CE880 /* PaymentLinkManagerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2099032EA0D99A003CE880 /* PaymentLinkManagerView.swift */; }; + FF2099052EA0D99A003CE880 /* PaymentLinkManagerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2099032EA0D99A003CE880 /* PaymentLinkManagerView.swift */; }; + FF2099062EA0D99A003CE880 /* PaymentLinkManagerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2099032EA0D99A003CE880 /* PaymentLinkManagerView.swift */; }; FF2B51552C7A4DAF00FFF126 /* PlanningByCourtView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2B51542C7A4DAF00FFF126 /* PlanningByCourtView.swift */; }; FF2B51612C7E302C00FFF126 /* local.sqlite in Resources */ = {isa = PBXBuildFile; fileRef = FF2B51602C7E302C00FFF126 /* local.sqlite */; }; FF2B6F5E2C036A1500835EE7 /* EventLinksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF2B6F5D2C036A1400835EE7 /* EventLinksView.swift */; }; @@ -1038,6 +1041,7 @@ FF1F4B7E2BFA0105000B4573 /* player-template.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = "player-template.html"; sourceTree = ""; }; FF1F4B7F2BFA0105000B4573 /* tournament-template.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = "tournament-template.html"; sourceTree = ""; }; FF1F4B812BFA0124000B4573 /* PrintSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrintSettingsView.swift; sourceTree = ""; }; + FF2099032EA0D99A003CE880 /* PaymentLinkManagerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentLinkManagerView.swift; sourceTree = ""; }; FF2B51542C7A4DAF00FFF126 /* PlanningByCourtView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlanningByCourtView.swift; sourceTree = ""; }; FF2B51602C7E302C00FFF126 /* local.sqlite */ = {isa = PBXFileReference; lastKnownFileType = file; path = local.sqlite; sourceTree = ""; }; FF2B51622C7F073100FFF126 /* Model_1_1.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Model_1_1.xcdatamodel; sourceTree = ""; }; @@ -1890,6 +1894,7 @@ FF17CA562CC02FEA003C7323 /* CoachListView.swift */, FF7DCD382CC330260041110C /* TeamRestingView.swift */, FF30ACF02E8D7078008B6006 /* PaymentRequestButton.swift */, + FF2099032EA0D99A003CE880 /* PaymentLinkManagerView.swift */, FF025AD62BD0C0FB00A86CF8 /* Components */, ); path = Team; @@ -2502,6 +2507,7 @@ FFA97C8D2E8A59D00089EA22 /* TournamentPickerView.swift in Sources */, C493B37E2C10AD3600862481 /* LoadingViewModifier.swift in Sources */, FF089EBD2BB0287D00F0AEC7 /* PlayerView.swift in Sources */, + FF2099052EA0D99A003CE880 /* PaymentLinkManagerView.swift in Sources */, FF967D032BAEF0C000A9A3BD /* MatchDetailView.swift in Sources */, FFE8B5B32DA848D300BDE966 /* OnlineWaitingListFaqSheetView.swift in Sources */, FF967D0F2BAF63B000A9A3BD /* PlayerBlockView.swift in Sources */, @@ -2779,6 +2785,7 @@ FFA97C8F2E8A59D00089EA22 /* TournamentPickerView.swift in Sources */, FF4CC0142C996C0600151637 /* PlayerBlockView.swift in Sources */, FF4CC0172C996C0600151637 /* PointView.swift in Sources */, + FF2099042EA0D99A003CE880 /* PaymentLinkManagerView.swift in Sources */, FF4CC0182C996C0600151637 /* ClubHolder.swift in Sources */, FF4CC0192C996C0600151637 /* EventSettingsView.swift in Sources */, C49771E72DC25F04005CD239 /* Color+Extensions.swift in Sources */, @@ -3034,6 +3041,7 @@ FFA97C8E2E8A59D00089EA22 /* TournamentPickerView.swift in Sources */, FF70FB932C90584900129CC2 /* PlayerBlockView.swift in Sources */, FF70FB962C90584900129CC2 /* PointView.swift in Sources */, + FF2099062EA0D99A003CE880 /* PaymentLinkManagerView.swift in Sources */, FF70FB972C90584900129CC2 /* ClubHolder.swift in Sources */, FF70FB982C90584900129CC2 /* EventSettingsView.swift in Sources */, C49771E42DC25F04005CD239 /* Color+Extensions.swift in Sources */, diff --git a/PadelClub/Utils/Network/PaymentService.swift b/PadelClub/Utils/Network/PaymentService.swift index 1eef4db..2be0200 100644 --- a/PadelClub/Utils/Network/PaymentService.swift +++ b/PadelClub/Utils/Network/PaymentService.swift @@ -13,8 +13,8 @@ class PaymentService { static func resendPaymentEmail(teamRegistrationId: String) async throws -> SimpleResponse { let service = try StoreCenter.main.service() let urlRequest = try service._baseRequest( - servicePath: "resend-payment-email/\(teamRegistrationId)/", - method: .post, + servicePath: "resend-payment-email/\(teamRegistrationId)/", + method: .post, requiresToken: true ) @@ -27,6 +27,42 @@ class PaymentService { return try JSON.decoder.decode(SimpleResponse.self, from: data) } + + static func getPaymentLink(teamRegistrationId: String) async throws -> PaymentLinkResponse { + let service = try StoreCenter.main.service() + let urlRequest = try service._baseRequest( + servicePath: "payment-link/\(teamRegistrationId)/", + method: .get, + requiresToken: true + ) + + let (data, response) = try await URLSession.shared.data(for: urlRequest) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + throw PaymentError.requestFailed + } + +// // Debug: Print the raw JSON response +// if let jsonString = String(data: data, encoding: .utf8) { +// print("Raw JSON Response: \(jsonString)") +// } + + return try JSON.decoder.decode(PaymentLinkResponse.self, from: data) + } + +} + +struct PaymentLinkResponse: Codable { + let success: Bool + let paymentLink: String? + let message: String? + + enum CodingKeys: String, CodingKey { + case success + case paymentLink + case message + } } enum PaymentError: Error { diff --git a/PadelClub/Views/Team/EditingTeamView.swift b/PadelClub/Views/Team/EditingTeamView.swift index 811607f..b308869 100644 --- a/PadelClub/Views/Team/EditingTeamView.swift +++ b/PadelClub/Views/Team/EditingTeamView.swift @@ -31,6 +31,7 @@ struct EditingTeamView: View { @State private var registrationDateModified: Date @State private var uniqueRandomIndex: Int @State private var isDeleting: Bool = false + @State private var showPaymentLinkManager: Bool = false var messageSentFailed: Binding { Binding { @@ -155,6 +156,11 @@ struct EditingTeamView: View { if team.hasPaidOnline() == false { PaymentRequestButton(teamRegistration: team) +#if PRODTEST + Button("Récupérer le lien de paiement") { + showPaymentLinkManager = true + } +#endif } } @@ -409,6 +415,19 @@ struct EditingTeamView: View { } .tint(.master) } + .sheet(isPresented: $showPaymentLinkManager) { + NavigationStack { + PaymentLinkManagerView(teamRegistration: team) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Fermer") { + showPaymentLinkManager = false + } + } + } + } + } .fullScreenCover(item: $editedTeam) { editedTeam in NavigationStack { AddTeamView(tournament: tournament, editedTeam: editedTeam) diff --git a/PadelClub/Views/Team/PaymentLinkManagerView.swift b/PadelClub/Views/Team/PaymentLinkManagerView.swift new file mode 100644 index 0000000..573fa54 --- /dev/null +++ b/PadelClub/Views/Team/PaymentLinkManagerView.swift @@ -0,0 +1,268 @@ +// +// PaymentLinkManagerView.swift +// PadelClub +// +// Created by Razmig Sarkissian on 16/10/2025. +// + + +// +// PaymentLinkManagerView.swift +// PadelClub +// +// Created by Razmig Sarkissian on 01/10/2025. +// + +import SwiftUI +import PadelClubData + +struct PaymentLinkManagerView: View { + let teamRegistration: TeamRegistration + @State private var isLoading = false + @State private var showAlert = false + @State private var alertMessage = "" + @State private var paymentLink: String? + @State private var showCopiedConfirmation = false + + var body: some View { + VStack(spacing: 20) { + // Header + header + + // Get Payment Link Button + getPaymentLinkButton + + // Payment Link Display and Actions + if let link = paymentLink { + paymentLinkSection(link: link) + } + + Spacer() + } + .padding() + .alert("Erreur", isPresented: $showAlert) { + Button("OK") { } + } message: { + Text(alertMessage) + } + } + + // MARK: - ViewBuilder Components + + @ViewBuilder + private var header: some View { + VStack(spacing: 8) { + Image(systemName: "creditcard.circle.fill") + .font(.system(size: 50)) + .foregroundColor(.blue) + + Text("Lien de paiement") + .font(.title2) + .fontWeight(.bold) + + Text("Obtenez un lien de paiement à partager avec l'équipe") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + } + + @ViewBuilder + private var getPaymentLinkButton: some View { + Button { + getPaymentLink() + } label: { + HStack { + if isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + .tint(.white) + } else { + Image(systemName: "link.circle") + } + Text(paymentLink == nil ? "Obtenir le lien de paiement" : "Régénérer le lien") + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(12) + } + .disabled(isLoading) + } + + @ViewBuilder + private func paymentLinkSection(link: String) -> some View { + VStack(spacing: 16) { + // Success message + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("Lien copié dans le presse-papiers!") + .font(.subheadline) + .foregroundColor(.green) + } + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background(Color.green.opacity(0.1)) + .cornerRadius(8) + + // Link display + linkDisplayView(link: link) + + // Action buttons + actionButtons(link: link) + } + .transition(.move(edge: .top).combined(with: .opacity)) + .animation(.spring(response: 0.6, dampingFraction: 0.8), value: paymentLink) + } + + @ViewBuilder + private func linkDisplayView(link: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Lien de paiement:") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.secondary) + + ScrollView(.horizontal, showsIndicators: false) { + Text(link) + .font(.system(.caption, design: .monospaced)) + .padding(12) + .background(Color.gray.opacity(0.1)) + .cornerRadius(8) + } + } + } + + @ViewBuilder + private func actionButtons(link: String) -> some View { + VStack(spacing: 12) { + // Copy button + copyButton(link: link) + + // Share button + shareButton(link: link) + + // Open in browser button + openInBrowserButton(link: link) + } + } + + @ViewBuilder + private func copyButton(link: String) -> some View { + Button { + UIPasteboard.general.string = link + showCopiedConfirmation = true + + // Haptic feedback + let generator = UINotificationFeedbackGenerator() + generator.notificationOccurred(.success) + + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + showCopiedConfirmation = false + } + } label: { + HStack { + Image(systemName: showCopiedConfirmation ? "checkmark.circle.fill" : "doc.on.doc.fill") + Text(showCopiedConfirmation ? "Copié !" : "Copier le lien") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .padding() + .background(showCopiedConfirmation ? Color.green : Color.blue.opacity(0.1)) + .foregroundColor(showCopiedConfirmation ? .white : .blue) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(showCopiedConfirmation ? Color.green : Color.blue, lineWidth: 1) + ) + } + .disabled(showCopiedConfirmation) + .animation(.easeInOut(duration: 0.2), value: showCopiedConfirmation) + } + + @ViewBuilder + private func shareButton(link: String) -> some View { + ShareLink(item: link) { + HStack { + Image(systemName: "square.and.arrow.up.fill") + Text("Partager le lien") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.blue.opacity(0.1)) + .foregroundColor(.blue) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.blue, lineWidth: 1) + ) + } + } + + @ViewBuilder + private func openInBrowserButton(link: String) -> some View { + Button { + if let url = URL(string: link) { + UIApplication.shared.open(url) + } + } label: { + HStack { + Image(systemName: "safari.fill") + Text("Ouvrir dans Safari") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.blue.opacity(0.1)) + .foregroundColor(.blue) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.blue, lineWidth: 1) + ) + } + } + + // MARK: - Private Methods + + private func getPaymentLink() { + isLoading = true + showCopiedConfirmation = false + + Task { + do { + let response = try await PaymentService.getPaymentLink( + teamRegistrationId: teamRegistration.id + ) + await MainActor.run { + isLoading = false + if response.success, let link = response.paymentLink { + paymentLink = link + // Automatically copy to clipboard + UIPasteboard.general.string = link + + // Haptic feedback + let generator = UINotificationFeedbackGenerator() + generator.notificationOccurred(.success) + } else { + alertMessage = response.message ?? "Impossible d'obtenir le lien de paiement" + showAlert = true + } + } + } catch { + await MainActor.run { + isLoading = false + alertMessage = "Erreur lors de la récupération du lien" + showAlert = true + } + } + } + } +} + +#Preview { + PaymentLinkManagerView(teamRegistration: TeamRegistration()) +} diff --git a/PadelClub/Views/Tournament/Screen/InscriptionManagerView.swift b/PadelClub/Views/Tournament/Screen/InscriptionManagerView.swift index 96bc4e8..b8b8976 100644 --- a/PadelClub/Views/Tournament/Screen/InscriptionManagerView.swift +++ b/PadelClub/Views/Tournament/Screen/InscriptionManagerView.swift @@ -379,40 +379,6 @@ struct InscriptionManagerView: View { ToolbarItem(placement: .navigationBarTrailing) { Menu { - - #if PRODTEST - if tournament.enableOnlinePayment { - Button { - isLoading = true - Task { - do { - try await selectedSortedTeams.filter { team in - team.hasPaidOnline() == false && team.hasPaid() == false - }.concurrentForEach { team in - _ = try await PaymentService.resendPaymentEmail(teamRegistrationId: team.id) - } - - await MainActor.run { - isLoading = false - alertMessage = "Relance effectuée avec succès" - showAlert = true - } - } catch { - Logger.error(error) - await MainActor.run { - isLoading = false - alertMessage = "Erreur lors de la requête" - showAlert = true - } - } - } - } label: { - Text("Requête de paiement") - } - .disabled(isLoading) - } - #endif - if tournament.inscriptionClosed() == false { Menu { _sortingTypePickerView() @@ -876,6 +842,31 @@ struct InscriptionManagerView: View { @ViewBuilder private func _informationView(for teams: [TeamRegistration]) -> some View { + #if PRODTEST + if tournament.enableOnlinePayment { + RowButtonView("Requête de paiement", role: .destructive) { + do { + try await teams.filter { team in + team.hasPaidOnline() == false && team.hasPaid() == false + }.concurrentForEach { team in + _ = try await PaymentService.resendPaymentEmail(teamRegistrationId: team.id) + } + + await MainActor.run { + alertMessage = "Relance effectuée avec succès" + showAlert = true + } + } catch { + Logger.error(error) + await MainActor.run { + alertMessage = "Erreur lors de la requête" + showAlert = true + } + } + } + } + #endif + Section { HStack { // VStack(alignment: .leading, spacing: 0) { From 0de19382d895d9b6420191c4d92bbfa04a01e348 Mon Sep 17 00:00:00 2001 From: Razmig Sarkissian Date: Thu, 16 Oct 2025 14:37:23 +0200 Subject: [PATCH 12/12] fix request payment positionning --- PadelClub/Views/Team/EditingTeamView.swift | 3 ++- PadelClub/Views/Team/PaymentRequestButton.swift | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/PadelClub/Views/Team/EditingTeamView.swift b/PadelClub/Views/Team/EditingTeamView.swift index b308869..4944668 100644 --- a/PadelClub/Views/Team/EditingTeamView.swift +++ b/PadelClub/Views/Team/EditingTeamView.swift @@ -155,7 +155,6 @@ struct EditingTeamView: View { } if team.hasPaidOnline() == false { - PaymentRequestButton(teamRegistration: team) #if PRODTEST Button("Récupérer le lien de paiement") { showPaymentLinkManager = true @@ -182,6 +181,8 @@ struct EditingTeamView: View { } footer: { if team.hasPaidOnline() { Text("Le remboursement passe part le service de Stripe qui re-crédite le moyen de paiement utilisé du montant payé.") + } else { + PaymentRequestButton(teamRegistration: team) } } } diff --git a/PadelClub/Views/Team/PaymentRequestButton.swift b/PadelClub/Views/Team/PaymentRequestButton.swift index 7ce6843..3b9e2a9 100644 --- a/PadelClub/Views/Team/PaymentRequestButton.swift +++ b/PadelClub/Views/Team/PaymentRequestButton.swift @@ -16,7 +16,7 @@ struct PaymentRequestButton: View { @State private var alertMessage = "" var body: some View { - Button("Renvoyer email de paiement") { + FooterButtonView("Renvoyer l'email de paiement", role: .destructive, confirmationMessage: "Cette action permet de renvoyer le mail de confirmation de sélection de l'équipe incluant la demande du paiement.") { resendEmail() } .disabled(isLoading)