You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
PadelClub/PadelClub/Views/Tournament/FileImportView.swift

622 lines
26 KiB

//
// 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<TeamImportStrategy> = 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<String> = Set()
@State private var chunkByParameter: Bool = true
init(defaultFileProvider: FileImportManager.FileProvider = .frenchFederation) {
_fileProvider = .init(wrappedValue: defaultFileProvider)
}
var tournamentStore: TournamentStore {
return self.tournament.tournamentStore
}
private func filteredTeams(tournament: Tournament) -> [FileImportManager.TeamHolder] {
if tournament.isAnimation() {
return teams.sorted(by: \.weight)
}
return teams.filter { $0.tournamentCategory == tournament.tournamentCategory && $0.tournamentAgeCategory == tournament.federalTournamentAge }.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")
}
if fileProvider == .customAutoSearch {
Text("Padel Club essaiera de chercher les joueurs dans la base fédérale automatiquement")
}
if fileProvider == .custom || fileProvider == .customAutoSearch {
Toggle(isOn: $chunkByParameter) {
Text("Détection des équipes")
if chunkByParameter {
Text("via le nom de l'équipe")
} else {
Text("couple de deux lignes")
}
}
}
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 || fileProvider == .customAutoSearch {
FooterButtonView("Voir le format du fichier") {
presentFormatHelperView = true
}
}
}
if let event = tournament.eventObject(), 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) \(tournament.federalAgeCategory.importingRawValue.lowercased()) 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
}
}
}
}
Picker(selection: $tournament.federalTournamentAge) {
ForEach(FederalTournamentAge.allCases) { ageCategory in
Text(ageCategory.importingRawValue).tag(ageCategory)
}
} label: {
Text("Modifier la catégorie d'âge")
}
.onChange(of: tournament.federalTournamentAge) {
_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: {
if tournament.isAnimation() {
Text("Équipe\(_filteredTeams.count.pluralSuffix) détectée\(_filteredTeams.count.pluralSuffix)")
} else {
Text("Équipe\(_filteredTeams.count.pluralSuffix) \(tournament.tournamentCategory.importingRawValue) \(tournament.federalTournamentAge.importingRawValue.lowercased()) détectée\(_filteredTeams.count.pluralSuffix)")
}
}
} footer: {
if previousTeams.isEmpty == false, tournament.isAnimation() == 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)
}
}
let unfound = _getUnfound(tournament: tournament, fromTeams: _filteredTeams)
if unfound.isEmpty == false {
Section {
LabeledContent {
Text(unfound.count.formatted())
} label: {
Text("Équipe\(unfound.count.pluralSuffix) précédente\(unfound.count.pluralSuffix) introuvable\(unfound.count.pluralSuffix)")
}
} footer: {
Text("Équipes de votre liste précédente non détectées dans ce nouveau fichier")
}
}
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 _getUnfound(tournament: Tournament, fromTeams filteredTeams: [FileImportManager.TeamHolder]) -> Set<TeamRegistration> {
let previousTeams = filteredTeams.compactMap({ $0.previousTeam })
let unfound = Set(tournament.unsortedTeams()).subtracting(Set(previousTeams))
return unfound
}
private func _validate(tournament: Tournament) async {
let filteredTeams = filteredTeams(tournament: tournament)
let unfound = _getUnfound(tournament: tournament, fromTeams: filteredTeams)
unfound.forEach { team in
if team.isWildCard() == false {
team.resetPositions()
team.walkOut = true
}
}
do {
try tournament.tournamentStore.teamRegistrations.addOrUpdate(contentOfs: unfound)
} catch {
Logger.error(error)
}
tournament.importTeams(filteredTeams)
validatedTournamentIds.insert(tournament.id)
if multiImport == false {
dismiss()
}
}
struct CombinedCategory: Identifiable, Hashable {
let tournamentCategory: TournamentCategory
let federalTournamentAge: FederalTournamentAge
var id: String {
return tournamentCategory.importingRawValue + federalTournamentAge.importingRawValue
}
}
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(fromDate: rankSourceDate)
}
let event: Event? = tournament.eventObject()
if let event, event.tournaments.count > 1 {
var categoriesDone: [CombinedCategory] = []
for someTournament in event.tournaments {
let combinedCategory = CombinedCategory(tournamentCategory: someTournament.tournamentCategory, federalTournamentAge: someTournament.federalTournamentAge)
if categoriesDone.contains(combinedCategory) == false {
let _teams = try await FileImportManager.shared.createTeams(from: fileContent, tournament: someTournament, fileProvider: fileProvider, checkingCategoryDisabled: false, chunkByParameter: chunkByParameter)
self.teams += _teams
categoriesDone.append(combinedCategory)
} else {
errorMessage = "Attention, l'événement possède plusieurs tournois d'une même catégorie / âge, 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, chunkByParameter: chunkByParameter)
}
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) {
if let teamName = team.name {
Text(teamName).foregroundStyle(.secondary)
}
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+)"
}
}
}