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