ProjetDetailScreen.swift
389 lignes · 16390 octets
import SwiftUI // Port of ui/projets/ProjetDetailScreen.kt — description, membres, kanban // board, and the compte-rendu (interaction) section on a contact. struct ProjetDetailScreen: View { @StateObject private var viewModel: ProjetDetailViewModel let onBack: () -> Void @State private var afficherEnregistrement = false @State private var relanceErreur: String? @State private var noteTextASupprimer: NoteProjetEntity? init(serverId: String, onBack: @escaping () -> Void) { _viewModel = StateObject(wrappedValue: ProjetDetailViewModel(projetServerId: serverId)) self.onBack = onBack } var body: some View { VStack(spacing: 0) { ScreenTopBar(title: viewModel.projet?.nom ?? "", onBack: onBack) ScrollView { VStack(alignment: .leading, spacing: 0) { Spacer().frame(height: 12) if let description = viewModel.projet?.description.nilIfBlank { Text(description) .font(C2VFont.bodyMedium) .foregroundColor(C2VColor.texteFaible) Spacer().frame(height: 8) } if !viewModel.membres.isEmpty { Text("Membres") .font(C2VFont.labelMedium) .foregroundColor(C2VColor.texteFaible) ForEach(viewModel.membres, id: \.self) { membre in Text("\(membre.utilisateur) — \(membre.niveau)") .font(C2VFont.bodyMedium) .foregroundColor(C2VColor.ink) } } C2VDivider().padding(.vertical, 12) KanbanBoardView( colonnes: viewModel.colonnes, taches: viewModel.visibleTaches, filterMode: viewModel.filterMode, onFilterModeChange: { viewModel.setFilterMode($0) }, assignableUsers: viewModel.assignableUsers, planification: ReglagesPlanification.de(viewModel.projet), onCreateTache: { titre, assigneA, debut, dureeJours, etiquette in viewModel.createTache( titre: titre, assigneA: assigneA, debut: debut, dureeJours: dureeJours, etiquette: etiquette ) }, onEditTache: { tache, titre, assigneA, debut, dureeJours, etiquette in viewModel.editTache( tache, titre: titre, assigneA: assigneA, debut: debut, dureeJours: dureeJours, etiquette: etiquette ) }, onDeleteTache: { viewModel.deleteTache($0) }, onMoveTache: { tache, direction in viewModel.moveTache(tache, direction: direction) }, onToggleSousTache: { tache, sousTacheId in viewModel.toggleSousTache(tache, sousTacheId: sousTacheId) } ) C2VDivider().padding(.vertical, 12) crSection C2VDivider().padding(.vertical, 12) notesSection Spacer().frame(height: 24) } .padding(.horizontal, 18) } } .background(C2VColor.fond) .toolbar(.hidden, for: .navigationBar) .task { await viewModel.refresh() } .sheet(isPresented: $afficherEnregistrement) { let projetServerId = viewModel.projetServerId EnregistrementNoteSheet { sujet, desc, path, transcription in await ProjetDetailScreen.sauvegarderNoteProjetVocale( projetServerId: projetServerId, sujet: sujet, description: desc, audioPath: path, demandeTranscription: transcription ) } } .confirmationDialog( "Supprimer cette note ?", isPresented: Binding( get: { noteTextASupprimer != nil }, set: { if !$0 { noteTextASupprimer = nil } } ), titleVisibility: .visible ) { Button("Supprimer", role: .destructive) { if let note = noteTextASupprimer { noteTextASupprimer = nil Task { await supprimerNote(note) } } } Button("Annuler", role: .cancel) { noteTextASupprimer = nil } } } private var crSection: some View { VStack(alignment: .leading, spacing: 0) { Text("Compte-rendu") .font(C2VFont.labelMedium) .foregroundColor(C2VColor.texteFaible) Spacer().frame(height: 6) let selectedContact = viewModel.contactsAvecServerId.first { $0.serverId == viewModel.crContactServerId } Menu { ForEach(viewModel.contactsAvecServerId, id: \.id) { contact in Button(crContactName(contact)) { viewModel.selectCrContact(contact.serverId) } } } label: { Text(selectedContact.map(crContactName) ?? "Choisir un contact") .font(C2VFont.labelMedium) .foregroundColor(C2VColor.ink) .padding(.vertical, 8) } if viewModel.crContactServerId != nil { Spacer().frame(height: 8) C2VTextField(label: "", placeholder: "Sujet", text: $viewModel.crSujet) Spacer().frame(height: 8) C2VTextField( label: "", placeholder: "Description", text: $viewModel.crDescription, minLines: 2 ) Spacer().frame(height: 8) Button("Ajouter") { viewModel.submitCr() } .buttonStyle(C2VTextButtonStyle()) .disabled(viewModel.crSujet.isBlank) Spacer().frame(height: 8) ForEach(viewModel.crInteractions, id: \.localId) { interaction in VStack(alignment: .leading, spacing: 2) { Text(interaction.sujet) .font(C2VFont.bodyMedium) .foregroundColor(C2VColor.ink) if interaction.description.isNotBlank { Text(interaction.description) .font(C2VFont.bodySmall) .foregroundColor(C2VColor.texteFaible) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 6) C2VDivider() } } } } // Kotlin `contactDisplayName` in ProjetDetailScreen (no company fallback). private func crContactName(_ contact: CrmContactEntity) -> String { if let full = contact.fullName?.nilIfBlank { return full } let composed = [contact.firstName, contact.lastName].compactMap { $0 } .joined(separator: " ") .trimmingCharacters(in: .whitespaces) return composed.isEmpty ? "—" : composed } // MARK: - Section Notes private var notesSection: some View { VStack(alignment: .leading, spacing: 0) { HStack { Text("Notes") .font(C2VFont.labelMedium) .foregroundColor(C2VColor.texteFaible) Spacer() Button { afficherEnregistrement = true } label: { Label("Note vocale", systemImage: "mic.badge.plus") .font(C2VFont.labelSmall) .foregroundColor(C2VColor.ink) } .buttonStyle(.plain) } Spacer().frame(height: 6) if let erreur = relanceErreur { Text(erreur) .font(C2VFont.bodySmall) .foregroundColor(C2VColor.brandError) .padding(.bottom, 4) } if viewModel.notesProjet.isEmpty { Text("Aucune note pour ce projet") .font(C2VFont.bodyMedium) .foregroundColor(C2VColor.texteFaible) } else { ForEach(viewModel.notesProjet, id: \.localId) { note in if note.audioPath != nil || note.transcriptionStatut != nil { NoteVocaleRow( sujet: note.titre, description: note.texte, audioPath: note.audioPath, transcriptionStatut: note.transcriptionStatut, transcriptionErreur: note.transcriptionErreur, onRelancer: { Task { await relancerTranscriptionNote(localId: note.localId) } }, onSupprimer: { Task { await supprimerNote(note) } } ) .padding(.vertical, 8) } else { VStack(alignment: .leading, spacing: 4) { Text(note.titre) .font(C2VFont.labelMedium) .foregroundColor(C2VColor.ink) Text("\(note.auteur) · \(noteDate(note))") .font(C2VFont.bodySmall) .foregroundColor(C2VColor.texteFaible) if note.texte.isNotBlank { Text(renderMarkdown(note.texte)) .font(C2VFont.bodyMedium) .foregroundColor(C2VColor.ink) } Button("Supprimer") { noteTextASupprimer = note } .buttonStyle(C2VTextButtonStyle(color: C2VColor.brandError)) .font(C2VFont.labelSmall) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 8) } C2VDivider() } } } } private func noteDate(_ note: NoteProjetEntity) -> String { let millis = note.updatedAt ?? note.createdAt let date = Date(timeIntervalSince1970: TimeInterval(millis) / 1000) return Self.noteDateFormatter.string(from: date) } private static let noteDateFormatter: DateFormatter = { let f = DateFormatter() f.dateStyle = .medium f.timeStyle = .none f.locale = Locale(identifier: "fr_FR") return f }() private func renderMarkdown(_ text: String) -> AttributedString { (try? AttributedString(markdown: text)) ?? AttributedString(text) } // MARK: - Relance et suppression private func relancerTranscriptionNote(localId: Int64) async { relanceErreur = nil guard let baseUrl = SyncCredentialsStore().baseUrl, let apiKey = SyncCredentialsStore().apiKey else { relanceErreur = "Relance impossible : serveur non configuré." return } let api = AilianceApiClient(baseUrl: baseUrl, apiKey: apiKey) let engine = SyncEngine(api: api, db: Card2vcfDatabase.shared) if let erreur = try? await engine.relancerTranscriptionNoteProjet(localId: localId) { relanceErreur = erreur } await viewModel.refresh() } private func supprimerNote(_ note: NoteProjetEntity) async { let db = Card2vcfDatabase.shared // 1. Supprimer le fichier audio local s'il existe. if let path = note.audioPath { try? FileManager.default.removeItem(atPath: path) } // 2. Op de synchronisation — encodage du projetServerId dans le payload. if let serverId = note.serverId { let payload: [String: Any] = ["projetServerId": note.projetServerId] let payloadJson = (try? JSONSerialization.data(withJSONObject: payload)) .flatMap { String(data: $0, encoding: .utf8) } ?? "{}" var op = SyncOpEntity(entityType: "note_projet", op: "delete") op.serverId = serverId op.payloadJson = payloadJson op.createdAt = ContactRepository.currentMillis() _ = try? await db.syncOpDao.insert(op) } // 3. Suppression locale. try? await db.noteProjetDao.deleteByLocalId(note.localId) await viewModel.refresh() } // MARK: - Sauvegarde note vocale (statique pour capture sûre) static func sauvegarderNoteProjetVocale( projetServerId: String, sujet: String, description: String, audioPath: String, demandeTranscription: Bool ) async -> Bool { let db = Card2vcfDatabase.shared let now = ContactRepository.currentMillis() // Insertion de la note var entity = NoteProjetEntity() entity.projetServerId = projetServerId entity.titre = sujet entity.texte = description entity.auteur = SyncUserContext.userName entity.createdAt = now if demandeTranscription { entity.transcriptionStatut = "en_attente" } let localId = (try? await db.noteProjetDao.upsert(entity)) ?? 0 // Déplacement vers le store stable if let store = try? AudioNoteStore.defaultStore(), localId > 0 { let stablePath = store.pathForNote(localId: localId) if FileManager.default.fileExists(atPath: audioPath), (try? FileManager.default.moveItem(atPath: audioPath, toPath: stablePath)) != nil { var withPath = entity withPath.localId = localId withPath.audioPath = stablePath _ = try? await db.noteProjetDao.upsert(withPath) } } // Op de synchronisation let payload = encodeNoteProjetPayload( titre: sujet, contenu: description, demandeTranscription: demandeTranscription ) var op = SyncOpEntity(entityType: "note_projet", op: "create") op.payloadJson = payload op.serverId = projetServerId op.localId = localId op.createdAt = now _ = try? await db.syncOpDao.insert(op) // Push immédiat en mode serveur if demandeTranscription, let baseUrl = SyncCredentialsStore().baseUrl, let apiKey = SyncCredentialsStore().apiKey { let api = AilianceApiClient(baseUrl: baseUrl, apiKey: apiKey) let engine = SyncEngine(api: api, db: db) let result = try? await engine.pousserEnAttente() return result?.success == true } return false } private static func encodeNoteProjetPayload( titre: String, contenu: String, demandeTranscription: Bool ) -> String { let obj: [String: Any] = [ "contenu": contenu, "demande_transcription": demandeTranscription, "titre": titre ] guard let data = try? JSONSerialization.data(withJSONObject: obj, options: [.sortedKeys]), let text = String(data: data, encoding: .utf8) else { return "{}" } return text } }
GitRust