KanbanBoardView.swift
391 lignes · 15867 octets
import SwiftUI // Port of ui/kanban/KanbanBoard.kt — filter chips, horizontal columns, // ←/→ move buttons, edit/create dialog with an assignee menu. private let kanbanColumnWidth: CGFloat = 220 private let kanbanBoardHeight: CGFloat = 360 struct KanbanBoardView: View { let colonnes: [KanbanColonne] let taches: [TacheEntity] let filterMode: KanbanFilter.Mode let onFilterModeChange: (KanbanFilter.Mode) -> Void let assignableUsers: [String] let planification: ReglagesPlanification let onCreateTache: (String, String?, String?, Int?, String?) -> Void let onEditTache: (TacheEntity, String, String?, String?, Int?, String?) -> Void let onDeleteTache: (TacheEntity) -> Void let onMoveTache: (TacheEntity, Int) -> Void let onToggleSousTache: (TacheEntity, String) -> Void @State private var dialogTarget: TacheDialogTarget? /// Étiquettes déjà posées dans le projet, proposées en réutilisation. private var etiquettesConnues: [String] { Array(Set(taches.compactMap(\.etiquette))).sorted() } var body: some View { VStack(alignment: .leading, spacing: 0) { HStack { HStack(spacing: 8) { FilterChipButton(label: "Mes tâches", selected: filterMode == .mine) { onFilterModeChange(.mine) } FilterChipButton(label: "Toutes", selected: filterMode == .all) { onFilterModeChange(.all) } } Spacer() Button("Nouvelle tâche") { dialogTarget = TacheDialogTarget(tache: nil) } .buttonStyle(C2VTextButtonStyle()) .disabled(colonnes.isEmpty) } .padding(.vertical, 8) if colonnes.isEmpty { Text("Aucun workflow associé à ce projet") .font(C2VFont.bodyMedium) .foregroundColor(C2VColor.texteFaible) .padding(.vertical, 8) } else { ScrollView(.horizontal, showsIndicators: false) { HStack(alignment: .top, spacing: 12) { ForEach(Array(colonnes.enumerated()), id: \.element.id) { index, colonne in KanbanColumnView( colonne: colonne, taches: taches .filter { $0.colonneId == colonne.id } .sorted { $0.ordre < $1.ordre }, planification: planification, canMoveLeft: index > 0, canMoveRight: index < colonnes.count - 1, onEdit: { dialogTarget = TacheDialogTarget(tache: $0) }, onDelete: onDeleteTache, onMoveLeft: { onMoveTache($0, -1) }, onMoveRight: { onMoveTache($0, 1) } ) } } } } } .sheet(item: $dialogTarget) { target in TacheDialog( tache: target.tache, assignableUsers: assignableUsers, planification: planification, etiquettesConnues: etiquettesConnues, onToggleSousTache: { sousTacheId in guard let tache = target.tache else { return } onToggleSousTache(tache, sousTacheId) }, onDismiss: { dialogTarget = nil }, onConfirm: { titre, assigneA, debut, dureeJours, etiquette in if let tache = target.tache { onEditTache(tache, titre, assigneA, debut, dureeJours, etiquette) } else { onCreateTache(titre, assigneA, debut, dureeJours, etiquette) } dialogTarget = nil } ) } } } private struct TacheDialogTarget: Identifiable { let id = UUID() let tache: TacheEntity? } private struct FilterChipButton: View { let label: String let selected: Bool let onTap: () -> Void var body: some View { Button(action: onTap) { Text(label) .font(C2VFont.labelMedium) .foregroundColor(selected ? C2VColor.ink : C2VColor.texteFaible) .padding(.vertical, 8) .padding(.horizontal, 12) .overlay( Rectangle().stroke(selected ? C2VColor.ink : C2VColor.bordure, lineWidth: 1) ) } .buttonStyle(.plain) } } private struct KanbanColumnView: View { let colonne: KanbanColonne let taches: [TacheEntity] let planification: ReglagesPlanification let canMoveLeft: Bool let canMoveRight: Bool let onEdit: (TacheEntity) -> Void let onDelete: (TacheEntity) -> Void let onMoveLeft: (TacheEntity) -> Void let onMoveRight: (TacheEntity) -> Void var body: some View { VStack(alignment: .leading, spacing: 0) { Text(colonne.nom.uppercased()) .font(C2VFont.labelMedium) .foregroundColor(C2VColor.texteFaible) .padding(.bottom, 6) ScrollView { VStack(spacing: 0) { ForEach(taches, id: \.localId) { tache in TacheCard( tache: tache, planification: planification, canMoveLeft: canMoveLeft, canMoveRight: canMoveRight, onEdit: { onEdit(tache) }, onDelete: { onDelete(tache) }, onMoveLeft: { onMoveLeft(tache) }, onMoveRight: { onMoveRight(tache) } ) } } } } .frame(width: kanbanColumnWidth, height: kanbanBoardHeight) } } private struct TacheCard: View { let tache: TacheEntity let planification: ReglagesPlanification let canMoveLeft: Bool let canMoveRight: Bool let onEdit: () -> Void let onDelete: () -> Void let onMoveLeft: () -> Void let onMoveRight: () -> Void private var enRetard: Bool { ApercuTache.enRetard( tache, joursOuvres: planification.joursOuvres, echeance: planification.echeance ) } var body: some View { VStack(alignment: .leading, spacing: 4) { Button(action: onEdit) { VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { if let teinte = ApercuTache.couleur(tache) { Circle() .fill(Color(teinteEtiquette: teinte)) .frame(width: 8, height: 8) } Text(tache.titre.nilIfBlank ?? "—") .font(C2VFont.bodyMedium) .foregroundColor(C2VColor.ink) } Text( tache.assigneA.map { String(format: "Assigné : %@", $0) } ?? "Non assigné" ) .font(C2VFont.bodySmall) .foregroundColor(C2VColor.texteFaible) // Repères de planification : rien si le projet ne planifie pas, comme sur le web. if planification.active { if let periode = ApercuTache.periode(tache, joursOuvres: planification.joursOuvres) { Text(enRetard ? "📅 \(periode) · en retard" : "📅 \(periode)") .font(C2VFont.bodySmall) .foregroundColor(enRetard ? C2VColor.brandError : C2VColor.texteFaible) } if let avancement = ApercuTache.avancement(tache) { Text("\(avancement.faites)/\(avancement.total) fait") .font(C2VFont.bodySmall) .foregroundColor(C2VColor.texteFaible) } if tache.parentId != nil { Text("↳ regroupée") .font(C2VFont.bodySmall) .foregroundColor(C2VColor.texteFaible) } } } .frame(maxWidth: .infinity, alignment: .leading) } .buttonStyle(.plain) HStack { Button("←", action: onMoveLeft) .buttonStyle(C2VTextButtonStyle()) .disabled(!canMoveLeft) Button("→", action: onMoveRight) .buttonStyle(C2VTextButtonStyle()) .disabled(!canMoveRight) Spacer() Button("Supprimer", action: onDelete) .buttonStyle(C2VTextButtonStyle()) } .padding(.top, 4) } .padding(10) .overlay(Rectangle().stroke(C2VColor.bordure, lineWidth: 1)) .padding(.vertical, 4) .contextMenu { if canMoveLeft { Button("← Colonne précédente", action: onMoveLeft) } if canMoveRight { Button("→ Colonne suivante", action: onMoveRight) } Button("Modifier la tâche", action: onEdit) Button("Supprimer", role: .destructive, action: onDelete) } } } private struct TacheDialog: View { let tache: TacheEntity? let assignableUsers: [String] let planification: ReglagesPlanification let etiquettesConnues: [String] let onToggleSousTache: (String) -> Void let onDismiss: () -> Void let onConfirm: (String, String?, String?, Int?, String?) -> Void @State private var titre: String @State private var assigneA: String? @State private var debut: String @State private var duree: String @State private var etiquette: String init( tache: TacheEntity?, assignableUsers: [String], planification: ReglagesPlanification, etiquettesConnues: [String], onToggleSousTache: @escaping (String) -> Void, onDismiss: @escaping () -> Void, onConfirm: @escaping (String, String?, String?, Int?, String?) -> Void ) { self.tache = tache self.assignableUsers = assignableUsers self.planification = planification self.etiquettesConnues = etiquettesConnues self.onToggleSousTache = onToggleSousTache self.onDismiss = onDismiss self.onConfirm = onConfirm _titre = State(initialValue: tache?.titre ?? "") _assigneA = State(initialValue: tache?.assigneA) _debut = State(initialValue: tache?.debut ?? "") _duree = State(initialValue: tache?.dureeJours.map(String.init) ?? "") _etiquette = State(initialValue: tache?.etiquette ?? "") } /// Une date malformée bloque la validation plutôt que d'être envoyée telle quelle. private var debutValide: Bool { debut.isEmpty || DateCivile(iso: debut) != nil } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 14) { Text(tache == nil ? "Nouvelle tâche" : "Modifier la tâche") .font(C2VFont.titleLarge) .foregroundColor(C2VColor.ink) C2VTextField(label: "", placeholder: "Titre", text: $titre) Menu { Button("Non assigné") { assigneA = nil } ForEach(assignableUsers.distinctPreservingOrder(), id: \.self) { user in Button(user) { assigneA = user } } } label: { Text(assigneA ?? "Non assigné") .font(C2VFont.labelMedium) .foregroundColor(C2VColor.ink) .padding(.vertical, 8) } if planification.active { C2VTextField( label: "Début", placeholder: "2026-07-22", text: $debut, supportingText: debutValide ? nil : "Date attendue au format AAAA-MM-JJ", isError: !debutValide ) C2VTextField(label: "Durée (jours ouvrés)", placeholder: "3", text: $duree) .keyboardType(.numberPad) .onChange(of: duree) { saisie in let chiffres = saisie.filter(\.isNumber) if chiffres != saisie { duree = chiffres } } ForEach(ApercuTache.sousTaches(tache), id: \.id) { st in Button { onToggleSousTache(st.id) } label: { HStack(spacing: 8) { Image(systemName: st.fait ? "checkmark.square" : "square") .foregroundColor(C2VColor.ink) Text(st.titre) .font(C2VFont.bodyMedium) .foregroundColor(C2VColor.ink) Spacer() } } .buttonStyle(.plain) } C2VTextField(label: "Étiquette", placeholder: "câblage", text: $etiquette) if !etiquettesConnues.isEmpty { Menu { ForEach(etiquettesConnues, id: \.self) { connue in Button(connue) { etiquette = connue } } } label: { Text("Étiquettes du projet") .font(C2VFont.labelMedium) .foregroundColor(C2VColor.texteFaible) } } } HStack { Spacer() Button("Annuler", action: onDismiss) .buttonStyle(C2VTextButtonStyle()) Button("Valider") { onConfirm( titre, assigneA, debut.nilIfBlank, Int(duree), etiquette.nilIfBlank ) } .buttonStyle(C2VTextButtonStyle()) .disabled(!debutValide) } } .padding(18) } .background(C2VColor.fond) .presentationDetents([.medium, .large]) } } private extension Color { /// « #RRGGBB » de `CouleurEtiquette` → `Color`, gris ardoise si illisible. init(teinteEtiquette: String) { let hex = teinteEtiquette.hasPrefix("#") ? String(teinteEtiquette.dropFirst()) : teinteEtiquette let rgb = UInt32(hex, radix: 16) ?? 0x6B_7280 self.init( red: Double((rgb >> 16) & 0xFF) / 255.0, green: Double((rgb >> 8) & 0xFF) / 255.0, blue: Double(rgb & 0xFF) / 255.0 ) } }
GitRust