diff --git a/PadelClub.xcodeproj/project.pbxproj b/PadelClub.xcodeproj/project.pbxproj index 3b64fd9..2745959 100644 --- a/PadelClub.xcodeproj/project.pbxproj +++ b/PadelClub.xcodeproj/project.pbxproj @@ -3099,6 +3099,7 @@ DEFINES_MODULE = YES; DEVELOPMENT_ASSET_PATHS = "\"PadelClub/Preview Content\""; DEVELOPMENT_TEAM = BQ3Y44M3Q6; + ENABLE_DEBUG_DYLIB = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = PadelClub/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Padel Club"; @@ -3120,7 +3121,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.24; + MARKETING_VERSION = 1.2.33; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3166,7 +3167,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.24; + MARKETING_VERSION = 1.2.33; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3285,7 +3286,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.24; + MARKETING_VERSION = 1.2.33; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3330,7 +3331,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.24; + MARKETING_VERSION = 1.2.33; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3374,7 +3375,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.24; + MARKETING_VERSION = 1.2.33; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub.beta; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3416,7 +3417,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.24; + MARKETING_VERSION = 1.2.33; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub.beta; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/PadelClub/Data/Federal/FederalTournament.swift b/PadelClub/Data/Federal/FederalTournament.swift index 5feb4e1..0021712 100644 --- a/PadelClub/Data/Federal/FederalTournament.swift +++ b/PadelClub/Data/Federal/FederalTournament.swift @@ -81,6 +81,11 @@ struct FederalTournament: Identifiable, Codable { var dateFin, dateValidation: Date? var codePostalEngagement, codeClub: String? var prixEspece: Int? + var japPhoneNumber: String? + + mutating func updateJapPhoneNumber(phone: String?) { + self.japPhoneNumber = phone + } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -249,7 +254,7 @@ struct FederalTournament: Identifiable, Codable { } var japMessage: String { - [nomClub, jugeArbitre?.nom, jugeArbitre?.prenom, courrielEngagement, installation?.telephone].compactMap({$0}).joined(separator: ";") + [nomClub, jugeArbitre?.nom, jugeArbitre?.prenom, courrielEngagement, japPhoneNumber].compactMap({$0}).joined(separator: ";") } func umpireLabel() -> String { diff --git a/PadelClub/Extensions/PlayerRegistration+Extensions.swift b/PadelClub/Extensions/PlayerRegistration+Extensions.swift index e702d05..abf75df 100644 --- a/PadelClub/Extensions/PlayerRegistration+Extensions.swift +++ b/PadelClub/Extensions/PlayerRegistration+Extensions.swift @@ -21,6 +21,7 @@ extension PlayerRegistration { self.tournamentPlayed = importedPlayer.tournamentPlayed self.points = importedPlayer.getPoints() self.clubName = importedPlayer.clubName?.prefixTrimmed(200) + self.clubCode = importedPlayer.clubCode?.replaceCharactersFromSet(characterSet: .whitespaces).prefixTrimmed(20) self.ligueName = importedPlayer.ligueName?.prefixTrimmed(200) self.assimilation = importedPlayer.assimilation?.prefixTrimmed(50) self.source = .frenchFederation diff --git a/PadelClub/Extensions/TeamRegistration+Extensions.swift b/PadelClub/Extensions/TeamRegistration+Extensions.swift index dd1e8a1..c3c2140 100644 --- a/PadelClub/Extensions/TeamRegistration+Extensions.swift +++ b/PadelClub/Extensions/TeamRegistration+Extensions.swift @@ -45,6 +45,12 @@ extension TeamRegistration { player.captain = oldPlayer.captain player.assimilation = oldPlayer.assimilation player.ligueName = oldPlayer.ligueName + player.registrationStatus = oldPlayer.registrationStatus + player.timeToConfirm = oldPlayer.timeToConfirm + player.sex = oldPlayer.sex + player.paymentType = oldPlayer.paymentType + player.paymentId = oldPlayer.paymentId + player.clubMember = oldPlayer.clubMember } } } diff --git a/PadelClub/Extensions/Tournament+Extensions.swift b/PadelClub/Extensions/Tournament+Extensions.swift index 1e7c1cd..c637a6f 100644 --- a/PadelClub/Extensions/Tournament+Extensions.swift +++ b/PadelClub/Extensions/Tournament+Extensions.swift @@ -43,7 +43,7 @@ extension Tournament { } func addTeam(_ players: Set, registrationDate: Date? = nil, name: String? = nil) -> TeamRegistration { - let team = TeamRegistration(tournament: id, registrationDate: registrationDate, name: name) + let team = TeamRegistration(tournament: id, registrationDate: registrationDate ?? Date(), name: name) team.setWeight(from: Array(players), inTournamentCategory: tournamentCategory) players.forEach { player in player.teamRegistration = team.id @@ -76,7 +76,7 @@ extension Tournament { guard let tournamentStore = self.tournamentStore else { return } let teams = (0.. -
  • +
  •  {{roundLabel}}
    {{formatLabel}}
  • diff --git a/PadelClub/HTML Templates/match-template.html b/PadelClub/HTML Templates/match-template.html index aba166a..bf0365d 100644 --- a/PadelClub/HTML Templates/match-template.html +++ b/PadelClub/HTML Templates/match-template.html @@ -1,8 +1,13 @@ -
  • +
  • {{entrantOne}} +
    {{matchDescriptionTop}}
  • -
  • {{matchDescription}}
  • -
  • - {{entrantTwo}} +
  • +
  • +
  • +
    + {{entrantTwo}} +
    +
    {{matchDescriptionBottom}}
  •  
  • diff --git a/PadelClub/HTML Templates/tournament-template.html b/PadelClub/HTML Templates/tournament-template.html index e98ebf0..03c77d2 100644 --- a/PadelClub/HTML Templates/tournament-template.html +++ b/PadelClub/HTML Templates/tournament-template.html @@ -92,11 +92,32 @@ overflow: hidden; text-overflow: ellipsis; } + + .game { + /* Ensure the game container is a positioning context for the overlay */ + position: relative; + /* Add any other existing styles for your game list items */ + } + + .match-description-overlay { + /* Position the overlay directly on top of the game item */ + position: absolute; + top: 0; + left: 0; + transform: translateY(100%); + width: 100%; + height: 100%; + display: flex; /* Enable flexbox for centering */ + justify-content: center; /* Center horizontally */ + align-items: center; /* Center vertically (if needed) */ + font-size: 1em; /* Optional: Adjust font size */ + /* Add any other desired styling for the overlay */ + } -

    {{tournamentTitle}} - {{tournamentStartDate}}

    +

    {{tournamentTitle}} - {{tournamentStartDate}}

    {{brackets}}
    diff --git a/PadelClub/Utils/FileImportManager.swift b/PadelClub/Utils/FileImportManager.swift index 01d2286..3e9c1d4 100644 --- a/PadelClub/Utils/FileImportManager.swift +++ b/PadelClub/Utils/FileImportManager.swift @@ -307,9 +307,11 @@ class FileImportManager { if (tournamentCategory == tournament.tournamentCategory && tournamentAgeCategory == tournament.federalTournamentAge) || checkingCategoryDisabled { let playerOne = PlayerRegistration(federalData: Array(resultOne[0...7]), sex: sexPlayerOne, sexUnknown: sexUnknown) playerOne?.setComputedRank(in: tournament) + playerOne?.setClubMember(for: tournament) let playerTwo = PlayerRegistration(federalData: Array(resultTwo[0...7]), sex: sexPlayerTwo, sexUnknown: sexUnknown) playerTwo?.setComputedRank(in: tournament) - + playerTwo?.setClubMember(for: tournament) + let players = [playerOne, playerTwo].compactMap({ $0 }) if players.isEmpty == false { let team = TeamHolder(players: players, tournamentCategory: tournamentCategory, tournamentAgeCategory: tournamentAgeCategory, previousTeam: tournament.findTeam(players), tournament: tournament) @@ -368,9 +370,11 @@ class FileImportManager { let playerOne = PlayerRegistration(federalData: Array(result[0...7]), sex: sexPlayerOne, sexUnknown: sexUnknown) playerOne?.setComputedRank(in: tournament) + playerOne?.setClubMember(for: tournament) let playerTwo = PlayerRegistration(federalData: Array(result[8...]), sex: sexPlayerTwo, sexUnknown: sexUnknown) playerTwo?.setComputedRank(in: tournament) - + playerTwo?.setClubMember(for: tournament) + let players = [playerOne, playerTwo].compactMap({ $0 }) if players.isEmpty == false { let team = TeamHolder(players: players, tournamentCategory: tournamentCategory, tournamentAgeCategory: tournamentAgeCategory, previousTeam: tournament.findTeam(players), tournament: tournament) @@ -404,6 +408,7 @@ class FileImportManager { let registeredPlayers = found?.map({ importedPlayer in let player = PlayerRegistration(importedPlayer: importedPlayer) player.setComputedRank(in: tournament) + player.setClubMember(for: tournament) return player }) if let registeredPlayers, registeredPlayers.isEmpty == false { @@ -466,6 +471,7 @@ class FileImportManager { if let found, autoSearch { let player = PlayerRegistration(importedPlayer: found) player.setComputedRank(in: tournament) + player.setClubMember(for: tournament) player.email = email player.phoneNumber = phoneNumber return player diff --git a/PadelClub/Utils/HtmlGenerator.swift b/PadelClub/Utils/HtmlGenerator.swift index a790cb4..656c8aa 100644 --- a/PadelClub/Utils/HtmlGenerator.swift +++ b/PadelClub/Utils/HtmlGenerator.swift @@ -176,7 +176,7 @@ class HtmlGenerator: ObservableObject { func generateLoserBracketHtml(upperRound: Round) -> String { //HtmlService.groupstage(bracket: tournament.orderedBrackets.first!).html() - HtmlService.loserBracket(upperRound: upperRound).html(headName: displayHeads, withRank: displayRank, withTeamIndex: displayTeamIndex, withScore: displayScore) + HtmlService.loserBracket(upperRound: upperRound, hideTitle: false).html(headName: displayHeads, withRank: displayRank, withTeamIndex: displayTeamIndex, withScore: displayScore) } var pdfURL: URL? { diff --git a/PadelClub/Utils/HtmlService.swift b/PadelClub/Utils/HtmlService.swift index dbe7808..da7e2f0 100644 --- a/PadelClub/Utils/HtmlService.swift +++ b/PadelClub/Utils/HtmlService.swift @@ -12,7 +12,7 @@ enum HtmlService { case template(tournament: Tournament) case bracket(round: Round) - case loserBracket(upperRound: Round) + case loserBracket(upperRound: Round, hideTitle: Bool) case match(match: Match) case player(entrant: TeamRegistration) case hiddenPlayer @@ -187,11 +187,17 @@ enum HtmlService { var template = html if let entrantOne = match.team(.one) { template = template.replacingOccurrences(of: "{{entrantOne}}", with: HtmlService.player(entrant: entrantOne).html(headName: headName, withRank: withRank, withTeamIndex: withTeamIndex, withScore: withScore)) + if withScore, let top = match.topPreviousRoundMatch(), top.hasEnded() { + template = template.replacingOccurrences(of: "{{matchDescriptionTop}}", with: [top.scoreLabel(winnerFirst:true)].compactMap({ $0 }).joined(separator: "\n")) + } } else { template = template.replacingOccurrences(of: "{{entrantOne}}", with: HtmlService.hiddenPlayer.html(headName: headName, withRank: withRank, withTeamIndex: withTeamIndex, withScore: withScore)) } if let entrantTwo = match.team(.two) { template = template.replacingOccurrences(of: "{{entrantTwo}}", with: HtmlService.player(entrant: entrantTwo).html(headName: headName, withRank: withRank, withTeamIndex: withTeamIndex, withScore: withScore)) + if withScore, let bottom = match.bottomPreviousRoundMatch(), bottom.hasEnded() { + template = template.replacingOccurrences(of: "{{matchDescriptionBottom}}", with: [bottom.scoreLabel(winnerFirst:true)].compactMap({ $0 }).joined(separator: "\n")) + } } else { template = template.replacingOccurrences(of: "{{entrantTwo}}", with: HtmlService.hiddenPlayer.html(headName: headName, withRank: withRank, withTeamIndex: withTeamIndex, withScore: withScore)) } @@ -206,9 +212,10 @@ enum HtmlService { } else if match.teamWon(atPosition: .two) == true { template = template.replacingOccurrences(of: "{{entrantTwoWon}}", with: "winner") } - template = template.replacingOccurrences(of: "{{matchDescription}}", with: [match.localizedStartDate(), match.scoreLabel()].joined(separator: "\n")) +// template = template.replacingOccurrences(of: "{{matchDescription}}", with: [match.localizedStartDate(), match.scoreLabel()].joined(separator: "\n")) } - template = template.replacingOccurrences(of: "{{matchDescription}}", with: "") + template = template.replacingOccurrences(of: "{{matchDescriptionTop}}", with: "") + template = template.replacingOccurrences(of: "{{matchDescriptionBottom}}", with: "") return template case .bracket(let round): var template = "" @@ -216,16 +223,31 @@ enum HtmlService { for (_, match) in round._matches().enumerated() { template = template.appending(HtmlService.match(match: match).html(headName: headName, withRank: withRank, withTeamIndex: withTeamIndex, withScore: withScore)) } + bracket = html.replacingOccurrences(of: "{{match-template}}", with: template) bracket = bracket.replacingOccurrences(of: "{{roundLabel}}", with: round.roundTitle()) bracket = bracket.replacingOccurrences(of: "{{formatLabel}}", with: round.matchFormat.formatTitle()) return bracket - case .loserBracket(let upperRound): + case .loserBracket(let upperRound, let hideTitle): var template = html + template = template.replacingOccurrences(of: "{{minHeight}}", with: withTeamIndex ? "226" : "156") template = template.replacingOccurrences(of: "{{tournamentTitle}}", with: upperRound.correspondingLoserRoundTitle()) + if let tournamentStartDate = upperRound.initialStartDate()?.localizedDate() { + template = template.replacingOccurrences(of: "{{tournamentStartDate}}", with: tournamentStartDate) + } else { + template = template.replacingOccurrences(of: "{{tournamentStartDate}}", with: "") + } + + template = template.replacingOccurrences(of: "{{titleHidden}}", with: hideTitle ? "hidden" : "") + var brackets = "" for round in upperRound.loserRounds() { brackets = brackets.appending(HtmlService.bracket(round: round).html(headName: headName, withRank: withRank, withTeamIndex: withTeamIndex, withScore: withScore)) + + if round.index == 1 { + let sub = HtmlService.loserBracket(upperRound: round, hideTitle: true).html(headName: headName, withRank: withRank, withTeamIndex: withTeamIndex, withScore: withScore) + template = template.appending(sub) + } } let winnerName = "" let winner = """ @@ -240,6 +262,14 @@ enum HtmlService { brackets = brackets.appending(winner) template = template.replacingOccurrences(of: "{{brackets}}", with: brackets) + + for round in upperRound.loserRounds() { + if round.index > 1 { + let sub = HtmlService.loserBracket(upperRound: round, hideTitle: true).html(headName: headName, withRank: withRank, withTeamIndex: withTeamIndex, withScore: withScore) + template = template.appending(sub) + } + } + return template case .template(let tournament): var template = html diff --git a/PadelClub/Utils/Network/StripeValidationService.swift b/PadelClub/Utils/Network/StripeValidationService.swift index dce0266..e0548da 100644 --- a/PadelClub/Utils/Network/StripeValidationService.swift +++ b/PadelClub/Utils/Network/StripeValidationService.swift @@ -10,12 +10,16 @@ import LeStorage class StripeValidationService { - static func validateStripeAccountID(_ accountID: String) async throws -> ValidationResponse { - let service = try StoreCenter.main.service() + // MARK: - Validate Stripe Account + static func validateStripeAccount(accountId: String) async throws -> ValidationResponse { + let service = try StoreCenter.main.service() var urlRequest = try service._baseRequest(servicePath: "validate-stripe-account/", method: .post, requiresToken: true) - let body = ["account_id": accountID] - urlRequest.httpBody = try JSONEncoder().encode(body) + var body: [String: Any] = [:] + + body["account_id"] = accountId + + urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body) do { let (data, response) = try await URLSession.shared.data(for: urlRequest) @@ -23,17 +27,79 @@ class StripeValidationService { guard let httpResponse = response as? HTTPURLResponse else { throw ValidationError.invalidResponse } + switch httpResponse.statusCode { case 200...299: let decodedResponse = try JSONDecoder().decode(ValidationResponse.self, from: data) return decodedResponse - case 400: - // Handle bad request + case 400, 403, 404: + // Handle client errors - still decode as ValidationResponse let errorResponse = try JSONDecoder().decode(ValidationResponse.self, from: data) return errorResponse - case 403: - // Handle permission error - let errorResponse = try JSONDecoder().decode(ValidationResponse.self, from: data) + default: + throw ValidationError.invalidResponse + } + } catch let error as ValidationError { + throw error + } catch { + throw ValidationError.networkError(error) + } + } + + // MARK: - Create Stripe Connect Account + static func createStripeConnectAccount() async throws -> CreateAccountResponse { + let service = try StoreCenter.main.service() + let urlRequest = try service._baseRequest(servicePath: "stripe/create-account/", method: .post, requiresToken: true) + do { + let (data, response) = try await URLSession.shared.data(for: urlRequest) + + guard let httpResponse = response as? HTTPURLResponse else { + throw ValidationError.invalidResponse + } + + switch httpResponse.statusCode { + case 200...299: + let decodedResponse = try JSONDecoder().decode(CreateAccountResponse.self, from: data) + return decodedResponse + case 400, 403, 404: + let errorResponse = try JSONDecoder().decode(CreateAccountResponse.self, from: data) + return errorResponse + default: + throw ValidationError.invalidResponse + } + } catch let error as ValidationError { + throw error + } catch { + throw ValidationError.networkError(error) + } + } + + // MARK: - Create Stripe Account Link + static func createStripeAccountLink(_ accountId: String? = nil) async throws -> CreateLinkResponse { + let service = try StoreCenter.main.service() + var urlRequest = try service._baseRequest(servicePath: "stripe/create-account-link/", method: .post, requiresToken: true) + + var body: [String: Any] = [:] + + if let accountId = accountId { + body["account_id"] = accountId + } + + urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body) + + do { + let (data, response) = try await URLSession.shared.data(for: urlRequest) + + guard let httpResponse = response as? HTTPURLResponse else { + throw ValidationError.invalidResponse + } + + switch httpResponse.statusCode { + case 200...299: + let decodedResponse = try JSONDecoder().decode(CreateLinkResponse.self, from: data) + return decodedResponse + case 400, 403, 404: + let errorResponse = try JSONDecoder().decode(CreateLinkResponse.self, from: data) return errorResponse default: throw ValidationError.invalidResponse @@ -46,17 +112,67 @@ class StripeValidationService { } } +// MARK: - Response Models + struct ValidationResponse: Codable { let valid: Bool + let canProcessPayments: Bool? + let onboardingComplete: Bool? + let needsOnboarding: Bool? let account: AccountDetails? let error: String? + + enum CodingKeys: String, CodingKey { + case valid + case canProcessPayments = "can_process_payments" + case onboardingComplete = "onboarding_complete" + case needsOnboarding = "needs_onboarding" + case account + case error + } } struct AccountDetails: Codable { let id: String + let chargesEnabled: Bool? + let payoutsEnabled: Bool? + let detailsSubmitted: Bool? enum CodingKeys: String, CodingKey { case id + case chargesEnabled = "charges_enabled" + case payoutsEnabled = "payouts_enabled" + case detailsSubmitted = "details_submitted" + } +} + +struct CreateAccountResponse: Codable { + let success: Bool + let accountId: String? + let message: String? + let existing: Bool? + let error: String? + + enum CodingKeys: String, CodingKey { + case success + case accountId = "account_id" + case message + case existing + case error + } +} + +struct CreateLinkResponse: Codable { + let success: Bool + let url: URL? + let accountId: String? + let error: String? + + enum CodingKeys: String, CodingKey { + case success + case url + case accountId = "account_id" + case error } } @@ -64,5 +180,28 @@ enum ValidationError: Error { case invalidResponse case networkError(Error) case invalidData + case encodingError + case urlNotFound + case accountNotFound + case onlinePaymentNotEnabled + + var localizedDescription: String { + switch self { + case .invalidResponse: + return "Réponse du serveur invalide" + case .networkError(let error): + return "Erreur réseau : \(error.localizedDescription)" + case .invalidData: + return "Données reçues invalides" + case .encodingError: + return "Échec de l'encodage des données de la requête" + case .accountNotFound: + return "Le compte n'a pas pu être généré" + case .urlNotFound: + return "Le lien pour utiliser un compte stripe n'a pas pu être généré" + case .onlinePaymentNotEnabled: + return "Le paiement en ligne n'a pas pu être activé pour ce tournoi" + } + } } diff --git a/PadelClub/Views/Cashier/CashierDetailView.swift b/PadelClub/Views/Cashier/CashierDetailView.swift index cb7ccdd..22bba64 100644 --- a/PadelClub/Views/Cashier/CashierDetailView.swift +++ b/PadelClub/Views/Cashier/CashierDetailView.swift @@ -203,16 +203,14 @@ struct CashierDetailView: View { DisclosureGroup { let selectedPlayers = tournament.selectedPlayers() ForEach(PlayerPaymentType.allCases) { type in - let count = selectedPlayers.filter({ $0.paymentType == type }).count - if count > 0 { + let players = selectedPlayers.filter({ $0.paymentType == type }) + if players.count > 0 { LabeledContent { - if let entryFee = tournament.entryFee { - let sum = Double(count) * entryFee - Text(sum.formatted(.currency(code: Locale.defaultCurrency()))) - } + let sum = players.compactMap({ $0.paidAmount(tournament) }).reduce(0.0, +) + Text(sum.formatted(.currency(code: Locale.defaultCurrency()))) } label: { Text(type.localizedLabel()) - Text(count.formatted()) + Text(players.count.formatted()) } } } diff --git a/PadelClub/Views/Cashier/CashierSettingsView.swift b/PadelClub/Views/Cashier/CashierSettingsView.swift index f97e7ac..01110d4 100644 --- a/PadelClub/Views/Cashier/CashierSettingsView.swift +++ b/PadelClub/Views/Cashier/CashierSettingsView.swift @@ -13,25 +13,50 @@ struct CashierSettingsView: View { @EnvironmentObject var dataStore: DataStore @State private var entryFee: Double? = nil + @State private var clubMemberFeeDeduction: Double? = nil @Bindable var tournament: Tournament @FocusState private var focusedField: Tournament.CodingKeys? let priceTags: [Double] = [15.0, 20.0, 25.0] - + let deductionTags: [Double] = [5.0, 10.0] + init(tournament: Tournament) { self.tournament = tournament _entryFee = State(wrappedValue: tournament.entryFee) + _clubMemberFeeDeduction = State(wrappedValue: tournament.clubMemberFeeDeduction) } var body: some View { List { Section { - TextField(tournament.isFree() ? "Gratuite" : "Inscription", value: $entryFee, format: .currency(code: Locale.defaultCurrency())) - .keyboardType(.decimalPad) - .multilineTextAlignment(.trailing) - .frame(maxWidth: .infinity) - .focused($focusedField, equals: ._entryFee) + LabeledContent { + TextField(tournament.isFree() ? "Gratuite" : "Inscription", value: $entryFee, format: .currency(code: Locale.defaultCurrency())) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .frame(maxWidth: .infinity) + .focused($focusedField, equals: ._entryFee) + } label: { + Text("Frais d'inscription") + } + LabeledContent { + TextField("Réduction", value: $clubMemberFeeDeduction, format: .currency(code: Locale.defaultCurrency())) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .frame(maxWidth: .infinity) + .focused($focusedField, equals: ._clubMemberFeeDeduction) + .onChange(of: focusedField) { + if focusedField == ._clubMemberFeeDeduction { + DispatchQueue.main.async { + UIApplication.shared.sendAction(#selector(UIResponder.selectAll(_:)), to: nil, from: nil, for: nil) + } + } + } + } label: { + Text("Réduction membre") + } + .disabled(tournament.isFree()) + } header: { - Text("Prix de l'inscription") + Text("Frais d'inscription") } footer: { Text("Si vous souhaitez que Padel Club vous aide à suivre les encaissements, indiquer un prix d'inscription. Sinon Padel Club vous aidera à suivre simplement l'arrivée et la présence des joueurs.") } @@ -104,27 +129,43 @@ struct CashierSettingsView: View { ToolbarItem(placement: .keyboard) { HStack { - if tournament.isFree() { - ForEach(priceTags, id: \.self) { priceTag in - Button(priceTag.formatted(.currency(code: Locale.defaultCurrency()))) { - entryFee = priceTag - tournament.entryFee = priceTag + if focusedField == ._entryFee { + if tournament.isFree() { + ForEach(priceTags, id: \.self) { priceTag in + Button(priceTag.formatted(.currency(code: Locale.defaultCurrency()))) { + entryFee = priceTag + tournament.entryFee = priceTag + focusedField = nil + } + .buttonStyle(.bordered) + } + } else { + Button("Gratuit") { + entryFee = nil + tournament.entryFee = nil + focusedField = nil + } + .buttonStyle(.bordered) + + } + } else if focusedField == ._clubMemberFeeDeduction { + ForEach(deductionTags, id: \.self) { deductionTag in + Button(deductionTag.formatted(.currency(code: Locale.defaultCurrency()).precision(.fractionLength(0)))) { + clubMemberFeeDeduction = deductionTag + tournament.clubMemberFeeDeduction = deductionTag focusedField = nil } .buttonStyle(.bordered) } - } else { Button("Gratuit") { - entryFee = nil - tournament.entryFee = nil + clubMemberFeeDeduction = entryFee + tournament.clubMemberFeeDeduction = clubMemberFeeDeduction focusedField = nil } .buttonStyle(.bordered) - } Spacer() Button("Valider") { - tournament.entryFee = entryFee focusedField = nil } .buttonStyle(.bordered) @@ -132,7 +173,14 @@ struct CashierSettingsView: View { } } } - .onChange(of: tournament.entryFee) { + .onChange(of: focusedField) { old, new in + if old == ._entryFee { + tournament.entryFee = entryFee + } else if old == ._clubMemberFeeDeduction { + tournament.clubMemberFeeDeduction = clubMemberFeeDeduction + } + } + .onChange(of: [tournament.entryFee, tournament.clubMemberFeeDeduction]) { _save() } } diff --git a/PadelClub/Views/Cashier/Event/EventCreationView.swift b/PadelClub/Views/Cashier/Event/EventCreationView.swift index fcef799..b63e77b 100644 --- a/PadelClub/Views/Cashier/Event/EventCreationView.swift +++ b/PadelClub/Views/Cashier/Event/EventCreationView.swift @@ -71,6 +71,19 @@ struct EventCreationView: View { .multilineTextAlignment(.leading) .frame(maxWidth: .infinity) .focused($textFieldIsFocus) + .toolbar { + if textFieldIsFocus { + ToolbarItem(placement: .keyboard) { + HStack { + Spacer() + Button("Valider") { + textFieldIsFocus = false + } + .buttonStyle(.bordered) + } + } + } + } LabeledContent { Text(tournaments.count.formatted()) } label: { @@ -93,18 +106,6 @@ struct EventCreationView: View { } } .toolbar { - if textFieldIsFocus { - ToolbarItem(placement: .keyboard) { - HStack { - Spacer() - Button("Valider") { - textFieldIsFocus = false - } - .buttonStyle(.bordered) - } - } - } - ToolbarItem(placement: .cancellationAction) { Button("Annuler", role: .cancel) { dismiss() diff --git a/PadelClub/Views/Cashier/Event/EventSettingsView.swift b/PadelClub/Views/Cashier/Event/EventSettingsView.swift index 49dd216..d75603a 100644 --- a/PadelClub/Views/Cashier/Event/EventSettingsView.swift +++ b/PadelClub/Views/Cashier/Event/EventSettingsView.swift @@ -15,6 +15,7 @@ struct EventSettingsView: View { @State private var eventName: String = "" @State private var pageLink: PageLink = .teams @State private var tournamentInformation: String = "" + @State private var eventStartDate: Date @FocusState private var focusedField: Tournament.CodingKeys? func eventLinksPasteData() -> String { @@ -22,6 +23,20 @@ struct EventSettingsView: View { var link = [String]() link.append(event.eventTitle()) + link.append("\n\n") + + link.append(tournamentInformation) + + link.append("\n\n") + + if let url = event.shareURL() { + var tournamentLink = [String]() + tournamentLink.append("Lien de l'événement") + tournamentLink.append(url.absoluteString) + let eventLink = tournamentLink.joined(separator: "\n") + link.append(eventLink) + } + link.append("\n\n") link.append("Retrouvez toutes les infos en suivant le\(tournaments.count.pluralSuffix) lien\(tournaments.count.pluralSuffix) ci-dessous :") link.append("\n\n") @@ -43,6 +58,7 @@ struct EventSettingsView: View { init(event: Event) { self.event = event _eventName = State(wrappedValue: event.name ?? "") + _eventStartDate = .init(wrappedValue: event.eventStartDate()) _tournamentInformation = State(wrappedValue: event.tournaments.first?.information ?? "") } @@ -67,6 +83,19 @@ struct EventSettingsView: View { } } + Section { + DatePicker(selection: $eventStartDate) { + Text(eventStartDate.formatted(.dateTime.weekday(.wide)).capitalized).lineLimit(1) + } + .onChange(of: eventStartDate) { + event.tournaments.forEach { tournament in + tournament.startDate = eventStartDate + } + + dataStore.tournaments.addOrUpdate(contentOfs: event.tournaments) + } + } + if event.tournaments.first?.dayDuration == 3, event.tournaments.count == 3 { Section { RowButtonView("Répartir les tournois") { @@ -99,7 +128,9 @@ struct EventSettingsView: View { } footer: { Text("Ce texte sera indiqué dans le champ information de tous les tournois de l'événement") } - + + _message(eventPasteMessage: _eventPasteMessage()) + if event.club != nil { let eventLinksPasteData = eventLinksPasteData() Section { @@ -135,12 +166,8 @@ struct EventSettingsView: View { }) .toolbarBackground(.visible, for: .navigationBar) .toolbar { - if let tenupId = event.tenupId { - ToolbarItem(placement: .topBarTrailing) { - Link(destination: URL(string:"https://tenup.fft.fr/tournoi/\(tenupId)")!) { - Text("Tenup") - } - } + ToolbarItem(placement: .topBarTrailing) { + _linkLabel() } if focusedField != nil { @@ -185,6 +212,59 @@ struct EventSettingsView: View { } } + private func _eventPasteMessage() -> String { + var paste = [String]() + if let name = event.name { + paste.append(name) + paste.append("\n") + } + + paste.append(event.formattedDateInterval()) + paste.append("\n") + + paste.append(tournamentInformation) + paste.append("\n") + + if let url = event.shareURL() { + paste.append(url.absoluteString) + paste.append("\n") + } + + return paste.joined(separator: "\n") + } + + + private func _message(eventPasteMessage: String) -> some View { + Section { + Text(eventPasteMessage).foregroundStyle(.secondary) + } header: { + Text("Message à partager") + } footer: { + HStack { + CopyPasteButtonView(pasteValue: eventPasteMessage) + Spacer() + ShareLink(item: eventPasteMessage) + } + } + } + + private func _linkLabel() -> some View { + Menu { + if let url = event.shareURL() { + ShareLink(item: url) { + Text("Lien de l'événement sur Padel Club") + } + } + if let tenupId = event.tenupId { + ShareLink(item: URL(string:"https://tenup.fft.fr/tournoi/\(tenupId)")!) { + Text("Tenup") + } + } + } label: { + Text("Liens") + } + } + private func _save() { dataStore.events.addOrUpdate(instance: event) } diff --git a/PadelClub/Views/Cashier/Event/EventTournamentsView.swift b/PadelClub/Views/Cashier/Event/EventTournamentsView.swift index 3901c77..3924aab 100644 --- a/PadelClub/Views/Cashier/Event/EventTournamentsView.swift +++ b/PadelClub/Views/Cashier/Event/EventTournamentsView.swift @@ -44,19 +44,68 @@ struct EventTournamentsView: View { } } footer: { if event.tournaments.count > 1 { - if mainTournament == nil { - FooterButtonView("c'est le tournoi principal") { - self.mainTournament = tournament - } - } else if mainTournament == tournament { - FooterButtonView("ce n'est pas le tournoi principal") { - self.mainTournament = tournament + if let mainTournament, mainTournament == tournament { + Menu { + Button("Formats") { + tournaments.forEach { tournament in + tournament.groupStageMatchFormat = mainTournament.groupStageMatchFormat + tournament.loserBracketMatchFormat = mainTournament.loserBracketMatchFormat + tournament.matchFormat = mainTournament.matchFormat + } + dataStore.tournaments.addOrUpdate(contentOfs: tournaments) + } + + Button("Infos JAP") { + tournaments.forEach { tournament in + tournament.setupUmpireSettings(defaultTournament: mainTournament) + } + dataStore.tournaments.addOrUpdate(contentOfs: tournaments) + } + + Button("Réglages Inscriptions") { + tournaments.forEach { tournament in + tournament.setupRegistrationSettings(templateTournament: mainTournament) + } + dataStore.tournaments.addOrUpdate(contentOfs: tournaments) + } + } label: { + Text("Copier des réglages sur les autres tournois") + .underline() + .multilineTextAlignment(.leading) } - } else if let mainTournament { - FooterButtonView("coller les réglages du tournoi principal") { - tournament.setupUmpireSettings(defaultTournament: mainTournament) - tournament.setupRegistrationSettings(templateTournament: mainTournament) - dataStore.tournaments.addOrUpdate(instance: tournament) + + } else { + Menu { + if tournament != self.mainTournament { + Button("Définir comme tournoi principal") { + self.mainTournament = tournament + } + } + + if let mainTournament { + + Divider() + + Button("Copier les formats du tournoi principal") { + tournament.groupStageMatchFormat = mainTournament.groupStageMatchFormat + tournament.loserBracketMatchFormat = mainTournament.loserBracketMatchFormat + tournament.matchFormat = mainTournament.matchFormat + dataStore.tournaments.addOrUpdate(instance: tournament) + } + + Button("Copier les infos JAP du tournoi principal") { + tournament.setupUmpireSettings(defaultTournament: mainTournament) + dataStore.tournaments.addOrUpdate(instance: tournament) + } + + Button("Copier les réglages des inscriptions du tournoi principal") { + tournament.setupRegistrationSettings(templateTournament: mainTournament) + dataStore.tournaments.addOrUpdate(instance: tournament) + } + } + } label: { + Text("Options rapides pour certains réglages") + .underline() } } } diff --git a/PadelClub/Views/Cashier/Event/EventView.swift b/PadelClub/Views/Cashier/Event/EventView.swift index 99ae840..365442d 100644 --- a/PadelClub/Views/Cashier/Event/EventView.swift +++ b/PadelClub/Views/Cashier/Event/EventView.swift @@ -18,6 +18,7 @@ enum EventDestination: Identifiable, Selectable, Equatable { case links case tournaments(Event) case cashier + case eventPlanning var id: String { return String(describing: self) @@ -33,6 +34,8 @@ enum EventDestination: Identifiable, Selectable, Equatable { return "Tournois" case .cashier: return "Finance" + case .eventPlanning: + return "Planning" } } @@ -42,7 +45,7 @@ enum EventDestination: Identifiable, Selectable, Equatable { return nil case .tournaments(let event): return event.tournaments.count - case .cashier: + case .cashier, .eventPlanning: return nil } } @@ -77,7 +80,7 @@ struct EventView: View { } func allDestinations() -> [EventDestination] { - [.club(event), .tournaments(event), .cashier] + [.club(event), .eventPlanning, .tournaments(event), .cashier] } var body: some View { @@ -90,6 +93,10 @@ struct EventView: View { switch selectedEventDestination { case .club(let event): EventClubSettingsView(event: event) + case .eventPlanning: + let allMatches = event.tournaments.flatMap { $0.allMatches() } + PlanningView(matches: allMatches, selectedScheduleDestination: .constant(nil)) + .environment(\.matchViewStyle, .feedStyle) case .links: EventLinksView(event: event) case .tournaments(let event): diff --git a/PadelClub/Views/Club/ClubDetailView.swift b/PadelClub/Views/Club/ClubDetailView.swift index 8fa26c2..1c0f9eb 100644 --- a/PadelClub/Views/Club/ClubDetailView.swift +++ b/PadelClub/Views/Club/ClubDetailView.swift @@ -245,10 +245,10 @@ struct ClubDetailView: View { CourtView(court: court) } .onChange(of: zipCode) { - club.zipCode = zipCode + club.zipCode = zipCode.prefixTrimmed(10) } .onChange(of: city) { - club.city = city + club.city = city.prefixTrimmed(100) } .onDisappear { if displayContext == .edition && clubDeleted == false { diff --git a/PadelClub/Views/Club/CourtView.swift b/PadelClub/Views/Club/CourtView.swift index bb1b3dd..6de7b16 100644 --- a/PadelClub/Views/Club/CourtView.swift +++ b/PadelClub/Views/Club/CourtView.swift @@ -44,7 +44,7 @@ struct CourtView: View { } } } label: { - Text("Nom du terrain") + Text("Nom de la piste") } } footer: { if court.name?.isEmpty == false { @@ -65,7 +65,7 @@ struct CourtView: View { Text("Sortie autorisée") } Toggle(isOn: $court.indoor) { - Text("Terrain intérieur") + Text("Piste intérieur") } } } diff --git a/PadelClub/Views/Club/Shared/ClubCourtSetupView.swift b/PadelClub/Views/Club/Shared/ClubCourtSetupView.swift index 540f829..bbb6ef9 100644 --- a/PadelClub/Views/Club/Shared/ClubCourtSetupView.swift +++ b/PadelClub/Views/Club/Shared/ClubCourtSetupView.swift @@ -19,7 +19,7 @@ struct ClubCourtSetupView: View { @ViewBuilder var body: some View { Section { - TournamentFieldsManagerView(localizedStringKey: "Terrains du club", count: $club.courtCount) + TournamentFieldsManagerView(localizedStringKey: "Pistes du club", count: $club.courtCount) .disabled(displayContext == .lockedForEditing) .onChange(of: club.courtCount) { if displayContext != .addition { @@ -53,7 +53,7 @@ struct ClubCourtSetupView: View { _courtView(atIndex: courtIndex, tournamentClub: club) } } header: { - Text("Nom des terrains") + Text("Nom des pistes") } footer: { if displayContext == .lockedForEditing && hideLockForEditingMessage == false { Text("Édition impossible, vous n'êtes pas le créateur de ce club.").foregroundStyle(.logoRed) diff --git a/PadelClub/Views/GroupStage/Components/GroupStageSettingsView.swift b/PadelClub/Views/GroupStage/Components/GroupStageSettingsView.swift index 9a84d8c..cd6afdd 100644 --- a/PadelClub/Views/GroupStage/Components/GroupStageSettingsView.swift +++ b/PadelClub/Views/GroupStage/Components/GroupStageSettingsView.swift @@ -64,7 +64,7 @@ struct GroupStageSettingsView: View { } Section { - CourtPicker(title: "Terrain dédié", selection: $courtIndex, maxCourt: tournament.courtCount) + CourtPicker(title: "Piste dédié", selection: $courtIndex, maxCourt: tournament.courtCount) RowButtonView("Confirmer", role: .destructive) { groupStage.matches().forEach { match in match.setCourt(courtIndex) @@ -179,7 +179,7 @@ struct GroupStageSettingsView: View { } Section { - RowButtonView("Rafraichir", role: .destructive) { + RowButtonView("Rafraîchir", role: .destructive) { let playedMatches = groupStage.playedMatches() playedMatches.forEach { match in match.updateTeamScores() diff --git a/PadelClub/Views/GroupStage/GroupStageView.swift b/PadelClub/Views/GroupStage/GroupStageView.swift index a85d47d..e88126e 100644 --- a/PadelClub/Views/GroupStage/GroupStageView.swift +++ b/PadelClub/Views/GroupStage/GroupStageView.swift @@ -241,7 +241,7 @@ struct GroupStageView: View { VStack(alignment: .leading, spacing: 0) { Text("#\(index + 1)") .font(.caption) - TeamPickerView(groupStagePosition: index, matchTypeContext: .groupStage, teamPicked: { team in + TeamPickerView(groupStagePosition: index, pickTypeContext: .groupStage, teamPicked: { team in print(team.pasteData()) team.groupStage = groupStage.id team.groupStagePosition = index diff --git a/PadelClub/Views/GroupStage/GroupStagesSettingsView.swift b/PadelClub/Views/GroupStage/GroupStagesSettingsView.swift index a9cd798..2ee131b 100644 --- a/PadelClub/Views/GroupStage/GroupStagesSettingsView.swift +++ b/PadelClub/Views/GroupStage/GroupStagesSettingsView.swift @@ -78,7 +78,7 @@ struct GroupStagesSettingsView: View { Section { if tournament.groupStageLoserBracket() == nil { RowButtonView("Ajouter des matchs de classements", role: .destructive) { - let round = Round(tournament: tournament.id, index: 0, matchFormat: tournament.loserRoundFormat, groupStageLoserBracket: true) + let round = Round(tournament: tournament.id, index: 0, format: tournament.loserRoundFormat, groupStageLoserBracket: true) do { try tournamentStore?.rounds.addOrUpdate(instance: round) diff --git a/PadelClub/Views/Match/MatchDetailView.swift b/PadelClub/Views/Match/MatchDetailView.swift index 36a3755..467c9f3 100644 --- a/PadelClub/Views/Match/MatchDetailView.swift +++ b/PadelClub/Views/Match/MatchDetailView.swift @@ -332,7 +332,7 @@ struct MatchDetailView: View { match.removeCourt() save() } label: { - Text("Supprimer le terrain") + Text("Supprimer la piste") } } Button(role: .destructive) { @@ -412,7 +412,7 @@ struct MatchDetailView: View { } } label: { VStack(alignment: .leading) { - Text("terrain").font(.footnote).foregroundStyle(.secondary) + Text("piste").font(.footnote).foregroundStyle(.secondary) if let courtName = match.courtName() { Text(courtName) .foregroundStyle(Color.master) @@ -479,7 +479,7 @@ struct MatchDetailView: View { DisclosureGroup(isExpanded: $isEditing) { startingOptionView } label: { - Text("Modifier l'horaire et le terrain") + Text("Modifier l'horaire et la piste") } } @@ -543,7 +543,7 @@ struct MatchDetailView: View { } } } label: { - Text("Terrain") + Text("Piste") } .onChange(of: fieldSetup) { if let courtIndex = fieldSetup.courtIndex { diff --git a/PadelClub/Views/Match/MatchSetupView.swift b/PadelClub/Views/Match/MatchSetupView.swift index 97094b7..67cd78c 100644 --- a/PadelClub/Views/Match/MatchSetupView.swift +++ b/PadelClub/Views/Match/MatchSetupView.swift @@ -64,7 +64,7 @@ struct MatchSetupView: View { } HStack { let luckyLosers = walkOutSpot ? match.luckyLosers() : [] - TeamPickerView(shouldConfirm: shouldConfirm, round: match.roundObject, matchTypeContext: matchTypeContext, luckyLosers: luckyLosers, teamPicked: { team in + 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) diff --git a/PadelClub/Views/Navigation/Agenda/TournamentLookUpView.swift b/PadelClub/Views/Navigation/Agenda/TournamentLookUpView.swift index 7cecadc..5f9410f 100644 --- a/PadelClub/Views/Navigation/Agenda/TournamentLookUpView.swift +++ b/PadelClub/Views/Navigation/Agenda/TournamentLookUpView.swift @@ -133,6 +133,9 @@ struct TournamentLookUpView: View { Menu { #if DEBUG if tournaments.isEmpty == false { + Button("Gather Mobile Phone") { + _gatherNumbers() + } Section { ShareLink(item: pastedTournaments) { Label("Par texte", systemImage: "square.and.arrow.up") @@ -180,6 +183,20 @@ struct TournamentLookUpView: View { private var liguesFound: [String] { Set(tournaments.compactMap { $0.nomLigue }).sorted() } + + private func _gatherNumbers() { + Task { + print("Doing.....") + for i in 0.. 0 { @@ -136,7 +135,7 @@ struct PlanningByCourtView: View { ContentUnavailableView { Label("Aucun match planifié", systemImage: "clock.badge.questionmark") } description: { - Text("Aucun match n'a été planifié sur ce terrain et au jour sélectionné") + Text("Aucun match n'a été planifié sur cette piste et au jour sélectionné") } actions: { } } @@ -144,7 +143,7 @@ struct PlanningByCourtView: View { ContentUnavailableView { Label("Aucun match planifié", systemImage: "clock.badge.questionmark") } description: { - Text("Aucun match n'a été planifié sur ce terrain et au jour sélectionné") + Text("Aucun match n'a été planifié sur cette piste et au jour sélectionné") } actions: { } } diff --git a/PadelClub/Views/Planning/PlanningSettingsView.swift b/PadelClub/Views/Planning/PlanningSettingsView.swift index 2d56d71..db24db4 100644 --- a/PadelClub/Views/Planning/PlanningSettingsView.swift +++ b/PadelClub/Views/Planning/PlanningSettingsView.swift @@ -63,7 +63,7 @@ struct PlanningSettingsView: View { Text("\(tournament.dayDuration) jour" + tournament.dayDuration.pluralSuffix) } - TournamentFieldsManagerView(localizedStringKey: "Terrains maximum", count: $tournament.courtCount) + TournamentFieldsManagerView(localizedStringKey: "Pistes maximum", count: $tournament.courtCount) if let event = tournament.eventObject() { NavigationLink { @@ -85,7 +85,7 @@ struct PlanningSettingsView: View { LabeledContent { Text(matchScheduler.courtsAvailable.count.formatted() + "/" + tournament.courtCount.formatted()) } label: { - Text("Sélection des terrains") + Text("Sélection des pistes") if matchScheduler.courtsAvailable.count > tournament.courtCount { Text("Attention !") .tint(.red) @@ -97,7 +97,7 @@ struct PlanningSettingsView: View { if tournament.courtCount < club.courtCount { let plural = tournament.courtCount.pluralSuffix let verb = tournament.courtCount > 1 ? "seront" : "sera" - Text("En réduisant les terrains maximum, seul\(plural) le\(plural) \(tournament.courtCount) premier\(plural) terrain\(plural) \(verb) utilisé\(plural)") + Text(", par contre, si vous gardez le nombre de terrains du club, vous pourrez plutôt préciser quel terrain n'est pas disponible.") + Text("En réduisant les pistes maximum, seule\(plural) le\(plural) \(tournament.courtCount) première\(plural) piste\(plural) \(verb) utilisée\(plural)") + Text(", par contre, si vous gardez le nombre de pistes du club, vous pourrez plutôt préciser quelle piste n'est pas disponible.") } else if tournament.courtCount > club.courtCount { let isCreatedByUser = club.hasBeenCreated(by: StoreCenter.main.userId) Button { @@ -109,10 +109,10 @@ struct PlanningSettingsView: View { } } label: { if isCreatedByUser { - Text("Vous avez indiqué plus de terrains dans ce tournoi que dans le club. ") + Text("Vous avez indiqué plus de pistes dans ce tournoi que dans le club. ") + Text("Mettre à jour le club ?").underline().foregroundStyle(.master) } else { - Label("Vous avez indiqué plus de terrains dans ce tournoi que dans le club.", systemImage: "exclamationmark.triangle.fill").foregroundStyle(.logoRed) + Label("Vous avez indiqué plus de pistes dans ce tournoi que dans le club.", systemImage: "exclamationmark.triangle.fill").foregroundStyle(.logoRed) } } .buttonStyle(.plain) @@ -122,7 +122,7 @@ struct PlanningSettingsView: View { } if issueFound { - Text("Padel Club n'a pas réussi à définir un horaire pour tous les matchs de ce tournoi, à cause de la programmation d'autres tournois ou de l'indisponibilité des terrains.") + Text("Padel Club n'a pas réussi à définir un horaire pour tous les matchs de ce tournoi, à cause de la programmation d'autres tournois ou de l'indisponibilité des pistes.") .foregroundStyle(.logoRed) } @@ -141,7 +141,7 @@ struct PlanningSettingsView: View { } } footer: { if let event, event.tournaments.count > 1 { - Text("Cette option fait en sorte qu'un terrain pris par un match d'un autre tournoi de cet événement soit toujours considéré comme libre.") + Text("Cette option fait en sorte qu'une piste prise par un match d'un autre tournoi de cet événement soit toujours considéré comme libre.") } } @@ -421,13 +421,13 @@ struct PlanningSettingsView: View { Section { Toggle(isOn: $matchScheduler.randomizeCourts) { - Text("Distribuer les terrains au hasard") + Text("Distribuer les pistes au hasard") } } Section { Toggle(isOn: $matchScheduler.shouldTryToFillUpCourtsAvailable) { - Text("Remplir au maximum les terrains d'une rotation") + Text("Remplir au maximum les pistes d'une rotation") } } footer: { Text("Tout en tenant compte de l'option ci-dessous, Padel Club essaiera de remplir les créneaux à chaque rotation.") @@ -438,7 +438,7 @@ struct PlanningSettingsView: View { Text("Équilibrer les matchs d'une manche") } } footer: { - Text("Cette option permet de programmer une manche sur plusieurs rotation de manière équilibrée dans le cas où il y a plus de matchs à jouer dans cette manche que de terrains.") + Text("Cette option permet de programmer une manche sur plusieurs rotation de manière équilibrée dans le cas où il y a plus de matchs à jouer dans cette manche que de pistes.") } Section { @@ -462,6 +462,15 @@ struct PlanningSettingsView: View { } header: { Text("Classement") } + + Section { + Toggle(isOn: $matchScheduler.accountGroupStageBreakTime) { + Text("Tenir compte des temps de pause réglementaires") + } + } header: { + Text("Poule") + } + Section { Toggle(isOn: $matchScheduler.rotationDifferenceIsImportant) { @@ -469,18 +478,27 @@ struct PlanningSettingsView: View { } LabeledContent { - StepperView(count: $matchScheduler.upperBracketRotationDifference, minimum: 0, maximum: 2) + StepperView(count: $matchScheduler.upperBracketRotationDifference, minimum: 0) } label: { Text("Tableau") } .disabled(matchScheduler.rotationDifferenceIsImportant == false) LabeledContent { - StepperView(count: $matchScheduler.loserBracketRotationDifference, minimum: 0, maximum: 2) + StepperView(count: $matchScheduler.loserBracketRotationDifference, minimum: 0) } label: { Text("Classement") } .disabled(matchScheduler.rotationDifferenceIsImportant == false) + + LabeledContent { + StepperView(count: $matchScheduler.groupStageRotationDifference, minimum: 0) + } label: { + Text("Poule") + } + .disabled(matchScheduler.rotationDifferenceIsImportant == false) + + } footer: { Text("Cette option ajoute du temps entre 2 rotations, permettant ainsi de mieux configurer plusieurs tournois se déroulant en même temps.") } @@ -517,83 +535,11 @@ struct PlanningSettingsView: View { } private func _groupMatchesByDay(matches: [Match]) -> [Date: [Match]] { - var matchesByDay = [Date: [Match]]() - let calendar = Calendar.current - - for match in matches { - // Extract day/month/year and create a date with only these components - let components = calendar.dateComponents([.year, .month, .day], from: match.computedStartDateForSorting) - let strippedDate = calendar.date(from: components)! - - // Group matches by the strippedDate (only day/month/year) - if matchesByDay[strippedDate] == nil { - matchesByDay[strippedDate] = [] - } - - let shouldIncludeMatch: Bool - switch match.matchType { - case .groupStage: - shouldIncludeMatch = !matchesByDay[strippedDate]!.filter { $0.groupStage != nil }.compactMap { $0.groupStage }.contains(match.groupStage!) - case .bracket: - shouldIncludeMatch = !matchesByDay[strippedDate]!.filter { $0.round != nil }.compactMap { $0.round }.contains(match.round!) - case .loserBracket: - shouldIncludeMatch = true - } - - if shouldIncludeMatch { - matchesByDay[strippedDate]!.append(match) - } - } - - return matchesByDay + tournament.groupMatchesByDay(matches: matches) } private func _matchCountPerDay(matchesByDay: [Date: [Match]], tournament: Tournament) -> [Date: NSCountedSet] { - let days = matchesByDay.keys - var matchCountPerDay = [Date: NSCountedSet]() - - for day in days { - if let matches = matchesByDay[day] { - var groupStageCount = 0 - let countedSet = NSCountedSet() - - for match in matches { - switch match.matchType { - case .groupStage: - if let groupStage = match.groupStageObject { - if groupStageCount < groupStage.size - 1 { - groupStageCount = groupStage.size - 1 - } - } - case .bracket: - countedSet.add(match.matchFormat) - case .loserBracket: - break - } - } - - if groupStageCount > 0 { - for _ in 0.. some View { diff --git a/PadelClub/Views/Planning/PlanningView.swift b/PadelClub/Views/Planning/PlanningView.swift index b72441d..0aa4a35 100644 --- a/PadelClub/Views/Planning/PlanningView.swift +++ b/PadelClub/Views/Planning/PlanningView.swift @@ -5,45 +5,45 @@ // Created by Razmig Sarkissian on 07/04/2024. // -import SwiftUI import LeStorage -import TipKit import PadelClubData +import SwiftUI +import TipKit struct PlanningView: View { - + @EnvironmentObject var dataStore: DataStore - @Environment(Tournament.self) var tournament: Tournament @State private var selectedDay: Date? @Binding var selectedScheduleDestination: ScheduleDestination? @State private var filterOption: PlanningFilterOption = .byDefault @State private var showFinishedMatches: Bool = false @State private var enableMove: Bool = false - + @Environment(\.editMode) private var editMode + let allMatches: [Match] let timeSlotMoveOptionTip = TimeSlotMoveOptionTip() - + init(matches: [Match], selectedScheduleDestination: Binding) { self.allMatches = matches _selectedScheduleDestination = selectedScheduleDestination } - + var matches: [Match] { allMatches.filter({ showFinishedMatches || $0.endDate == nil }) } - - var timeSlots: [Date:[Match]] { - Dictionary(grouping: matches) { $0.startDate ?? .distantFuture } + + var timeSlots: [Date: [Match]] { + Dictionary(grouping: matches) { $0.plannedStartDate ?? $0.startDate ?? .distantFuture } } - - func days(timeSlots: [Date:[Match]]) -> [Date] { + + func days(timeSlots: [Date: [Match]]) -> [Date] { Set(timeSlots.keys.map { $0.startOfDay }).sorted() } - - func keys(timeSlots: [Date:[Match]]) -> [Date] { + + func keys(timeSlots: [Date: [Match]]) -> [Date] { timeSlots.keys.sorted() } - + private func _computedTitle(days: [Date]) -> String { if let selectedDay { return selectedDay.formatted(.dateTime.day().weekday().month()) @@ -55,6 +55,23 @@ struct PlanningView: View { } } } + + private func _confirmationMode() -> Bool { + enableMove || editMode?.wrappedValue == .active + } + + private var enableEditionBinding: Binding { + Binding { + editMode?.wrappedValue == .active + } set: { value in + if value { + editMode?.wrappedValue = .active + } else { + editMode?.wrappedValue = .inactive + } + } + + } var body: some View { let timeSlots = self.timeSlots @@ -62,119 +79,146 @@ struct PlanningView: View { let days = self.days(timeSlots: timeSlots) let matches = matches let notSlots = matches.allSatisfy({ $0.startDate == nil }) - BySlotView(days: days, keys: keys, timeSlots: timeSlots, matches: matches, selectedDay: selectedDay) - .environment(\.filterOption, filterOption) - .environment(\.showFinishedMatches, showFinishedMatches) - .environment(\.enableMove, enableMove) - .navigationTitle(Text(_computedTitle(days: days))) - .navigationBarBackButtonHidden(enableMove) - .toolbar(content: { - if days.count > 1 { - ToolbarTitleMenu { - Picker(selection: $selectedDay) { - Text("Tous les jours").tag(nil as Date?) - ForEach(days, id: \.self) { day in - if day.monthYearFormatted == Date.distantFuture.monthYearFormatted { - Text("Sans horaire").tag(day as Date?) - } else { - Text(day.formatted(.dateTime.day().weekday().month())).tag(day as Date?) - } + BySlotView( + days: days, keys: keys, timeSlots: timeSlots, matches: matches, selectedDay: selectedDay + ) + .environment(\.filterOption, filterOption) + .environment(\.showFinishedMatches, showFinishedMatches) + .environment(\.enableMove, enableMove) + .navigationTitle(Text(_computedTitle(days: days))) + .navigationBarBackButtonHidden(_confirmationMode()) + .toolbar(content: { + if days.count > 1 { + ToolbarTitleMenu { + Picker(selection: $selectedDay) { + Text("Tous les jours").tag(nil as Date?) + ForEach(days, id: \.self) { day in + if day.monthYearFormatted == Date.distantFuture.monthYearFormatted { + Text("Sans horaire").tag(day as Date?) + } else { + Text(day.formatted(.dateTime.day().weekday().month())).tag( + day as Date?) } - } label: { - Text("Jour") } - .pickerStyle(.automatic) - .disabled(enableMove) + } label: { + Text("Jour") } + .pickerStyle(.automatic) + .disabled(_confirmationMode()) } - - if enableMove { - ToolbarItem(placement: .topBarLeading) { - Button("Annuler") { - enableMove = false - } + } + + if _confirmationMode() { + ToolbarItem(placement: .topBarLeading) { + Button("Annuler") { + enableMove = false + enableEditionBinding.wrappedValue = false } - - ToolbarItem(placement: .topBarTrailing) { + } + if enableMove { + ToolbarItemGroup(placement: .topBarTrailing) { Button("Sauver") { - do { - try self.tournament.tournamentStore?.matches.addOrUpdate(contentOfs: allMatches) - } catch { - Logger.error(error) + let groupByTournaments = allMatches.grouped { match in + match.currentTournament() } - + groupByTournaments.forEach { tournament, matches in + tournament?.tournamentStore?.matches.addOrUpdate(contentOfs: matches) + } + enableMove = false } } - - } else { - - ToolbarItemGroup(placement: .topBarTrailing) { - if notSlots == false { + } + } else { + if notSlots == false { + ToolbarItemGroup(placement: .bottomBar) { + HStack { + CourtOptionsView(timeSlots: timeSlots, underlined: false) + Spacer() Toggle(isOn: $enableMove) { - Label("Déplacer", systemImage: "rectangle.2.swap") + Label { + Text("Déplacer") + } icon: { + Image(systemName: "rectangle.2.swap") + } } .popoverTip(timeSlotMoveOptionTip) + .disabled(_confirmationMode()) + Spacer() + Toggle(isOn: enableEditionBinding) { + Text("Modifier") + } + .disabled(_confirmationMode()) } - - Menu { - Section { - Picker(selection: $showFinishedMatches) { - Text("Afficher tous les matchs").tag(true) - Text("Masquer les matchs terminés").tag(false) - } label: { - Text("Option de filtrage") - } - .labelsHidden() - .pickerStyle(.inline) - } header: { + } + } + ToolbarItemGroup(placement: .topBarTrailing) { + Menu { + Section { + Picker(selection: $showFinishedMatches) { + Text("Afficher tous les matchs").tag(true) + Text("Masquer les matchs terminés").tag(false) + } label: { Text("Option de filtrage") } - - Divider() - - Section { - Picker(selection: $filterOption) { - ForEach(PlanningFilterOption.allCases) { - Text($0.localizedPlanningLabel()).tag($0) - } - } label: { - Text("Option de triage") + .labelsHidden() + .pickerStyle(.inline) + } header: { + Text("Option de filtrage") + } + + Divider() + + Section { + Picker(selection: $filterOption) { + ForEach(PlanningFilterOption.allCases) { + Text($0.localizedPlanningLabel()).tag($0) } - .labelsHidden() - .pickerStyle(.inline) - } header: { + } label: { Text("Option de triage") - } - } label: { - Label("Trier", systemImage: "line.3.horizontal.decrease.circle") - .symbolVariant(filterOption == .byCourt || showFinishedMatches ? .fill : .none) + .labelsHidden() + .pickerStyle(.inline) + } header: { + Text("Option de triage") + } - + } label: { + Label("Trier", systemImage: "line.3.horizontal.decrease.circle") + .symbolVariant( + filterOption == .byCourt || showFinishedMatches ? .fill : .none) } + } - }) - .overlay { - if notSlots { - ContentUnavailableView { - Label("Aucun horaire défini", systemImage: "clock.badge.questionmark") - } description: { - Text("Vous n'avez pas encore défini d'horaire pour les différentes phases du tournoi") - } actions: { + } + }) + .overlay { + if notSlots { + ContentUnavailableView { + Label("Aucun horaire défini", systemImage: "clock.badge.questionmark") + } description: { + Text( + "Vous n'avez pas encore défini d'horaire pour les différentes phases du tournoi" + ) + } actions: { + if selectedScheduleDestination != nil { RowButtonView("Horaire intelligent") { selectedScheduleDestination = nil } } } } + } } - + struct BySlotView: View { - @Environment(Tournament.self) var tournament: Tournament @Environment(\.filterOption) private var filterOption @Environment(\.showFinishedMatches) private var showFinishedMatches @Environment(\.enableMove) private var enableMove + @Environment(\.editMode) private var editMode + @State private var selectedIds = Set() + @State private var showDateUpdateView: Bool = false + @State private var dateToUpdate: Date = Date() let days: [Date] let keys: [Date] @@ -184,15 +228,15 @@ struct PlanningView: View { let timeSlotMoveTip = TimeSlotMoveTip() var body: some View { - List { - + List(selection: $selectedIds) { if enableMove { TipView(timeSlotMoveTip) .tipStyle(tint: .logoYellow, asSection: true) } - + if !matches.allSatisfy({ $0.startDate == nil }) { - ForEach(days.filter({ selectedDay == nil || selectedDay == $0 }), id: \.self) { day in + ForEach(days.filter({ selectedDay == nil || selectedDay == $0 }), id: \.self) { + day in DaySectionView( day: day, keys: keys.filter({ $0.dayInt == day.dayInt }), @@ -202,15 +246,116 @@ struct PlanningView: View { } } } + .toolbar(content: { + if editMode?.wrappedValue == .active { + ToolbarItem(placement: .bottomBar) { + Button { + showDateUpdateView = true + } label: { + Text("Modifier la date des matchs sélectionnés") + } + .disabled(selectedIds.isEmpty) + } + } + }) + .sheet(isPresented: $showDateUpdateView, onDismiss: { + selectedIds.removeAll() + }) { + let selectedMatches = matches.filter({ selectedIds.contains($0.stringId) }) + DateUpdateView(selectedMatches: selectedMatches) + } } } + + struct DateUpdateView: View { + @Environment(\.dismiss) var dismiss + + let selectedMatches: [Match] + let selectedFormats: [MatchFormat] + @State private var dateToUpdate: Date + + init(selectedMatches: [Match]) { + self.selectedMatches = selectedMatches + self.selectedFormats = Array(Set(selectedMatches.map({ match in + match.matchFormat + }))) + _dateToUpdate = .init(wrappedValue: selectedMatches.first?.plannedStartDate ?? selectedMatches.first?.startDate ?? Date()) + } + + var body: some View { + NavigationStack { + List { + Section { + DatePicker(selection: $dateToUpdate) { + Text(dateToUpdate.formatted(.dateTime.weekday(.wide))).font(.headline) + } + } + + Section { + DateAdjusterView(date: $dateToUpdate) + DateAdjusterView(date: $dateToUpdate, time: 10) + ForEach(selectedFormats, id: \.self) { matchFormat in + DateAdjusterView(date: $dateToUpdate, matchFormat: matchFormat) + } + } + + Section { + ForEach(selectedMatches) { match in + MatchRowView(match: match) + } + } header: { + Text("Matchs à modifier") + } + + } + .navigationTitle("Modification de la date") + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(.visible, for: .navigationBar) + .toolbar(content: { + ToolbarItem(placement: .topBarLeading) { + Button("Annuler", role: .cancel) { + dismiss() + } + } + ToolbarItem(placement: .topBarTrailing) { + Button("Valider") { + _updateDate() + } + } + }) + } + } + + private func _updateDate() { + selectedMatches.forEach { match in + if match.hasStarted() || match.hasEnded() { + match.plannedStartDate = dateToUpdate + } else { + let hasStarted = match.currentTournament()?.hasStarted() == true + match.startDate = dateToUpdate + if hasStarted { + match.plannedStartDate = dateToUpdate + } + } + } + + let groupByTournaments = selectedMatches.grouped { match in + match.currentTournament() + } + groupByTournaments.forEach { tournament, matches in + tournament?.tournamentStore?.matches.addOrUpdate(contentOfs: matches) + } + dismiss() + } + + } struct DaySectionView: View { - @Environment(Tournament.self) var tournament: Tournament @Environment(\.filterOption) private var filterOption @Environment(\.showFinishedMatches) private var showFinishedMatches @Environment(\.enableMove) private var enableMove + @Environment(\.editMode) private var editMode let day: Date let keys: [Date] @@ -222,31 +367,40 @@ struct PlanningView: View { ForEach(keys, id: \.self) { key in TimeSlotSectionView( key: key, - matches: timeSlots[key]?.sorted(by: filterOption == .byDefault ? \.computedOrder : \.courtIndexForSorting) ?? [] + matches: timeSlots[key]?.sorted( + by: filterOption == .byDefault + ? \.computedOrder : \.courtIndexForSorting) ?? [] ) } .onMove(perform: enableMove ? moveSection : nil) } header: { HeaderView(day: day, timeSlots: timeSlots) } footer: { - if day.monthYearFormatted == Date.distantFuture.monthYearFormatted { - Text("Il s'agit des matchs qui n'ont pas réussi à être placé par Padel Club. Peut-être à cause de créneaux indisponibles, d'autres tournois ou des réglages.") + VStack(alignment: .leading) { + if day.monthYearFormatted == Date.distantFuture.monthYearFormatted { + Text( + "Il s'agit des matchs qui n'ont pas réussi à être placé par Padel Club. Peut-être à cause de créneaux indisponibles, d'autres tournois ou des réglages." + ) + } + + CourtOptionsView(timeSlots: timeSlots, underlined: true) } } } - + func moveSection(from source: IndexSet, to destination: Int) { let daySlots = keys.filter { $0.dayInt == day.dayInt }.sorted() - + guard let sourceIdx = source.first, sourceIdx < daySlots.count, - destination <= daySlots.count else { + destination <= daySlots.count + else { return } - - // Create a mutable copy of the time slots for this day + + // Create a mutable copy of the time slots for this day var slotsToUpdate = daySlots - + let updateRange = min(sourceIdx, destination)...max(sourceIdx, destination) // Perform the move in the array @@ -256,7 +410,7 @@ struct PlanningView: View { } else { slotsToUpdate.insert(sourceTime, at: destination) } - + // Update matches by swapping their startDates for index in updateRange { // Find the new time slot for these matches @@ -264,7 +418,7 @@ struct PlanningView: View { guard let newStartTime = daySlots[safe: index] else { continue } guard let matchesToUpdate = timeSlots[oldStartTime] else { continue } - // Update each match with the new start time + // Update each match with the new start time for match in matchesToUpdate { match.startDate = newStartTime } @@ -272,22 +426,47 @@ struct PlanningView: View { } } - struct TimeSlotSectionView: View { @Environment(\.enableMove) private var enableMove + @Environment(\.editMode) private var editMode let key: Date let matches: [Match] - + + @State private var isExpanded: Bool = false + @State private var showDateUpdateView: Bool = false + var body: some View { if !matches.isEmpty { if enableMove { TimeSlotHeaderView(key: key, matches: matches) } else { - DisclosureGroup { + DisclosureGroup(isExpanded: $isExpanded) { MatchListView(matches: matches) } label: { TimeSlotHeaderView(key: key, matches: matches) } + .contextMenu { + PlanningView.CourtOptionsView(timeSlots: [key: matches], underlined: false) + + Button { + showDateUpdateView = true + } label: { + Text("Modifier la date") + } + + } + .sheet(isPresented: $showDateUpdateView, onDismiss: { + }) { + PlanningView.DateUpdateView(selectedMatches: matches) + } + +// .onChange(of: editMode?.wrappedValue) { +// if editMode?.wrappedValue == .active, isExpanded == false { +// isExpanded = true +// } else if editMode?.wrappedValue == .inactive, isExpanded == true { +// isExpanded = false +// } +// } } } } @@ -297,7 +476,7 @@ struct PlanningView: View { let matches: [Match] var body: some View { - ForEach(matches) { match in + ForEach(matches, id: \.stringId) { match in NavigationLink { MatchDetailView(match: match) .matchViewStyle(.sectionedStandardStyle) @@ -309,25 +488,36 @@ struct PlanningView: View { } struct MatchRowView: View { + @Environment(\.matchViewStyle) private var matchViewStyle let match: Match var body: some View { LabeledContent { - if let courtName = match.courtName() { - Text(courtName) + VStack(alignment: .center) { + if let courtName = match.courtName() { + Text(courtName).foregroundStyle(.primary) + } + Text(match.matchFormat.shortFormat + " (~" + match.getDuration().formatted() + "m)").font(.footnote).foregroundStyle(.tertiary) } } label: { if let groupStage = match.groupStageObject { Text(groupStage.groupStageTitle(.title)) + Text(match.matchTitle()) } else if let round = match.roundObject { Text(round.roundTitle()) + if round.index > 0 { + Text(match.matchTitle()) + } } - Text(match.matchTitle()) + if matchViewStyle == .feedStyle, let tournament = match.currentTournament() { + Text(tournament.tournamentTitle()) + } + + Text(match.startDate?.formattedAsHourMinute() ?? "--") } } } - struct HeaderView: View { @Environment(\.filterOption) private var filterOption @Environment(\.showFinishedMatches) private var showFinishedMatches @@ -365,7 +555,6 @@ struct PlanningView: View { struct TimeSlotHeaderView: View { let key: Date let matches: [Match] - @Environment(Tournament.self) var tournament: Tournament var body: some View { LabeledContent { @@ -378,187 +567,261 @@ struct PlanningView: View { .font(.title) .fontWeight(.semibold) } + + let names = matches.sorted(by: \.computedOrder) + .compactMap({ $0.roundTitle() }) + .reduce(into: [String]()) { uniqueNames, name in + if !uniqueNames.contains(name) { + uniqueNames.append(name) + } + } + Text(names.joined(separator: ", ")).lineLimit(1).truncationMode(.tail) + // if matches.count <= matches.first?.courtCount() ?? { + // } else { + // Text(matches.count.formatted().appending(" matchs")) + // } + + } + } + } + + struct CourtOptionsView: View { + let timeSlots: [Date: [Match]] + let underlined: Bool + var allMatches: [Match] { + timeSlots.flatMap { $0.value } + } + + private func _removeCourts() { + allMatches.forEach { match in + match.courtIndex = nil + } + } + + private func _eventCourtCount() -> Int { timeSlots.first?.value.first?.currentTournament()?.eventObject()?.eventCourtCount() ?? 2 + } + + private func _save() { + let groupByTournaments = allMatches.grouped { match in + match.currentTournament() + } + groupByTournaments.forEach { tournament, matches in + tournament?.tournamentStore?.matches.addOrUpdate(contentOfs: matches) + } + } + + var body: some View { + Menu { + Button("Supprimer") { + _removeCourts() + _save() + } - if matches.count <= tournament.courtCount { - let names = matches.sorted(by: \.computedOrder) - .compactMap({ $0.roundTitle() }) - .reduce(into: [String]()) { uniqueNames, name in - if !uniqueNames.contains(name) { - uniqueNames.append(name) + 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() { + match.courtIndex = rand + courtsAvailable.remove(elements: [rand]) } } - Text(names.joined(separator: ", ")).lineLimit(1).truncationMode(.tail) - } else { - Text(matches.count.formatted().appending(" matchs")) + } + _save() + } + Button("Fixer par ordre croissant") { + _removeCourts() + + let eventCourtCount = _eventCourtCount() + + for slot in timeSlots { + var courtsAvailable = Array(0.. Int { -// timeSlots.filter { $0.key.dayInt == dayInt }.flatMap({ $0.value }).count -// } -// -// private func _timeSlotView(key: Date, matches: [Match]) -> some View { -// LabeledContent { -// Text(self._formattedMatchCount(matches.count)) -// } label: { -// if key.monthYearFormatted == Date.distantFuture.monthYearFormatted { -// Text("Aucun horaire") -// } else { -// Text(key.formatted(date: .omitted, time: .shortened)).font(.title).fontWeight(.semibold) -// } -// if matches.count <= tournament.courtCount { -// let names = matches.sorted(by: \.computedOrder) -// .compactMap({ $0.roundTitle() }) -// .reduce(into: [String]()) { uniqueNames, name in -// if !uniqueNames.contains(name) { -// uniqueNames.append(name) -// } -// } -// Text(names.joined(separator: ", ")).lineLimit(1).truncationMode(.tail) -// } else { -// Text(matches.count.formatted().appending(" matchs")) -// } -// } -// } -// -// fileprivate func _formattedMatchCount(_ count: Int) -> String { -// return "\(count.formatted()) match\(count.pluralSuffix)" -// } -// } + // struct BySlotView: View { + // @Environment(Tournament.self) var tournament: Tournament + // let days: [Date] + // let keys: [Date] + // let timeSlots: [Date:[Match]] + // let matches: [Match] + // let selectedDay: Date? + // let filterOption: PlanningFilterOption + // let showFinishedMatches: Bool + // + // var body: some View { + // List { + // if matches.allSatisfy({ $0.startDate == nil }) == false { + // ForEach(days.filter({ selectedDay == nil || selectedDay == $0 }), id: \.self) { day in + // Section { + // ForEach(keys.filter({ $0.dayInt == day.dayInt }), id: \.self) { key in + // if let _matches = timeSlots[key]?.sorted(by: filterOption == .byDefault ? \.computedOrder : \.courtIndexForSorting) { + // DisclosureGroup { + // ForEach(_matches) { match in + // NavigationLink { + // MatchDetailView(match: match) + // .matchViewStyle(.sectionedStandardStyle) + // + // } label: { + // LabeledContent { + // if let courtName = match.courtName() { + // Text(courtName) + // } + // } label: { + // if let groupStage = match.groupStageObject { + // Text(groupStage.groupStageTitle(.title)) + // } else if let round = match.roundObject { + // Text(round.roundTitle()) + // } + // Text(match.matchTitle()) + // } + // } + // } + // } label: { + // _timeSlotView(key: key, matches: _matches) + // } + // } + // } + // .onMove(perform: moveSection) + // } header: { + // HStack { + // if day.monthYearFormatted == Date.distantFuture.monthYearFormatted { + // Text("Sans horaire") + // } else { + // Text(day.formatted(.dateTime.day().weekday().month())) + // } + // Spacer() + // let count = _matchesCount(inDayInt: day.dayInt, timeSlots: timeSlots) + // if showFinishedMatches { + // Text(self._formattedMatchCount(count)) + // } else { + // Text(self._formattedMatchCount(count) + " restant\(count.pluralSuffix)") + // } + // } + // } footer: { + // if day.monthYearFormatted == Date.distantFuture.monthYearFormatted { + // Text("Il s'agit des matchs qui n'ont pas réussi à être placé par Padel Club. Peut-être à cause de créneaux indisponibles, d'autres tournois ou des réglages.") + // } + // } + // .headerProminence(.increased) + // } + // } + // } + // } + // + // func moveSection(from source: IndexSet, to destination: Int) { + // let daySlots = keys.filter { selectedDay == nil || $0.dayInt == selectedDay?.dayInt }.sorted() + // + // guard let sourceIdx = source.first, + // sourceIdx < daySlots.count, + // destination <= daySlots.count else { + // return + // } + // + // // Create a mutable copy of the time slots for this day + // var slotsToUpdate = daySlots + // + // let updateRange = min(sourceIdx, destination)...max(sourceIdx, destination) - 1 + // print(updateRange) + // + // // Perform the move in the array + // let sourceTime = slotsToUpdate.remove(at: sourceIdx) + // if sourceIdx < destination { + // slotsToUpdate.insert(sourceTime, at: destination - 1) + // } else { + // slotsToUpdate.insert(sourceTime, at: destination) + // } + // + // // Update matches by swapping their startDates + // for index in updateRange { + // // Find the new time slot for these matches + // let oldStartTime = slotsToUpdate[index] + // let newStartTime = daySlots[index] + // guard let matchesToUpdate = timeSlots[oldStartTime] else { continue } + // print("moving", oldStartTime, "to", newStartTime) + // + // // Update each match with the new start time + // for match in matchesToUpdate { + // match.startDate = newStartTime + // } + // } + // + // try? self.tournament.tournamentStore?.matches.addOrUpdate(contentOfs: matches) + // } + // + // + // private func _matchesCount(inDayInt dayInt: Int, timeSlots: [Date:[Match]]) -> Int { + // timeSlots.filter { $0.key.dayInt == dayInt }.flatMap({ $0.value }).count + // } + // + // private func _timeSlotView(key: Date, matches: [Match]) -> some View { + // LabeledContent { + // Text(self._formattedMatchCount(matches.count)) + // } label: { + // if key.monthYearFormatted == Date.distantFuture.monthYearFormatted { + // Text("Aucun horaire") + // } else { + // Text(key.formatted(date: .omitted, time: .shortened)).font(.title).fontWeight(.semibold) + // } + // if matches.count <= tournament.courtCount { + // let names = matches.sorted(by: \.computedOrder) + // .compactMap({ $0.roundTitle() }) + // .reduce(into: [String]()) { uniqueNames, name in + // if !uniqueNames.contains(name) { + // uniqueNames.append(name) + // } + // } + // Text(names.joined(separator: ", ")).lineLimit(1).truncationMode(.tail) + // } else { + // Text(matches.count.formatted().appending(" matchs")) + // } + // } + // } + // + // fileprivate func _formattedMatchCount(_ count: Int) -> String { + // return "\(count.formatted()) match\(count.pluralSuffix)" + // } + // } } enum PlanningFilterOption: Int, CaseIterable, Identifiable { var id: Int { self.rawValue } - + case byDefault case byCourt - + func localizedPlanningLabel() -> String { switch self { case .byCourt: - return "Par terrain" + return "Par piste" case .byDefault: return "Par ordre des matchs" } } } - struct FilterOptionKey: EnvironmentKey { static let defaultValue: PlanningFilterOption = .byDefault } diff --git a/PadelClub/Views/Player/PlayerDetailView.swift b/PadelClub/Views/Player/PlayerDetailView.swift index 9a07ef1..7bc6597 100644 --- a/PadelClub/Views/Player/PlayerDetailView.swift +++ b/PadelClub/Views/Player/PlayerDetailView.swift @@ -80,6 +80,10 @@ struct PlayerDetailView: View { Toggle("Joueur sur place", isOn: $player.hasArrived) Toggle("Capitaine", isOn: $player.captain).disabled(player.hasPaidOnline()) //Toggle("Coach", isOn: $player.coach) + + Toggle(isOn: $player.clubMember) { + Text("Membre du club") + } } Section { @@ -256,7 +260,7 @@ struct PlayerDetailView: View { // } // } } - .onChange(of: [player.hasArrived, player.captain, player.coach]) { + .onChange(of: [player.hasArrived, player.captain, player.coach, player.clubMember]) { _save() } .onChange(of: player.sex) { diff --git a/PadelClub/Views/Round/LoserRoundView.swift b/PadelClub/Views/Round/LoserRoundView.swift index dce0ef7..235c7c1 100644 --- a/PadelClub/Views/Round/LoserRoundView.swift +++ b/PadelClub/Views/Round/LoserRoundView.swift @@ -48,6 +48,7 @@ struct LoserRoundView: View { if isEditingTournamentSeed.wrappedValue == true { RowButtonView(match.disabled ? "Jouer ce match" : "Ne pas jouer ce match", role: .destructive) { match._toggleMatchDisableState(!match.disabled, single: true) + loserBracket.updateEnabledMatches() } } } diff --git a/PadelClub/Views/Round/LoserRoundsView.swift b/PadelClub/Views/Round/LoserRoundsView.swift index fd2e837..53179cd 100644 --- a/PadelClub/Views/Round/LoserRoundsView.swift +++ b/PadelClub/Views/Round/LoserRoundsView.swift @@ -13,7 +13,7 @@ class UpperRound: Identifiable, Selectable { let round: Round var loserRounds: [LoserRound] = [] let title: String - let playedMatches: [Match] + var playedMatches: [Match] var correspondingLoserRoundTitle: String init(round: Round) { diff --git a/PadelClub/Views/Round/RoundView.swift b/PadelClub/Views/Round/RoundView.swift index 1f8892d..b97768b 100644 --- a/PadelClub/Views/Round/RoundView.swift +++ b/PadelClub/Views/Round/RoundView.swift @@ -27,6 +27,10 @@ struct RoundView: View { var upperRound: UpperRound + func _refreshRound() { + self.upperRound.playedMatches = self.upperRound.round.playedMatches() + } + init(upperRound: UpperRound) { self.upperRound = upperRound // let seeds = upperRound.round.seeds() @@ -135,6 +139,22 @@ struct RoundView: View { } } } else { + + let seeds = upperRound.round.seeds() + if upperRound.round.seeds().isEmpty == false { + RowButtonView("Retirer les têtes de séries", role: .destructive) { + seeds.forEach { tr in + tr.bracketPosition = nil + } + + let teamScores = upperRound.playedMatches.flatMap { match in + match.teamScores + } + tournamentStore?.teamScores.delete(contentOfs: teamScores) + tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: seeds) + } + } + let isRoundValidForSeeding = tournament.isRoundValidForSeeding(roundIndex: upperRound.round.index) let availableSeeds = tournament.availableSeeds() let availableQualifiedTeams = tournament.availableQualifiedTeams() @@ -252,6 +272,9 @@ struct RoundView: View { } } } + .onAppear(perform: { + self._refreshRound() + }) .task { await MainActor.run { let seeds = self.upperRound.round.seeds() diff --git a/PadelClub/Views/Score/FollowUpMatchView.swift b/PadelClub/Views/Score/FollowUpMatchView.swift index 9e0fdab..2b65381 100644 --- a/PadelClub/Views/Score/FollowUpMatchView.swift +++ b/PadelClub/Views/Score/FollowUpMatchView.swift @@ -69,7 +69,7 @@ struct FollowUpMatchView: View { case .index: return "Ordre prévu" case .court: - return "Terrain" + return "Piste" case .restingTime: return "Temps de repos" case .winner: @@ -285,7 +285,7 @@ struct FollowUpMatchView: View { } } } label: { - Text("Sur le terrain") + Text("Sur la piste") } .labelsHidden() .underline() diff --git a/PadelClub/Views/Shared/PaymentInfoSheetView.swift b/PadelClub/Views/Shared/PaymentInfoSheetView.swift index 4f4a440..7c0f297 100644 --- a/PadelClub/Views/Shared/PaymentInfoSheetView.swift +++ b/PadelClub/Views/Shared/PaymentInfoSheetView.swift @@ -29,29 +29,23 @@ struct PaymentInfoSheetView: View { - Tous les paiements sont traités via Stripe, une plateforme sécurisée de paiement en ligne Remboursements : - - Les remboursements peuvent être activés ou désactivés par l'organisateur + - Les remboursements en ligne peuvent être activés ou désactivés par l'organisateur - Si activés, une date limite de remboursement peut être définie - - Aucun remboursement n'est possible après cette date limite - - Les remboursements sont automatiquement traités via la même méthode de paiement utilisée + - Aucun remboursement en ligne n'est possible après cette date limite + - Les remboursements en ligne sont automatiquement traités via la même méthode de paiement utilisée Commissions et frais : - Padel Club prélève une commission de \(stripePlatformFee)% sur chaque transaction - - Cette commission couvre les frais de service et de maintenance de la plateforme - Des frais supplémentaires de Stripe s'appliquent (\(stripePercentageFee)% + \(stripeFixedFee)€ par transaction) - - Le montant total des frais est indiqué clairement avant validation du paiement Exigences pour les organisateurs : - L'organisateur doit avoir un compte Stripe valide pour recevoir les paiements - Le compte Stripe doit être vérifié et connecté à Padel Club - Sans compte Stripe connecté, l'option de paiement en ligne ne peut pas être activée - - Les fonds sont directement versés sur le compte bancaire associé au compte Stripe de l'organisateur Sécurité : - Toutes les transactions sont sécurisées et chiffrées - Padel Club ne stocke pas les informations de carte bancaire - - La conformité RGPD et PCI-DSS est assurée par Stripe - - En cas de problème avec un paiement, veuillez contacter l'organisateur du tournoi ou le support Padel Club. """ } diff --git a/PadelClub/Views/Team/EditingTeamView.swift b/PadelClub/Views/Team/EditingTeamView.swift index 236ecfe..4efa7eb 100644 --- a/PadelClub/Views/Team/EditingTeamView.swift +++ b/PadelClub/Views/Team/EditingTeamView.swift @@ -29,6 +29,8 @@ struct EditingTeamView: View { @State private var isProcessingRefund = false @State private var refundMessage: String? @State private var registrationDateModified: Date + @State private var uniqueRandomIndex: Int + var messageSentFailed: Binding { Binding { @@ -48,6 +50,7 @@ struct EditingTeamView: View { return registrationDate != team.registrationDate + || uniqueRandomIndex != team.uniqueRandomIndex || walkOut != team.walkOut || wildCardBracket != team.wildCardBracket || wildCardGroupStage != team.wildCardGroupStage @@ -69,6 +72,7 @@ struct EditingTeamView: View { _walkOut = State(wrappedValue: team.walkOut) _wildCardBracket = State(wrappedValue: team.wildCardBracket) _wildCardGroupStage = State(wrappedValue: team.wildCardGroupStage) + _uniqueRandomIndex = .init(wrappedValue: team.uniqueRandomIndex) } private func _resetTeam() { @@ -77,6 +81,7 @@ struct EditingTeamView: View { team.wildCardGroupStage = false team.walkOut = false team.wildCardBracket = false + team.uniqueRandomIndex = 0 } var body: some View { @@ -96,12 +101,22 @@ struct EditingTeamView: View { HStack { CopyPasteButtonView(pasteValue: team.playersPasteData()) Spacer() - NavigationLink { - GroupStageTeamReplacementView(team: team) - .environment(tournament) - } label: { - Text("Chercher à remplacer") - .underline() + if team.isWildCard(), team.unsortedPlayers().isEmpty { + TeamPickerView(pickTypeContext: .wildcard) { teamregistration in + teamregistration.wildCardBracket = team.wildCardBracket + teamregistration.wildCardGroupStage = team.wildCardGroupStage + tournament.tournamentStore?.teamRegistrations.addOrUpdate(instance: teamregistration) + tournament.tournamentStore?.teamRegistrations.delete(instance: team) + dismiss() + } + } else { + NavigationLink { + GroupStageTeamReplacementView(team: team) + .environment(tournament) + } label: { + Text("Chercher à remplacer") + .underline() + } } } } @@ -173,15 +188,18 @@ struct EditingTeamView: View { DatePicker(selection: $registrationDateModified) { if registrationDate != registrationDateModified { HStack { - FooterButtonView("Valider") { + Button("Valider", systemImage: "checkmark.circle") { registrationDate = registrationDateModified } + .tint(.green) Divider() - FooterButtonView("Annuler", role: .cancel) { + Button("Annuler", systemImage: "xmark.circle", role: .cancel) { registrationDateModified = registrationDate } - .foregroundStyle(.blue) + .tint(.logoRed) } + .labelStyle(.iconOnly) + .buttonStyle(.borderedProminent) } else { Text("Inscription") Text(registrationDateModified.localizedWeekDay().capitalized) @@ -206,6 +224,16 @@ struct EditingTeamView: View { } } + Section { + LabeledContent { + StepperView(count: $uniqueRandomIndex, minimum: 0) + } label: { + Text("Ordre à poids de paire égal") + } + } footer: { + Text("Si plusieurs équipes ont le même poids et que leur position est tiré au sort, ce champ permet de les positionner correctement dans l'ordre croissant.") + } + Section { HStack { TextField("Nom de l'équipe", text: $name) @@ -287,6 +315,7 @@ struct EditingTeamView: View { team.wildCardBracket = wildCardBracket team.wildCardGroupStage = wildCardGroupStage team.walkOut = walkOut + team.uniqueRandomIndex = uniqueRandomIndex _save() } @@ -295,6 +324,7 @@ struct EditingTeamView: View { walkOut = team.walkOut wildCardBracket = team.wildCardBracket wildCardGroupStage = team.wildCardGroupStage + uniqueRandomIndex = team.uniqueRandomIndex } }, message: { Text("Ce changement peut entraîner l'entrée ou la sortie d'une équipe de votre sélection. Padel Club préviendra automatiquement une équipe inscrite en ligne de son nouveau statut.") @@ -394,6 +424,12 @@ struct EditingTeamView: View { } } } + .onChange(of: uniqueRandomIndex) { + if canSaveWithoutWarning() { + team.uniqueRandomIndex = uniqueRandomIndex + _save() + } + } .onChange(of: [walkOut, wildCardBracket, wildCardGroupStage]) { if canSaveWithoutWarning() { if walkOut == false && team.walkOut == true { diff --git a/PadelClub/Views/Team/TeamPickerView.swift b/PadelClub/Views/Team/TeamPickerView.swift index 15282da..f8003a6 100644 --- a/PadelClub/Views/Team/TeamPickerView.swift +++ b/PadelClub/Views/Team/TeamPickerView.swift @@ -8,6 +8,13 @@ import SwiftUI import PadelClubData +public enum TeamPickType: String { + case bracket = "bracket" + case groupStage = "groupStage" + case loserBracket = "loserBracket" + case wildcard = "wildcard" +} + struct TeamPickerView: View { @EnvironmentObject var dataStore: DataStore @Environment(Tournament.self) var tournament: Tournament @@ -20,7 +27,7 @@ struct TeamPickerView: View { var shouldConfirm: Bool = false var groupStagePosition: Int? = nil var round: Round? = nil - var matchTypeContext: MatchType = .bracket + var pickTypeContext: TeamPickType = .bracket var luckyLosers: [TeamRegistration] = [] let teamPicked: ((TeamRegistration) -> (Void)) @@ -31,21 +38,34 @@ struct TeamPickerView: View { } } + var wording: String { + switch pickTypeContext { + case .bracket: + return "Choisir" + case .groupStage: + return "Choisir" + case .loserBracket: + return "Choisir" + case .wildcard: + return "Choisir la wildcard" + } + } + var body: some View { ConfirmButtonView(shouldConfirm: shouldConfirm, message: MatchSetupView.confirmationMessage) { presentTeamPickerView = true } label: { - Text("Choisir") + Text(wording) .underline() } .sheet(isPresented: $presentTeamPickerView) { NavigationStack { List { - if matchTypeContext == .loserBracket, let losers = round?.parentRound?.losers() { + if pickTypeContext == .loserBracket, let losers = round?.parentRound?.losers() { _sectionView(losers.sorted(by: \.weight, order: sortOrder), title: "Perdant du tour précédent") } - if matchTypeContext == .loserBracket, let losers = round?.previousRound()?.winners() { + if pickTypeContext == .loserBracket, let losers = round?.previousRound()?.winners() { _sectionView(losers.sorted(by: \.weight, order: sortOrder), title: "Gagnant du tour précédent") } @@ -63,7 +83,11 @@ struct TeamPickerView: View { let teams = tournament.selectedSortedTeams() - if matchTypeContext == .loserBracket { + if pickTypeContext == .wildcard { + _sectionView(tournament.waitingListSortedTeams(selectedSortedTeams: teams).sorted(by: \.weight, order: sortOrder), title: "Liste d'attente") + } + + if pickTypeContext == .loserBracket { _sectionView(teams.filter({ $0.inGroupStage() && $0.qualified == false }).sorted(by: \.weight, order: sortOrder), title: "Non qualifié de poules") } @@ -145,7 +169,7 @@ struct TeamPickerView: View { .frame(maxWidth: .infinity) .buttonStyle(.plain) .id(team.id) - .listRowView(isActive: matchTypeContext == .loserBracket && round?.teams().map({ $0.id }).contains(team.id) == true, color: .green, hideColorVariation: true) + .listRowView(isActive: pickTypeContext == .loserBracket && round?.teams().map({ $0.id }).contains(team.id) == true, color: .green, hideColorVariation: true) // .confirmationDialog("Attention", isPresented: confirmationRequest, titleVisibility: .visible) { // Button("Retirer du tableau", role: .destructive) { // teamPicked(confirmTeam!) diff --git a/PadelClub/Views/Tournament/Screen/AddTeamView.swift b/PadelClub/Views/Tournament/Screen/AddTeamView.swift index 382fbed..66f6a1a 100644 --- a/PadelClub/Views/Tournament/Screen/AddTeamView.swift +++ b/PadelClub/Views/Tournament/Screen/AddTeamView.swift @@ -155,6 +155,7 @@ struct AddTeamView: View { players.forEach { player in let newPlayer = PlayerRegistration(importedPlayer: player) newPlayer.setComputedRank(in: tournament) + newPlayer.setClubMember(for: tournament) createdPlayers = Set() createdPlayerIds = Set() createdPlayers.insert(newPlayer) @@ -176,6 +177,7 @@ struct AddTeamView: View { players.forEach { player in let newPlayer = PlayerRegistration(importedPlayer: player) newPlayer.setComputedRank(in: tournament) + newPlayer.setClubMember(for: tournament) createdPlayers.insert(newPlayer) createdPlayerIds.insert(newPlayer.id) } @@ -183,6 +185,7 @@ struct AddTeamView: View { searchViewModel.selectedPlayers.forEach { player in let newPlayer = PlayerRegistration(importedPlayer: player) newPlayer.setComputedRank(in: tournament) + newPlayer.setClubMember(for: tournament) createdPlayers.insert(newPlayer) createdPlayerIds.insert(newPlayer.id) } @@ -336,6 +339,7 @@ struct AddTeamView: View { }.forEach { player in let player = PlayerRegistration(importedPlayer: player) player.setComputedRank(in: tournament) + player.setClubMember(for: tournament) currentSelection.insert(player) } diff --git a/PadelClub/Views/Tournament/Screen/BroadcastView.swift b/PadelClub/Views/Tournament/Screen/BroadcastView.swift index 54eb097..5382d73 100644 --- a/PadelClub/Views/Tournament/Screen/BroadcastView.swift +++ b/PadelClub/Views/Tournament/Screen/BroadcastView.swift @@ -321,6 +321,9 @@ struct BroadcastView: View { Section { let club = tournament.club() actionForURL(title: (club == nil) ? "Aucun club indiqué pour ce tournoi" : club!.clubTitle(), description: "Page du club", url: club?.shareURL()) + if let event = tournament.eventObject() { + actionForURL(title: event.eventTitle(), description: "Page de l'évémement", url: event.shareURL()) + } actionForURL(title: "Padel Club", url: URLs.main.url) } header: { Text("Autres liens") diff --git a/PadelClub/Views/Tournament/Screen/Components/TournamentClubSettingsView.swift b/PadelClub/Views/Tournament/Screen/Components/TournamentClubSettingsView.swift index 6d63c78..ce67e4b 100644 --- a/PadelClub/Views/Tournament/Screen/Components/TournamentClubSettingsView.swift +++ b/PadelClub/Views/Tournament/Screen/Components/TournamentClubSettingsView.swift @@ -22,7 +22,7 @@ struct TournamentClubSettingsView: View { let selectedClub = event?.clubObject() Section { - TournamentFieldsManagerView(localizedStringKey: "Terrains pour le tournoi", count: $tournament.courtCount) + TournamentFieldsManagerView(localizedStringKey: "Pistes pour le tournoi", count: $tournament.courtCount) .onChange(of: tournament.courtCount) { do { try dataStore.tournaments.addOrUpdate(instance: tournament) @@ -36,7 +36,7 @@ struct TournamentClubSettingsView: View { CourtAvailabilitySettingsView(event: event) .environment(tournament) } label: { - Text("Indisponibilités des terrains") + Text("Indisponibilités des pistes") } } } footer: { @@ -44,7 +44,7 @@ struct TournamentClubSettingsView: View { if tournament.courtCount < club.courtCount { let plural = tournament.courtCount.pluralSuffix let verb = tournament.courtCount > 1 ? "seront" : "sera" - Text("En réduisant les terrains maximum, seul\(plural) le\(plural) \(tournament.courtCount) premier\(plural) terrain\(plural) \(verb) utilisé\(plural)") + Text(", par contre, si vous gardez le nombre de terrains du club, vous pourrez plutôt préciser quel terrain n'est pas disponible.") + Text("En réduisant les pistes maximum, seule\(plural) le\(plural) \(tournament.courtCount) première\(plural) piste\(plural) \(verb) utilisée\(plural)") + Text(", par contre, si vous gardez le nombre de pistes du club, vous pourrez plutôt préciser quelle piste n'est pas disponible.") } else if tournament.courtCount > club.courtCount { let isCreatedByUser = club.hasBeenCreated(by: StoreCenter.main.userId) Button { @@ -56,10 +56,10 @@ struct TournamentClubSettingsView: View { } } label: { if isCreatedByUser { - Text("Vous avez indiqué plus de terrains dans ce tournoi que dans le club.") + Text("Vous avez indiqué plus de pistes dans ce tournoi que dans le club.") + Text("Mettre à jour le club ?").underline().foregroundStyle(.master) } else { - Label("Vous avez indiqué plus de terrains dans ce tournoi que dans le club.", systemImage: "exclamationmark.triangle.fill").foregroundStyle(.logoRed) + Label("Vous avez indiqué plus de pistes dans ce tournoi que dans le club.", systemImage: "exclamationmark.triangle.fill").foregroundStyle(.logoRed) } } .buttonStyle(.plain) diff --git a/PadelClub/Views/Tournament/Screen/Components/TournamentGeneralSettingsView.swift b/PadelClub/Views/Tournament/Screen/Components/TournamentGeneralSettingsView.swift index 0c40db0..66d7de0 100644 --- a/PadelClub/Views/Tournament/Screen/Components/TournamentGeneralSettingsView.swift +++ b/PadelClub/Views/Tournament/Screen/Components/TournamentGeneralSettingsView.swift @@ -16,6 +16,7 @@ struct TournamentGeneralSettingsView: View { @State private var tournamentName: String = "" @State private var tournamentInformation: String = "" @State private var entryFee: Double? = nil + @State private var clubMemberFeeDeduction: Double? = nil @State private var umpireCustomMail: String @State private var umpireCustomPhone: String @State private var umpireCustomContact: String @@ -24,12 +25,14 @@ struct TournamentGeneralSettingsView: View { @FocusState private var focusedField: Tournament.CodingKeys? let priceTags: [Double] = [15.0, 20.0, 25.0] + let deductionTags: [Double] = [5.0, 10.0] init(tournament: Tournament) { self.tournament = tournament _tournamentName = State(wrappedValue: tournament.name ?? "") _tournamentInformation = State(wrappedValue: tournament.information ?? "") _entryFee = State(wrappedValue: tournament.entryFee) + _clubMemberFeeDeduction = State(wrappedValue: tournament.clubMemberFeeDeduction) _umpireCustomMail = State(wrappedValue: tournament.umpireCustomMail ?? "") _umpireCustomPhone = State(wrappedValue: tournament.umpireCustomPhone ?? "") _umpireCustomContact = State(wrappedValue: tournament.umpireCustomContact ?? "") @@ -58,6 +61,24 @@ struct TournamentGeneralSettingsView: View { } label: { Text("Inscription") } + + LabeledContent { + TextField("Réduction", value: $clubMemberFeeDeduction, format: .currency(code: Locale.defaultCurrency())) + .keyboardType(.decimalPad) + .multilineTextAlignment(.trailing) + .frame(maxWidth: .infinity) + .focused($focusedField, equals: ._clubMemberFeeDeduction) + .onChange(of: focusedField) { + if focusedField == ._clubMemberFeeDeduction { + DispatchQueue.main.async { + UIApplication.shared.sendAction(#selector(UIResponder.selectAll(_:)), to: nil, from: nil, for: nil) + } + } + } + } label: { + Text("Réduction membre") + } + .disabled(tournament.isFree()) } footer: { Text("Si vous souhaitez que Padel Club vous aide à suivre les encaissements, indiquer un prix d'inscription. Sinon Padel Club vous aidera à suivre simplement l'arrivée et la présence des joueurs.") } @@ -172,6 +193,21 @@ struct TournamentGeneralSettingsView: View { .buttonStyle(.bordered) } + } else if focusedField == ._clubMemberFeeDeduction { + ForEach(deductionTags, id: \.self) { deductionTag in + Button(deductionTag.formatted(.currency(code: Locale.defaultCurrency()).precision(.fractionLength(0)))) { + clubMemberFeeDeduction = deductionTag + tournament.clubMemberFeeDeduction = deductionTag + focusedField = nil + } + .buttonStyle(.bordered) + } + Button("Gratuit") { + clubMemberFeeDeduction = entryFee + tournament.clubMemberFeeDeduction = clubMemberFeeDeduction + focusedField = nil + } + .buttonStyle(.bordered) } else { if focusedField == ._name, tournamentName.isEmpty == false { Button("Effacer") { @@ -214,7 +250,7 @@ struct TournamentGeneralSettingsView: View { .onChange(of: tournament.startDate) { _save() } - .onChange(of: tournament.entryFee) { + .onChange(of: [tournament.entryFee, tournament.clubMemberFeeDeduction]) { _save() } .onChange(of: [tournament.name, tournament.information, tournament.umpireCustomMail, tournament.umpireCustomPhone, tournament.umpireCustomContact]) { @@ -243,6 +279,8 @@ struct TournamentGeneralSettingsView: View { } } else if old == ._entryFee { tournament.entryFee = entryFee + } else if old == ._clubMemberFeeDeduction { + tournament.clubMemberFeeDeduction = clubMemberFeeDeduction } else if old == ._umpireCustomMail { _confirmUmpireMail() } else if old == ._umpireCustomPhone { @@ -301,11 +339,7 @@ struct TournamentGeneralSettingsView: View { } private func _save() { - do { - try dataStore.tournaments.addOrUpdate(instance: tournament) - } catch { - Logger.error(error) - } + dataStore.tournaments.addOrUpdate(instance: tournament) } private func _customUmpireView() -> some View { diff --git a/PadelClub/Views/Tournament/Screen/Components/UpdateSourceRankDateView.swift b/PadelClub/Views/Tournament/Screen/Components/UpdateSourceRankDateView.swift index 35186b1..7095821 100644 --- a/PadelClub/Views/Tournament/Screen/Components/UpdateSourceRankDateView.swift +++ b/PadelClub/Views/Tournament/Screen/Components/UpdateSourceRankDateView.swift @@ -45,7 +45,7 @@ struct UpdateSourceRankDateView: View { do { try await tournament.updateRank(to: currentRankSourceDate, forceRefreshLockWeight: forceRefreshLockWeight, providedSources: nil) - try dataStore.tournaments.addOrUpdate(instance: tournament) + dataStore.tournaments.addOrUpdate(instance: tournament) } catch { Logger.error(error) } diff --git a/PadelClub/Views/Tournament/Screen/PrintSettingsView.swift b/PadelClub/Views/Tournament/Screen/PrintSettingsView.swift index f5b351b..0693086 100644 --- a/PadelClub/Views/Tournament/Screen/PrintSettingsView.swift +++ b/PadelClub/Views/Tournament/Screen/PrintSettingsView.swift @@ -47,9 +47,9 @@ struct PrintSettingsView: View { Text("Tableau") }) -// Toggle(isOn: $generator.includeLoserBracket, label: { -// Text("Tableau des matchs de classements") -// }) + Toggle(isOn: $generator.includeLoserBracket, label: { + Text("Tableau des matchs de classements") + }) if tournament.groupStages().isEmpty == false { Toggle(isOn: $generator.includeGroupStage, label: { @@ -132,18 +132,18 @@ struct PrintSettingsView: View { } label: { Text("Aperçu du tableau") } -// -// ForEach(tournament.rounds()) { round in -// if round.index > 0 { -// NavigationLink { -// WebViewPreview(round: round) -// .environmentObject(generator) -// } label: { -// Text("Aperçu \(round.correspondingLoserRoundTitle())") -// } -// } -// } -// + + ForEach(tournament.rounds()) { round in + if round.index > 0 { + NavigationLink { + WebViewPreview(round: round) + .environmentObject(generator) + } label: { + Text("Aperçu \(round.correspondingLoserRoundTitle())") + } + } + } + ForEach(tournament.groupStages()) { groupStage in NavigationLink { WebViewPreview(groupStage: groupStage) @@ -182,6 +182,13 @@ struct PrintSettingsView: View { Text("Poule") } } + + if let round = tournament.rounds().first { + ShareLink(item: generator.generateLoserBracketHtml(upperRound: round)) { + Text("Classement") + } + } + } header: { Text("Partager le code source HTML") } diff --git a/PadelClub/Views/Tournament/Screen/RegistrationSetupView.swift b/PadelClub/Views/Tournament/Screen/RegistrationSetupView.swift index 355b6b2..4b51a39 100644 --- a/PadelClub/Views/Tournament/Screen/RegistrationSetupView.swift +++ b/PadelClub/Views/Tournament/Screen/RegistrationSetupView.swift @@ -10,6 +10,7 @@ import SwiftUI import PadelClubData struct RegistrationSetupView: View { + @Environment(\.openURL) private var openURL @EnvironmentObject var dataStore: DataStore @Bindable var tournament: Tournament @State private var enableOnlineRegistration: Bool @@ -32,6 +33,7 @@ struct RegistrationSetupView: View { @State private var isTemplate: Bool @State private var isCorporateTournament: Bool @State private var isValidating = false + @State private var unregisterDeltaInHours: Int // Online Payment @State private var enableOnlinePayment: Bool @@ -40,7 +42,7 @@ struct RegistrationSetupView: View { @State private var refundDateLimit: Date @State private var refundDateLimitEnabled: Bool @State private var stripeAccountId: String - @State private var stripeAccountIdIsInvalid: Bool = false + @State private var stripeAccountIdIsInvalid: Bool? @State private var paymentConfig: PaymentConfig? @State private var timeToConfirmConfig: TimeToConfirmConfig? @@ -49,6 +51,10 @@ struct RegistrationSetupView: View { @State private var hasChanges: Bool = false + @State private var stripeOnBoardingURL: URL? = nil + @State private var errorMessage: String? = nil + @State private var presentErrorAlert: Bool = false + @Environment(\.dismiss) private var dismiss init(tournament: Tournament) { @@ -56,6 +62,7 @@ struct RegistrationSetupView: View { _enableOnlineRegistration = .init(wrappedValue: tournament.enableOnlineRegistration) _isTemplate = .init(wrappedValue: tournament.isTemplate) _isCorporateTournament = .init(wrappedValue: tournament.isCorporateTournament) + _unregisterDeltaInHours = .init(wrappedValue: tournament.unregisterDeltaInHours) // Registration Date Limit if let registrationDateLimit = tournament.registrationDateLimit { _registrationDateLimit = .init(wrappedValue: registrationDateLimit) @@ -184,8 +191,10 @@ struct RegistrationSetupView: View { } Section { + Text("Par défaut, sans date définie, les inscriptions en ligne sont possible dès son activation.") + Toggle(isOn: $openingRegistrationDateEnabled) { - Text("Définir une date") + Text("Définir une date ultérieur") } if openingRegistrationDateEnabled { @@ -215,6 +224,18 @@ struct RegistrationSetupView: View { Text("Si une date de fermeture des inscriptions en ligne est définie, alors plus aucune inscription ne sera possible après cette date. Sinon, la date du début du tournoi ou la date de clôture des inscriptions seront utilisées.") } + Section { + LabeledContent { + StepperView(count: $unregisterDeltaInHours) + } label: { + Text("\(unregisterDeltaInHours)h avant") + } + } header: { + Text("Limite de désinscription") + } footer: { + Text("Empêche la désinscription plusieurs heures avant le début du tournoi") + } + Section { if displayWarning() { Text("Attention, l'inscription en ligne est activée et vous avez des équipes inscrites en ligne, en modifiant la structure ces équipes seront intégrées ou retirées de votre sélection d'équipes. Padel Club saura prévenir les équipes inscrites en ligne automatiquement.") @@ -304,7 +325,6 @@ struct RegistrationSetupView: View { ToolbarItem(placement: .topBarTrailing) { ButtonValidateView(role: .destructive) { _save() - dismiss() } } } @@ -315,7 +335,7 @@ struct RegistrationSetupView: View { HStack { Button("Effacer") { stripeAccountId = "" - stripeAccountIdIsInvalid = false + stripeAccountIdIsInvalid = nil tournament.stripeAccountId = nil } .buttonStyle(.borderless) @@ -328,7 +348,13 @@ struct RegistrationSetupView: View { } } } - + .alert("Paiement en ligne", isPresented: $presentErrorAlert, actions: { + Button("Fermer") { + self.presentErrorAlert = false + } + }, message: { + Text(ValidationError.onlinePaymentNotEnabled.localizedDescription) + }) .toolbarRole(.editor) .headerProminence(.increased) .navigationTitle("Inscription en ligne") @@ -399,7 +425,7 @@ struct RegistrationSetupView: View { } Toggle(isOn: $enableOnlinePaymentRefund) { - Text("Autoriser les remboursements") + Text("Autoriser les remboursements en ligne") } if enableOnlinePaymentRefund { @@ -419,32 +445,6 @@ struct RegistrationSetupView: View { Text("Revenu Padel Club") } } - - if isCorporateTournament == false, dataStore.user.registrationPaymentMode.requiresStripe() { - VStack(alignment: .leading) { - LabeledContent { - if isValidating { - ProgressView() - } else if focusedField == nil, stripeAccountIdIsInvalid == false, stripeAccountId.isEmpty == false, isValidating == false { - Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) - } - } label: { - TextField("Identifiant du compte Stripe", text: $stripeAccountId) - .frame(maxWidth: .infinity) - .focused($focusedField, equals: ._stripeAccountId) - .disabled(isValidating) - .keyboardType(.alphabet) - .textContentType(nil) - .autocorrectionDisabled() - } - if stripeAccountIdIsInvalid { - Text("Identifiant Stripe invalide. Vous ne pouvez pas activer le paiement en ligne.").foregroundStyle(.logoRed) - Button("Ré-essayer") { - _confirmStripeAccountId() - } - } - } - } } header: { Text("Paiement en ligne") } footer: { @@ -472,13 +472,81 @@ struct RegistrationSetupView: View { .onChange(of: refundDateLimit) { _hasChanged() } - .onChange(of: focusedField) { old, new in - if old == ._stripeAccountId { - _confirmStripeAccountId() - } - } - if dataStore.user.registrationPaymentMode.requiresStripe() { + if isCorporateTournament == false, dataStore.user.registrationPaymentMode.requiresStripe() { + Section { + LabeledContent { + if isValidating { + ProgressView() + } else if focusedField == nil, stripeAccountIdIsInvalid == false, stripeAccountId.isEmpty == false, isValidating == false { + Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) + } + } label: { + TextField("Identifiant du compte Stripe", text: $stripeAccountId) + .frame(maxWidth: .infinity) + .focused($focusedField, equals: ._stripeAccountId) + .disabled(isValidating) + .keyboardType(.alphabet) + .textContentType(nil) + .autocorrectionDisabled() + } + .onChange(of: focusedField) { old, new in + if old == ._stripeAccountId { + _confirmStripeAccountId() + } + } + + if stripeAccountIdIsInvalid == true { + Text("Identifiant Stripe invalide. Vous ne pouvez pas activer le paiement en ligne.").foregroundStyle(.logoRed) + } + + if stripeAccountId.isEmpty == false { + Button("Vérifier le compte Stripe") { + _confirmStripeAccountId() + } + .disabled(isValidating) + } + + if let errorMessage { + Text(errorMessage).foregroundStyle(.logoRed) + } + + RowButtonView("Connecter ou créer un compte Stripe", role: .destructive) { + errorMessage = nil + stripeAccountIdIsInvalid = nil + stripeAccountId = "" + stripeOnBoardingURL = nil + do { + let createStripeAccountResponse = try await StripeValidationService.createStripeConnectAccount() + print("createStripeAccountResponse", createStripeAccountResponse) + + guard let accounId = createStripeAccountResponse.accountId else { + throw ValidationError.accountNotFound + } + + let createStripeAccountLinkResponse = try await StripeValidationService.createStripeAccountLink(accounId) + print("createStripeAccountLinkResponse", createStripeAccountLinkResponse) + stripeOnBoardingURL = createStripeAccountLinkResponse.url + stripeAccountIdIsInvalid = nil + stripeAccountId = accounId + + if let stripeOnBoardingURL { + openURL(stripeOnBoardingURL) + } else { + throw ValidationError.urlNotFound + } + + } catch { + self.errorMessage = error.localizedDescription + Logger.error(error) + } + } + } header: { + Text("Compte Stripe") + } footer: { + Text("Vous devez connecter un compte Stripe à Padel Club. En cliquant sur le bouton ci-dessus, vous serez dirigé vers Stripe pour choisir votre compte Stripe à connecter ou pour en créer un.") + } + Section { let fixedFee = RegistrationPaymentMode.stripeFixedFee // Fixed fee in euros let percentageFee = RegistrationPaymentMode.stripePercentageFee @@ -510,11 +578,10 @@ struct RegistrationSetupView: View { // Text("Aucune commission Padel Club ne sera prélevée.").foregroundStyle(.logoRed).bold() } } - } private func _confirmStripeAccountId() { - stripeAccountIdIsInvalid = false + stripeAccountIdIsInvalid = nil if stripeAccountId.isEmpty { tournament.stripeAccountId = nil } else if stripeAccountId.count >= 5, stripeAccountId.starts(with: "acct_") { @@ -528,13 +595,12 @@ struct RegistrationSetupView: View { Task { isValidating = true do { - let response = try await StripeValidationService.validateStripeAccountID(accId) + let response = try await StripeValidationService.validateStripeAccount(accountId: accId) + print("validateStripeAccount", response) stripeAccountId = accId - stripeAccountIdIsInvalid = response.valid == false - enableOnlinePayment = response.valid + stripeAccountIdIsInvalid = response.canProcessPayments == false } catch { stripeAccountIdIsInvalid = true - enableOnlinePayment = false } isValidating = false } @@ -550,7 +616,9 @@ struct RegistrationSetupView: View { tournament.enableOnlineRegistration = enableOnlineRegistration tournament.isTemplate = isTemplate tournament.isCorporateTournament = isCorporateTournament - + tournament.unregisterDeltaInHours = unregisterDeltaInHours + + var shouldDismiss = true if enableOnlineRegistration { tournament.accountIsRequired = userAccountIsRequired tournament.licenseIsRequired = licenseIsRequired @@ -573,6 +641,12 @@ struct RegistrationSetupView: View { tournament.stripeAccountId = stripeAccountId } else { tournament.stripeAccountId = nil + + if enableOnlinePayment, isCorporateTournament == false, dataStore.user.registrationPaymentMode.requiresStripe() { + enableOnlinePayment = false + tournament.enableOnlinePayment = false + shouldDismiss = false + } } } else { tournament.accountIsRequired = true @@ -610,8 +684,11 @@ struct RegistrationSetupView: View { } self.dataStore.tournaments.addOrUpdate(instance: tournament) - - dismiss() + if shouldDismiss { + dismiss() + } else { + presentErrorAlert = true + } } } diff --git a/PadelClub/Views/Tournament/Screen/TournamentSettingsView.swift b/PadelClub/Views/Tournament/Screen/TournamentSettingsView.swift index b11cc37..a9b29aa 100644 --- a/PadelClub/Views/Tournament/Screen/TournamentSettingsView.swift +++ b/PadelClub/Views/Tournament/Screen/TournamentSettingsView.swift @@ -30,7 +30,7 @@ enum TournamentSettings: Identifiable, Selectable, Equatable { case .general: return "Général" case .club: - return "Terrains" + return "Pistes" case .tournamentType: return "Type" } diff --git a/PadelClub/Views/Tournament/Subscription/SubscriptionView.swift b/PadelClub/Views/Tournament/Subscription/SubscriptionView.swift index e51a67c..8673763 100644 --- a/PadelClub/Views/Tournament/Subscription/SubscriptionView.swift +++ b/PadelClub/Views/Tournament/Subscription/SubscriptionView.swift @@ -26,10 +26,11 @@ extension Product { return StoreItem(rawValue: self.id)! } var formattedPrice: String { + let ttcPrice = "\(self.displayPrice) TTC" if let period = self.subscription?.subscriptionPeriod { - return self.displayPrice + " / " + period.unit.label + return "\(ttcPrice) / \(period.unit.label)" } - return self.displayPrice + return ttcPrice } } @@ -101,6 +102,7 @@ class SubscriptionModel: ObservableObject, StoreDelegate { } else { self.totalPrice = product.displayPrice } + self.totalPrice += " TTC" } else { self.totalPrice = "" } diff --git a/PadelClub/Views/Tournament/TournamentInitView.swift b/PadelClub/Views/Tournament/TournamentInitView.swift index bb13869..b54e5c7 100644 --- a/PadelClub/Views/Tournament/TournamentInitView.swift +++ b/PadelClub/Views/Tournament/TournamentInitView.swift @@ -78,7 +78,7 @@ struct TournamentInitView: View { Text(tournament.localizedTournamentType()) } label: { Text("Réglages du tournoi") - Text("Formats, terrains, prix et plus") + Text("Formats, pistes, prix et plus") } } diff --git a/PadelClub/Views/Tournament/TournamentView.swift b/PadelClub/Views/Tournament/TournamentView.swift index 9fad158..761d5e2 100644 --- a/PadelClub/Views/Tournament/TournamentView.swift +++ b/PadelClub/Views/Tournament/TournamentView.swift @@ -261,6 +261,10 @@ struct TournamentView: View { LabelStructure() } + NavigationLink(value: Screen.cashier) { + Text(tournament.isFree() ? "Présence" : "Encaissement") + } + NavigationLink(value: Screen.rankings) { LabeledContent { if tournament.publishRankings == false { diff --git a/PadelClubTests/ServerDataTests.swift b/PadelClubTests/ServerDataTests.swift index 9a18b02..9e06d95 100644 --- a/PadelClubTests/ServerDataTests.swift +++ b/PadelClubTests/ServerDataTests.swift @@ -236,9 +236,7 @@ final class ServerDataTests: XCTestCase { let rounds: [Round] = try await StoreCenter.main.service().get() let parentRoundId = rounds.first?.id - let round = Round(tournament: tournamentId, index: 1, parent: parentRoundId, matchFormat: MatchFormat.nineGames, startDate: Date(), groupStageLoserBracket: false, loserBracketMode: .manual, - plannedStartDate: Date() - ) + let round = Round(tournament: tournamentId, index: 1, parent: parentRoundId, format: MatchFormat.nineGames, startDate: Date(), groupStageLoserBracket: false, loserBracketMode: .manual, plannedStartDate: Date()) round.storeId = "abc" if let r: Round = try await StoreCenter.main.service().post(round) { @@ -270,7 +268,7 @@ final class ServerDataTests: XCTestCase { return } - let teamRegistration = TeamRegistration(tournament: tournamentId, groupStage: groupStageId, registrationDate: Date(), callDate: Date(), bracketPosition: 1, groupStagePosition: 2, comment: "comment", source: "source", sourceValue: "source V", logo: "logo", name: "Stax", walkOut: true, wildCardBracket: true, wildCardGroupStage: true, weight: 1, lockedWeight: 11, confirmationDate: Date(), qualified: true) + let teamRegistration = TeamRegistration(tournament: tournamentId, groupStage: groupStageId, registrationDate: Date(), callDate: Date(), bracketPosition: 1, groupStagePosition: 2, comment: "comment", source: "source", sourceValue: "source V", logo: "logo", name: "Stax", walkOut: true, wildCardBracket: true, wildCardGroupStage: true, weight: 1, lockedWeight: 11, confirmationDate: Date(), qualified: true, finalRanking: 100, pointsEarned: 10, uniqueRandomIndex: 1) teamRegistration.storeId = "123" if let tr: TeamRegistration = try await StoreCenter.main.service().post(teamRegistration) { @@ -297,6 +295,7 @@ final class ServerDataTests: XCTestCase { assert(tr.qualified == teamRegistration.qualified) assert(tr.finalRanking == teamRegistration.finalRanking) assert(tr.pointsEarned == teamRegistration.pointsEarned) + assert(tr.uniqueRandomIndex == teamRegistration.uniqueRandomIndex) } else { XCTFail("missing data") }