ProjetDetailViewModel.swift 429 lignes · 16123 octets
import Combine
import Foundation

// Port of ui/projets/ProjetDetailViewModel.kt — membres, workflow kanban
// columns, tasks, and compte-rendu (interaction) on a contact.
// Sync-op payload JSON matches the Android `syncJson` config: snake_case keys
// with defaults encoded (SyncModels.kt).

/// UI-side mirror of the Android `MembreProjetDto` stored in `membresJson`.
struct ProjetMembre: Decodable, Hashable {
    var utilisateur: String
    var niveau: String = "lecteur"

    private enum CodingKeys: String, CodingKey {
        case utilisateur
        case niveau
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        utilisateur = try container.decode(String.self, forKey: .utilisateur)
        niveau = try container.decodeIfPresent(String.self, forKey: .niveau) ?? "lecteur"
    }
}

/// UI-side mirror of the Android `ColonneDto` stored in `colonnesJson`.
struct KanbanColonne: Decodable, Hashable, Identifiable {
    var id: String
    var nom: String
    var ordre: Int = 0

    private enum CodingKeys: String, CodingKey {
        case id
        case nom
        case ordre
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        id = try container.decode(String.self, forKey: .id)
        nom = try container.decode(String.self, forKey: .nom)
        ordre = try container.decodeIfPresent(Int.self, forKey: .ordre) ?? 0
    }
}

@MainActor
final class ProjetDetailViewModel: ObservableObject {
    @Published private(set) var projet: ProjetEntity?
    @Published private(set) var membres: [ProjetMembre] = []
    @Published private(set) var colonnes: [KanbanColonne] = []
    @Published private(set) var taches: [TacheEntity] = []
    @Published private(set) var filterMode: KanbanFilter.Mode = .mine
    @Published private(set) var contactsAvecServerId: [CrmContactEntity] = []
    @Published private(set) var crContactServerId: String?
    @Published var crSujet = ""
    @Published var crDescription = ""
    @Published private(set) var crInteractions: [InteractionEntity] = []
    @Published private(set) var notesProjet: [NoteProjetEntity] = []

    let projetServerId: String
    let userName: String

    private let database: any CrmDatabase
    private var cancellables: Set<AnyCancellable> = []

    init(
        projetServerId: String,
        database: any CrmDatabase = Card2vcfDatabase.shared,
        userName: String = SyncUserContext.userName
    ) {
        self.projetServerId = projetServerId
        self.database = database
        self.userName = userName
        NotificationCenter.default.publisher(for: .card2vcfDatabaseDidChange)
            .receive(on: RunLoop.main)
            .sink { [weak self] _ in Task { await self?.refresh() } }
            .store(in: &cancellables)
    }

    var visibleTaches: [TacheEntity] {
        KanbanFilter.filter(taches, mode: filterMode, userName: userName)
    }

    var assignableUsers: [String] {
        (membres.map { $0.utilisateur } + [userName]).filter { $0.isNotBlank }
    }

    func refresh() async {
        let db = database
        projet = try? await db.projetDao.getByServerId(projetServerId)
        membres = Self.parseMembres(projet?.membresJson ?? "[]")
        let workflowServerId = projet?.workflowServerId ?? ""
        if workflowServerId.isNotBlank,
           let workflow = try? await db.workflowDao.getByServerId(workflowServerId) {
            colonnes = Self.parseColonnes(workflow.colonnesJson).sorted { $0.ordre < $1.ordre }
        } else {
            colonnes = []
        }
        taches = (try? await db.tacheDao.fetchByProjetServerId(projetServerId)) ?? []
        let rawNotes = (try? await db.noteProjetDao.listByProjetServerId(projetServerId)) ?? []
        notesProjet = rawNotes.sorted { ($0.updatedAt ?? $0.createdAt) > ($1.updatedAt ?? $1.createdAt) }
        let allContacts = (try? await db.crmContactDao.fetchAll()) ?? []
        contactsAvecServerId = allContacts.filter { $0.serverId != nil }
        await refreshInteractions()
    }

    func setFilterMode(_ mode: KanbanFilter.Mode) {
        filterMode = mode
    }

    func selectCrContact(_ serverId: String?) {
        crContactServerId = serverId
        Task { await refreshInteractions() }
    }

    private func refreshInteractions() async {
        guard let contactServerId = crContactServerId else {
            crInteractions = []
            return
        }
        crInteractions = (try? await database.interactionDao.fetchByContactServerId(contactServerId)) ?? []
    }

    func submitCr() {
        guard let contactServerId = crContactServerId else { return }
        let sujet = crSujet.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !sujet.isEmpty else { return }
        let description = crDescription.trimmingCharacters(in: .whitespacesAndNewlines)
        Task {
            let now = ContactRepository.currentMillis()
            _ = try? await database.interactionDao.upsert(
                InteractionEntity(
                    contactServerId: contactServerId,
                    sujet: sujet,
                    description: description,
                    creePar: userName,
                    createdAt: now
                )
            )
            _ = try? await database.syncOpDao.insert(
                SyncOpEntity(
                    entityType: "interaction",
                    op: "create",
                    payloadJson: Self.encodeJson(["sujet": sujet, "description": description]),
                    serverId: contactServerId,
                    createdAt: now
                )
            )
            crSujet = ""
            crDescription = ""
            await refresh()
        }
    }

    func createTache(
        titre: String,
        assigneA: String?,
        debut: String? = nil,
        dureeJours: Int? = nil,
        etiquette: String? = nil
    ) {
        let titreTrim = titre.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !titreTrim.isEmpty else { return }
        guard let colonneId = colonnes.first?.id else { return }
        Task {
            let now = ContactRepository.currentMillis()
            let localId = (try? await database.tacheDao.upsert(
                TacheEntity(
                    projetLocalId: projet?.localId,
                    projetServerId: projetServerId,
                    titre: titreTrim,
                    colonneId: colonneId,
                    assigneA: assigneA,
                    debut: debut,
                    dureeJours: dureeJours,
                    etiquette: etiquette,
                    auteur: userName,
                    createdAt: now,
                    updatedAt: now
                )
            )) ?? 0
            _ = try? await database.syncOpDao.insert(
                SyncOpEntity(
                    entityType: "tache",
                    op: "create",
                    payloadJson: Self.createTachePayload(
                        titre: titreTrim,
                        description: "",
                        colonneId: colonneId,
                        assigneA: assigneA,
                        debut: debut,
                        dureeJours: dureeJours,
                        etiquette: etiquette
                    ),
                    localId: localId,
                    createdAt: now
                )
            )
            await refresh()
        }
    }

    /// Le dialogue initialise toujours les champs de planification depuis la
    /// tâche et les renvoie tels quels : renommer ne peut donc pas effacer la
    /// date qu'on vient de lire (miroir des valeurs par défaut du Kotlin).
    func editTache(
        _ tache: TacheEntity,
        titre: String,
        assigneA: String?,
        debut: String?,
        dureeJours: Int?,
        etiquette: String?
    ) {
        let titreTrim = titre.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !titreTrim.isEmpty else { return }
        Task {
            let now = ContactRepository.currentMillis()
            var updated = tache
            updated.titre = titreTrim
            updated.assigneA = assigneA
            updated.debut = debut
            updated.dureeJours = dureeJours
            updated.etiquette = etiquette
            updated.updatedAt = now
            _ = try? await database.tacheDao.upsert(updated)
            let payload = Self.updateTachePayload(
                titre: titreTrim,
                description: tache.description,
                assigneA: assigneA,
                debut: debut,
                dureeJours: dureeJours,
                etiquette: etiquette
            )
            await enqueueTacheMutation(tache, op: "update", payloadJson: payload) {
                Self.createTachePayload(
                    titre: titreTrim,
                    description: tache.description,
                    colonneId: tache.colonneId,
                    assigneA: assigneA,
                    debut: debut,
                    dureeJours: dureeJours,
                    etiquette: etiquette
                )
            }
            await refresh()
        }
    }

    /// Coche ou décoche une sous-tâche : bascule locale immédiate, puis
    /// opération poussée à la synchronisation.
    ///
    /// Passe par une route dédiée plutôt que par la mise à jour générale, qui ne
    /// transporte que des titres et ne saurait pas porter l'état `fait`.
    func toggleSousTache(_ tache: TacheEntity, sousTacheId: String) {
        guard let serverId = tache.serverId else { return }
        Task {
            let sous = ApercuTache.sousTaches(tache)
            guard !sous.isEmpty else { return }
            let basculees = sous.map { st -> SousTacheDto in
                guard st.id == sousTacheId else { return st }
                var copie = st
                copie.fait = !st.fait
                return copie
            }
            let now = ContactRepository.currentMillis()
            var updated = tache
            updated.sousTachesJson = SyncJson.encodeToString(basculees)
            updated.updatedAt = now
            _ = try? await database.tacheDao.upsert(updated)
            _ = try? await database.syncOpDao.insert(
                SyncOpEntity(
                    entityType: "tache",
                    op: "soustache",
                    payloadJson: sousTacheId,
                    serverId: serverId,
                    createdAt: now
                )
            )
            await refresh()
        }
    }

    func deleteTache(_ tache: TacheEntity) {
        Task {
            if let serverId = tache.serverId {
                try? await database.tacheDao.deleteByServerId(serverId)
                _ = try? await database.syncOpDao.insert(
                    SyncOpEntity(
                        entityType: "tache",
                        op: "delete",
                        serverId: serverId,
                        createdAt: ContactRepository.currentMillis()
                    )
                )
            } else {
                try? await database.tacheDao.deleteByLocalId(tache.localId)
                await clearPendingCreate(tache.localId)
            }
            await refresh()
        }
    }

    func moveTache(_ tache: TacheEntity, direction: Int) {
        let ordered = colonnes
        guard let currentIndex = ordered.firstIndex(where: { $0.id == tache.colonneId }) else { return }
        let targetIndex = currentIndex + direction
        guard ordered.indices.contains(targetIndex) else { return }
        let targetColonneId = ordered[targetIndex].id
        Task {
            let now = ContactRepository.currentMillis()
            var moved = tache
            moved.colonneId = targetColonneId
            moved.updatedAt = now
            _ = try? await database.tacheDao.upsert(moved)
            let payload = Self.encodeJson(["colonne_id": targetColonneId])
            await enqueueTacheMutation(tache, op: "move", payloadJson: payload) {
                Self.createTachePayload(
                    titre: tache.titre,
                    description: tache.description,
                    colonneId: targetColonneId,
                    assigneA: tache.assigneA
                )
            }
            await refresh()
        }
    }

    /// When the task has no `serverId` yet (create not synchronized), the
    /// pending create op is patched instead of enqueuing the mutation.
    private func enqueueTacheMutation(
        _ tache: TacheEntity,
        op: String,
        payloadJson: String,
        fallbackCreatePayload: () -> String
    ) async {
        if let serverId = tache.serverId {
            _ = try? await database.syncOpDao.insert(
                SyncOpEntity(
                    entityType: "tache",
                    op: op,
                    payloadJson: payloadJson,
                    serverId: serverId,
                    createdAt: ContactRepository.currentMillis()
                )
            )
            return
        }
        let ops = (try? await database.syncOpDao.listAll()) ?? []
        if var pendingCreate = ops.first(where: {
            $0.entityType == "tache" && $0.op == "create" && $0.localId == tache.localId
        }) {
            pendingCreate.payloadJson = fallbackCreatePayload()
            _ = try? await database.syncOpDao.insert(pendingCreate)
        }
    }

    private func clearPendingCreate(_ localId: Int64) async {
        let ops = (try? await database.syncOpDao.listAll()) ?? []
        for op in ops where op.entityType == "tache" && op.localId == localId {
            try? await database.syncOpDao.deleteById(op.id)
        }
    }

    // MARK: - JSON helpers (snake_case, defaults encoded — Android syncJson)

    static func createTachePayload(
        titre: String,
        description: String,
        colonneId: String,
        assigneA: String?,
        debut: String? = nil,
        dureeJours: Int? = nil,
        etiquette: String? = nil
    ) -> String {
        encodeJson([
            "titre": titre,
            "description": description,
            "colonne_id": colonneId,
            "assigne_a": assigneA as Any,
            "debut": debut as Any,
            "duree_jours": dureeJours as Any,
            "etiquette": etiquette as Any
        ])
    }

    /// Miroir de `UpdateTacheRequest` (Kotlin). **Ne déclare que ce que le
    /// téléphone édite** : sous-tâches, dépendances et regroupement sont absents,
    /// et c'est leur absence qui les préserve côté serveur.
    static func updateTachePayload(
        titre: String,
        description: String,
        assigneA: String?,
        debut: String?,
        dureeJours: Int?,
        etiquette: String?
    ) -> String {
        encodeJson([
            "titre": titre,
            "description": description,
            "assigne_a": assigneA as Any,
            "assigne_a_pose": true,
            "debut": debut as Any,
            "duree_jours": dureeJours as Any,
            "etiquette": etiquette as Any
        ])
    }

    static func encodeJson(_ object: [String: Any]) -> String {
        let sanitized = object.mapValues { value -> Any in
            if case Optional<Any>.none = value { return NSNull() }
            return value
        }
        guard
            let data = try? JSONSerialization.data(withJSONObject: sanitized, options: [.sortedKeys]),
            let text = String(data: data, encoding: .utf8)
        else { return "{}" }
        return text
    }

    static func parseMembres(_ json: String) -> [ProjetMembre] {
        guard let data = json.data(using: .utf8) else { return [] }
        return (try? JSONDecoder().decode([ProjetMembre].self, from: data)) ?? []
    }

    static func parseColonnes(_ json: String) -> [KanbanColonne] {
        guard let data = json.data(using: .utf8) else { return [] }
        return (try? JSONDecoder().decode([KanbanColonne].self, from: data)) ?? []
    }
}