// // FileImportView.swift // PadelClub // // Created by Razmig Sarkissian on 24/03/2024. // import SwiftUI import TipKit import LeStorage enum FileImportCustomField: Int, Identifiable, CaseIterable { var id: Int { self.rawValue } case sexType case teamName case lastName case firstName case phoneNumber case email case rank case licenceId case clubName func columnLabel() -> String { let columnIndex: Int = self.rawValue + 1 return columnIndex.formatted() } func descriptionLabel() -> String? { switch self { case .sexType: return "f ou m" default: return nil } } func localizedLabel() -> String { switch self { case .sexType: return "Sexe" case .teamName: return "Nom de l'équipe" case .lastName: return "Nom" case .firstName: return "Prénom" case .phoneNumber: return "Téléphone" case .email: return "E-mail" case .rank: return "Rang" case .licenceId: return "Licence" case .clubName: return "Nom du club" } } } struct FileImportView: View { @EnvironmentObject var dataStore: DataStore @Environment(Tournament.self) var tournament: Tournament @Environment(\.dismiss) private var dismiss let notFoundAreWalkOutTip = NotFoundAreWalkOutTip() @State private var fileContent: String? @State private var teams: [FileImportManager.TeamHolder] = [] @State private var isShowing = false @State private var didImport = false @State private var convertingFile = false @State private var errorMessage: String? = nil @State private var forceRankUpdate: Bool = false @State private var selectedOptions: Set = Set() @State private var fileProvider: FileImportManager.FileProvider = .frenchFederation @State private var validationInProgress: Bool = false @State private var multiImport: Bool = false @State private var presentFormatHelperView: Bool = false @State private var validatedTournamentIds: Set = Set() var tournamentStore: TournamentStore { return self.tournament.tournamentStore } private func filteredTeams(tournament: Tournament) -> [FileImportManager.TeamHolder] { return teams.filter { $0.tournamentCategory == tournament.tournamentCategory }.sorted(by: \.weight) } private func _deleteTeams() async { await MainActor.run { do { try tournamentStore.teamRegistrations.delete(contentOfs: tournament.unsortedTeams()) } catch { Logger.error(error) } } } var body: some View { List { if teams.isEmpty { Section { RowButtonView("Choisir le fichier", systemImage: "square.and.arrow.down") { convertingFile = false isShowing.toggle() } } if tournament.unsortedTeams().count > 0 { RowButtonView("Effacer les équipes déjà inscrites", role: .destructive) { await _deleteTeams() } } Section { Picker(selection: $fileProvider) { ForEach(FileImportManager.FileProvider.allCases) { Text($0.localizedLabel).tag($0) } } label: { Text("Source du fichier") } RowButtonView("Démarrer l'importation") { if let fileContent { do { try await _startImport(fileContent: fileContent) } catch { errorMessage = error.localizedDescription } } } .disabled(fileContent == nil || convertingFile) } footer: { if fileProvider == .frenchFederation { let footerString = "Fichier provenant de [beach-padel.app.fft.fr](\(URLs.beachPadel.rawValue))" Text(.init(footerString)) } else if fileProvider == .custom { FooterButtonView("Voir le format du fichier") { presentFormatHelperView = true } } } if let event = tournament.eventObject(), event.tenupId != nil, event.tournaments.count > 1, fileProvider == .frenchFederation { Section { RowButtonView("Importer pour tous les tournois") { multiImport = true if let fileContent { do { try await _startImport(fileContent: fileContent) } catch { errorMessage = error.localizedDescription } } } } footer: { Text("Ce tournoi fait partie d'un événement avec plusieurs tournois. Vous pouvez importer les équipes du fichier pour tous les tournois d'un coup.") } .disabled(fileContent == nil || convertingFile) } } // if filteredTeams.isEmpty == false && tournament.unsortedTeams().isEmpty == false { // Section { // ForEach(TeamImportStrategy.allCases, id: \.self) { strategy in // LabeledContent { // Toggle(isOn: .init(get: { // selectedOptions.contains(strategy) // }, set: { selected in // if selected { // selectedOptions.insert(strategy) // } else { // selectedOptions.remove(strategy) // // } // })) {} // } label: { // Text(strategy.titleLabel()) // Text(strategy.descriptionLabel()) // } // // } // } header: { // Text("Stratégie d'importation") // } // } if convertingFile { Section { LabeledContent { ProgressView() } label: { Text("Conversion du fichier en cours") } } } if let errorMessage { Section { Text(errorMessage) } header: { Text("Erreur") } } // if tournament.entriesCount > 0 { // Section { // if tournament.inscriptionClosed { // Label("Les inscriptions clôturées", systemImage: "lock") // Text("Si le poids des équipes a changé, aucun déplacement entre les poules et le tableau n'est possible. Par contre, le classement sera mis à jour au sein des poules et du tableau, respectivement, en fonction de leur nouveau poids.") // Toggle(isOn: $forceRankUpdate) { // Text("Ne pas en tenir compte") // } // } else { // Label("Les inscriptions sont ouvertes", systemImage: "lock.open") // Text("Si le poids des équipes a changé, le classement de toutes les équipes sera mis à jour en fonction de leur nouveau poids.") // } // } // } let filteredTeams = filteredTeams(tournament: tournament) if filteredTeams.isEmpty && teams.isEmpty == false && multiImport == false { @Bindable var tournament = tournament Section { Text("Aucune équipe \(tournament.tournamentCategory.importingRawValue) détectée mais \(teams.count) équipes sont dans le fichier") Picker(selection: $tournament.tournamentCategory) { ForEach([TournamentCategory.men, TournamentCategory.women, TournamentCategory.mix]) { category in Text(category.importingRawValue).tag(category) } } label: { Text("Modifier la catégorie") } .onChange(of: tournament.tournamentCategory) { _save() Task { if let fileContent { do { try await _startImport(fileContent: fileContent) } catch { errorMessage = error.localizedDescription } } } } } } else if teams.isEmpty && didImport == true { Section { ContentUnavailableView("Aucune équipe détectée", systemImage: "person.2.slash", description: Text("Vérifiez si votre fichier correspond à la source.")) } } else if didImport { let _filteredTeams = filteredTeams let previousTeams = tournament.sortedTeams() if previousTeams.isEmpty == false { Section { TipView(notFoundAreWalkOutTip) .tipStyle(tint: nil) } } if multiImport, fileProvider == .frenchFederation, let tournaments = tournament.eventObject()?.tournaments, tournaments.count > 1 { ForEach(tournaments) { tournament in let tournamentFilteredTeams = self.filteredTeams(tournament: tournament) Section { RowButtonView("Valider les \(tournamentFilteredTeams.count.formatted()) équipe\(tournamentFilteredTeams.count.pluralSuffix)") { await _validate(tournament: tournament) } .disabled(validatedTournamentIds.contains(tournament.id)) } header: { Text(tournament.tournamentTitle()) } } .headerProminence(.increased) } else { Section { LabeledContent { Text(_filteredTeams.count.formatted()) } label: { Text("Équipe\(_filteredTeams.count.pluralSuffix) \(tournament.tournamentCategory.importingRawValue) détectée\(_filteredTeams.count.pluralSuffix)") } } footer: { if previousTeams.isEmpty == false { Text("La liste ci-dessous n'est qu'une indication d'évolution par rapport au seul poids d'équipe. Cela ne tient pas compte des dates d'inscriptions, WCs et autres éléments.").foregroundStyle(.logoRed) } } ForEach(_filteredTeams) { team in _teamView(team: team, inTeams: _filteredTeams, previousTeams: previousTeams) } } } } .navigationBarTitleDisplayMode(.inline) .toolbarBackground(.visible, for: .navigationBar) .sheet(isPresented: $presentFormatHelperView) { NavigationStack { List { Section { Text("Créer un fichier xls, xlsx ou csv qui contient les colonnes suivantes.") Text("Chaque ligne correspond à un joueur, et chaque groupe de deux lignes correspond à une équipe.") Text("Aucune valeur n'est obligatoire.") } Section { ForEach(FileImportCustomField.allCases) { fileImportCustomField in LabeledContent { Text(fileImportCustomField.localizedLabel()) } label: { Text("Colonne \(fileImportCustomField.columnLabel())") if let descriptionLabel = fileImportCustomField.descriptionLabel() { Text(descriptionLabel) } } } } } .navigationTitle("Description du format") .navigationBarTitleDisplayMode(.inline) .toolbarBackground(.visible, for: .navigationBar) .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Fermer") { presentFormatHelperView = false } } } } .tint(.master) } .fileImporter(isPresented: $isShowing, allowedContentTypes: [.spreadsheet, .commaSeparatedText, .text], allowsMultipleSelection: false, onCompletion: { results in switch results { case .success(let fileurls): if let selectedFile = fileurls.first { if selectedFile.startAccessingSecurityScopedResource() { errorMessage = nil teams.removeAll() Task { do { if selectedFile.lastPathComponent.hasSuffix("xls") || selectedFile.lastPathComponent.hasSuffix("xlsx") { convertingFile = true fileContent = try await CloudConvert.manager.uploadFile(selectedFile) convertingFile = false } else { fileContent = try String(contentsOf: selectedFile) } selectedFile.stopAccessingSecurityScopedResource() } catch { errorMessage = error.localizedDescription } } } else { // Handle denied access } } case .failure(let error): errorMessage = error.localizedDescription } }) .onOpenURL { url in do { fileContent = try String(contentsOf: url) } catch { errorMessage = error.localizedDescription } } .navigationTitle("Importation") .navigationBarTitleDisplayMode(.large) .toolbar { // ToolbarItem(placement: .bottomBar) { // PasteButton(payloadType: String.self) { strings in // guard let string = strings.first else { return } // fileContent = string // fileProvider = .padelClub // } // } ToolbarItem(placement: .cancellationAction) { Button("Annuler", role: .cancel) { dismiss() } } ToolbarItem(placement: .topBarTrailing) { if validationInProgress { ProgressView() } else { ButtonValidateView(title: (multiImport ? "Tout Valider" : "Valider")) { validationInProgress = true Task { if let tournaments = tournament.eventObject()?.tournaments, tournaments.count > 1, multiImport { for tournament in tournaments { if validatedTournamentIds.contains(tournament.id) == false { await _validate(tournament: tournament) } } dismiss() } else { await _validate(tournament: tournament) } } } .disabled(teams.isEmpty) } } } .interactiveDismissDisabled(validationInProgress) .disabled(validationInProgress) } private func _validate(tournament: Tournament) async { let filteredTeams = filteredTeams(tournament: tournament) let previousTeams = filteredTeams.compactMap({ $0.previousTeam }) let unfound = Set(tournament.unsortedTeams()).subtracting(Set(previousTeams)) unfound.forEach { team in team.resetPositions() team.wildCardBracket = false team.wildCardGroupStage = false team.walkOut = true } do { try self.tournamentStore.teamRegistrations.addOrUpdate(contentOfs: unfound) } catch { Logger.error(error) } tournament.importTeams(filteredTeams) validatedTournamentIds.insert(tournament.id) if multiImport == false { dismiss() } } private func _startImport(fileContent: String) async throws { await MainActor.run { errorMessage = nil teams.removeAll() } if let rankSourceDate = tournament.rankSourceDate, tournament.unrankValue(for: false) == nil || tournament.unrankValue(for: true) == nil { await MonthData.calculateCurrentUnrankedValues(mostRecentDateAvailable: rankSourceDate) } let event: Event? = tournament.eventObject() if let event, event.tenupId != nil { var categoriesDone: [TournamentCategory] = [] for someTournament in event.tournaments { if categoriesDone.contains(someTournament.tournamentCategory) == false { let _teams = try await FileImportManager.shared.createTeams(from: fileContent, tournament: someTournament, fileProvider: fileProvider, checkingCategoryDisabled: false) self.teams += _teams categoriesDone.append(someTournament.tournamentCategory) } else { errorMessage = "Attention, l'événement possède plusieurs tournois d'une même catégorie (homme, femme, mixte), Padel Club ne peut savoir quelle équipe appartient à quel tournoi." } } } else { self.teams = try await FileImportManager.shared.createTeams(from: fileContent, tournament: tournament, fileProvider: fileProvider, checkingCategoryDisabled: true) } await MainActor.run { didImport = true } } @ViewBuilder private func _teamView(team: FileImportManager.TeamHolder, inTeams teams: [FileImportManager.TeamHolder], previousTeams: [TeamRegistration]) -> some View { let newIndex = team.index(in: teams) Section { HStack { VStack(alignment: .leading) { ForEach(team.players.sorted(by: \.computedRank)) { Text($0.playerLabel()) } } Spacer() HStack { if let previousTeam = team.previousTeam { Text(previousTeam.formattedSeed(in: previousTeams)) Image(systemName: "arrowshape.forward.fill") } Text(team.formattedSeedIndex(index: newIndex)) } } if let callDate = team.previousTeam?.callDate, let newDate = tournament.getStartDate(ofSeedIndex: newIndex), callDate != newDate { Text("Attention, cette paire a déjà été convoquée à \(callDate.localizedDate())") .foregroundStyle(.logoRed) .italic() .font(.caption) } } } private func _save() { do { try dataStore.tournaments.addOrUpdate(instance: tournament) } catch { Logger.error(error) } } } //#Preview { // FileImportView() // .environment(Tournament.mock()) //} enum TeamImportStrategy: CaseIterable { case notFoundAreWalkOut case deleteBeforeImport func titleLabel() -> String { switch self { case .notFoundAreWalkOut: "Mettre les équipes manquantes WO" case .deleteBeforeImport: "Effacer avant d'importer" } } func descriptionLabel() -> String { switch self { case .notFoundAreWalkOut: "Si une équipe déjà présente n'est pas dans la nouvelle liste, elle sera mise à WO" case .deleteBeforeImport: "Supprime toutes les équipes avant d'importer" // case .lockWeight: // "Permets de déplacer les équipes avec leur nouveaux classements sans les déplacer entre les blocs (p500+)" } } }