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
        )
    }
}