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)) ?? [] } }
GitRust