Files
Lesstime/docs/superpowers/plans/2026-05-19-mail-phase6-task-integration.md

65 KiB

Mail Integration — Phase 6 : Intégration Tâches

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Brancher l'intégration mail ↔ tâches : modal "Créer tâche depuis mail" (pré-remplie subject+body), modal "Lier mail à tâche existante" (autocomplete), nouvel onglet "Mails" dans TaskModal.vue listant les mails liés.

Architecture: 3 nouveaux composants modals sous components/mail/, modification de TaskModal.vue pour ajouter un onglet mails à côté des onglets details et planning existants, branchement des handlers placeholders dans pages/mail.vue. Pas de nouveau backend (endpoints Phase 3 déjà en place).

Contexte codebase important :

  • Il n'y a pas de TaskDrawer.vue — le composant principal est TaskModal.vue (frontend/components/task/TaskModal.vue), qui contient un système d'onglets details / planning.
  • TaskModal.vue est ouvert via v-model: boolean (prop modelValue) et reçoit la tâche complète via la prop task: Task | null.
  • Le service useMailService() expose déjà : createTaskFromMail(mailId, input), linkTask(mailId, taskId), listMailsForTask(taskId).
  • MailCreateTaskInput est { projectId: number; taskGroupId?: number | null; priority?: string | null } — le backend dérive titre/description du mail, le frontend passe uniquement les affectations.
  • useProjectService().getAll(), useTaskGroupService().getByProject(projectId), useTaskPriorityService().getAll() sont les services de sélection.
  • Le projet est SPA (SSR off) — pas de guard import.meta.client nécessaire pour DOM.
  • Pattern modal existant : <Teleport to="body"> + <Transition> + backdrop click pour fermer (voir TaskModal.vue).
  • MalioSelect requiert { label: string, value: number | null } — utiliser <select> natif pour les enums string (ex: priority qui est une string).

Tech Stack: Nuxt 4, Vue 3 Composition API + <script setup lang="ts">, Pinia, @malio/layer-ui (MalioButton, MalioButtonIcon, MalioSelect), Tailwind CSS, @nuxt/icon.

Branche cible : feat/mail-integration (vérifier qu'elle est active avant de commencer).

Fichiers créés/modifiés par le codeur :

Fichier Action
frontend/components/mail/MailCreateTaskModal.vue Créer
frontend/components/mail/MailLinkTaskModal.vue Créer
frontend/components/mail/MailPickerModal.vue Créer
frontend/components/task/TaskModal.vue Modifier (ajout onglet mails)
frontend/pages/mail.vue Modifier (brancher handlers placeholders)
frontend/i18n/locales/fr.json Modifier (ajout sous-clés mail.createTaskModal, mail.linkTaskModal, mail.pickerModal, mail.taskTab)
frontend/i18n/locales/en.json Modifier si le fichier existe

Task 1 : Vérification de l'environnement

  • Step 1 : Vérifier la branche active

    git -C /home/r-dev/malio-dev/Lesstime branch --show-current
    

    Attendu : feat/mail-integration. Si non :

    git -C /home/r-dev/malio-dev/Lesstime checkout feat/mail-integration
    
  • Step 2 : Vérifier que les fichiers Phase 5 existent

    ls -la /home/r-dev/malio-dev/Lesstime/frontend/pages/mail.vue \
           /home/r-dev/malio-dev/Lesstime/frontend/components/mail/MailMessageViewer.vue \
           /home/r-dev/malio-dev/Lesstime/frontend/services/mail.ts \
           /home/r-dev/malio-dev/Lesstime/frontend/services/dto/mail.ts
    

    Attendu : les 4 fichiers présents. Si absents, implémenter Phase 5 d'abord.

  • Step 3 : Vérifier le baseline TypeScript

    cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1 | head -30
    

    Noter les erreurs existantes (si any) pour les distinguer des erreurs Phase 6.


Task 2 : Clés i18n Phase 6 — frontend/i18n/locales/fr.json

Ajouter les sous-clés dans le bloc mail existant (après la clé task déjà présente en Phase 5).

  • Step 1 : Ajouter les sous-clés dans fr.json

    Dans le bloc "mail": { ... }, ajouter après "task": { ... } :

    "createTaskModal": {
        "title": "Créer une tâche depuis ce mail",
        "submit": "Créer la tâche",
        "projectLabel": "Projet *",
        "projectPlaceholder": "Sélectionner un projet",
        "groupLabel": "Groupe (optionnel)",
        "groupPlaceholder": "Aucun groupe",
        "priorityLabel": "Priorité (optionnelle)",
        "priorityPlaceholder": "Aucune priorité",
        "titleHint": "Le titre sera rempli depuis le sujet du mail.",
        "descriptionHint": "La description sera remplie depuis le corps du mail."
    },
    "linkTaskModal": {
        "title": "Lier à une tâche existante",
        "submit": "Lier la tâche",
        "searchPlaceholder": "Rechercher une tâche par titre…",
        "projectFilter": "Filtrer par projet",
        "projectAll": "Tous les projets",
        "empty": "Aucune tâche correspondante.",
        "loading": "Recherche en cours…"
    },
    "pickerModal": {
        "title": "Lier un mail à cette tâche",
        "searchPlaceholder": "Rechercher un mail (sujet, expéditeur)…",
        "empty": "Aucun mail correspondant.",
        "loading": "Chargement des mails…",
        "submit": "Lier ce mail"
    },
    "taskTab": {
        "title": "Mails",
        "empty": "Aucun mail lié à cette tâche.",
        "linkButton": "Lier un mail",
        "openInMailer": "Ouvrir dans la messagerie",
        "unlinkConfirm": "Délier ce mail ?"
    }
    
  • Step 2 : Si en.json existe, ajouter les traductions anglaises

    ls /home/r-dev/malio-dev/Lesstime/frontend/i18n/locales/
    

    Si en.json existe, ajouter dans le bloc "mail" :

    "createTaskModal": {
        "title": "Create a task from this mail",
        "submit": "Create task",
        "projectLabel": "Project *",
        "projectPlaceholder": "Select a project",
        "groupLabel": "Group (optional)",
        "groupPlaceholder": "No group",
        "priorityLabel": "Priority (optional)",
        "priorityPlaceholder": "No priority",
        "titleHint": "Title will be filled from the mail subject.",
        "descriptionHint": "Description will be filled from the mail body."
    },
    "linkTaskModal": {
        "title": "Link to an existing task",
        "submit": "Link task",
        "searchPlaceholder": "Search a task by title…",
        "projectFilter": "Filter by project",
        "projectAll": "All projects",
        "empty": "No matching task.",
        "loading": "Searching…"
    },
    "pickerModal": {
        "title": "Link a mail to this task",
        "searchPlaceholder": "Search a mail (subject, sender)…",
        "empty": "No matching mail.",
        "loading": "Loading mails…",
        "submit": "Link this mail"
    },
    "taskTab": {
        "title": "Mails",
        "empty": "No mail linked to this task.",
        "linkButton": "Link a mail",
        "openInMailer": "Open in mailer",
        "unlinkConfirm": "Unlink this mail?"
    }
    
  • Step 3 : Valider la syntaxe JSON

    node -e "JSON.parse(require('fs').readFileSync('/home/r-dev/malio-dev/Lesstime/frontend/i18n/locales/fr.json', 'utf8')); console.log('JSON OK')"
    

    Attendu : JSON OK. Corriger la virgule manquante si erreur.

  • Step 4 : Commit

    git -C /home/r-dev/malio-dev/Lesstime add frontend/i18n/locales/fr.json frontend/i18n/locales/en.json 2>/dev/null; true
    git -C /home/r-dev/malio-dev/Lesstime commit -m "feat(mail) : clés i18n Phase 6 — createTaskModal, linkTaskModal, pickerModal, taskTab"
    

Task 3 : Composant MailCreateTaskModal.vue

Modal pour créer une tâche depuis un mail. Le backend (POST /api/mail/messages/{id}/create-task) dérive automatiquement le titre (subject) et la description (body plain text) — le frontend passe uniquement l'affectation (projet, groupe, priorité).

Le composant charge les projets au onMounted, puis charge les groupes quand le projet est sélectionné, et les priorités une seule fois. La priorité est une string (en IRI ou slug selon le backend) — utiliser <select> natif (pas MalioSelect) car les values sont des strings ou null.

  • Step 1 : Créer frontend/components/mail/MailCreateTaskModal.vue

    <script setup lang="ts">
    import type { MailMessageDetailDto } from '~/services/dto/mail'
    import type { Task } from '~/services/dto/task'
    import type { Project } from '~/services/dto/project'
    import type { TaskGroup } from '~/services/dto/task-group'
    import type { TaskPriority } from '~/services/dto/task-priority'
    import { useMailService } from '~/services/mail'
    import { useProjectService } from '~/services/projects'
    import { useTaskGroupService } from '~/services/task-groups'
    import { useTaskPriorityService } from '~/services/task-priorities'
    
    const props = defineProps<{
        /** v-model: true = modal ouvert */
        modelValue: boolean
        /** ID BDD du message source */
        messageId: number
        /** Détail du message (pour afficher sujet/expéditeur en lecture seule) */
        messageDetail: MailMessageDetailDto | null
    }>()
    
    const emit = defineEmits<{
        'update:modelValue': [value: boolean]
        /** Émis après création réussie — payload = tâche créée */
        created: [task: Task]
    }>()
    
    const { t } = useI18n()
    const mailService = useMailService()
    const projectService = useProjectService()
    const taskGroupService = useTaskGroupService()
    const priorityService = useTaskPriorityService()
    
    // ─── État formulaire ──────────────────────────────────────────────────────
    
    const projectId = ref<number | null>(null)
    const taskGroupId = ref<number | null>(null)
    const priorityId = ref<number | null>(null)
    const isSubmitting = ref(false)
    const touchedProject = ref(false)
    
    // ─── Données de référence ─────────────────────────────────────────────────
    
    const projects = ref<Project[]>([])
    const groups = ref<TaskGroup[]>([])
    const priorities = ref<TaskPriority[]>([])
    const loadingGroups = ref(false)
    
    const projectOptions = computed(() =>
        projects.value.map(p => ({ label: p.name, value: p.id }))
    )
    
    const groupOptions = computed(() => [
        { label: t('mail.createTaskModal.groupPlaceholder'), value: null },
        ...groups.value.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id })),
    ])
    
    // ─── Chargement initial ───────────────────────────────────────────────────
    
    onMounted(async () => {
        const [projs, prios] = await Promise.all([
            projectService.getAll({ archived: false }),
            priorityService.getAll(),
        ])
        projects.value = projs
        priorities.value = prios
    })
    
    // Recharger les groupes quand le projet change
    watch(projectId, async (pid) => {
        taskGroupId.value = null
        groups.value = []
        if (!pid) return
        loadingGroups.value = true
        try {
            groups.value = await taskGroupService.getByProject(pid)
        } finally {
            loadingGroups.value = false
        }
    })
    
    // Reset formulaire à l'ouverture
    watch(() => props.modelValue, (open) => {
        if (open) {
            projectId.value = null
            taskGroupId.value = null
            priorityId.value = null
            touchedProject.value = false
        }
    })
    
    // ─── Actions ──────────────────────────────────────────────────────────────
    
    function close(): void {
        emit('update:modelValue', false)
    }
    
    async function handleSubmit(): Promise<void> {
        touchedProject.value = true
        if (!projectId.value) return
    
        isSubmitting.value = true
        try {
            const task = await mailService.createTaskFromMail(props.messageId, {
                projectId: projectId.value,
                taskGroupId: taskGroupId.value ?? undefined,
                priority: priorityId.value ? `/api/task_priorities/${priorityId.value}` : undefined,
            })
            emit('created', task)
            close()
        } finally {
            isSubmitting.value = false
        }
    }
    </script>
    
    <template>
        <Teleport v-if="modelValue" to="body">
            <Transition name="mail-modal" appear>
                <div class="fixed inset-0 z-50 flex items-center justify-center p-4">
                    <!-- Backdrop -->
                    <div
                        class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
                        @click="close"
                    />
    
                    <!-- Modal -->
                    <div
                        class="relative z-10 w-full max-w-lg rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 overflow-hidden"
                        style="max-height: min(90vh, 640px)"
                    >
                        <!-- Header -->
                        <div class="flex items-center justify-between border-b border-neutral-100 bg-neutral-50/80 px-6 py-4">
                            <h2 class="text-base font-bold text-neutral-900">
                                {{ t('mail.createTaskModal.title') }}
                            </h2>
                            <MalioButtonIcon
                                icon="mdi:close"
                                aria-label="Fermer"
                                variant="ghost"
                                icon-size="20"
                                @click="close"
                            />
                        </div>
    
                        <!-- Corps -->
                        <div class="overflow-y-auto px-6 py-5 space-y-5">
                            <!-- Info mail source (lecture seule) -->
                            <div
                                v-if="messageDetail"
                                class="rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-3 text-sm"
                            >
                                <p class="font-medium text-neutral-800 truncate">
                                    {{ messageDetail.header.subject ?? t('mail.noSubject') }}
                                </p>
                                <p class="mt-0.5 text-xs text-neutral-500 truncate">
                                    {{ messageDetail.header.fromName ?? messageDetail.header.fromEmail }}
                                </p>
                                <p class="mt-2 text-xs text-neutral-400 italic">
                                    {{ t('mail.createTaskModal.titleHint') }}
                                </p>
                                <p class="text-xs text-neutral-400 italic">
                                    {{ t('mail.createTaskModal.descriptionHint') }}
                                </p>
                            </div>
    
                            <!-- Sélection projet -->
                            <div>
                                <MalioSelect
                                    v-model="projectId"
                                    :options="projectOptions"
                                    :label="t('mail.createTaskModal.projectLabel')"
                                    :empty-option-label="t('mail.createTaskModal.projectPlaceholder')"
                                    min-width="w-full"
                                />
                                <p
                                    v-if="touchedProject && !projectId"
                                    class="mt-1 text-xs text-red-500"
                                >
                                    {{ t('mail.createTaskModal.projectLabel').replace(' *', '') }} requis
                                </p>
                            </div>
    
                            <!-- Sélection groupe (optionnel, chargé après projet) -->
                            <div v-if="projectId">
                                <MalioSelect
                                    v-model="taskGroupId"
                                    :options="groupOptions"
                                    :label="t('mail.createTaskModal.groupLabel')"
                                    :empty-option-label="t('mail.createTaskModal.groupPlaceholder')"
                                    min-width="w-full"
                                    :disabled="loadingGroups"
                                />
                            </div>
    
                            <!-- Sélection priorité (optionnelle)  MalioSelect car les values sont number | null -->
                            <div>
                                <MalioSelect
                                    v-model="priorityId"
                                    :options="[
                                        { label: t('mail.createTaskModal.priorityPlaceholder'), value: null },
                                        ...priorities.map(p => ({ label: p.label, value: p.id }))
                                    ]"
                                    :label="t('mail.createTaskModal.priorityLabel')"
                                    :empty-option-label="t('mail.createTaskModal.priorityPlaceholder')"
                                    min-width="w-full"
                                />
                            </div>
                        </div>
    
                        <!-- Footer -->
                        <div class="flex justify-end gap-3 border-t border-neutral-100 px-6 py-4">
                            <MalioButton
                                variant="tertiary"
                                label="Annuler"
                                button-class="w-auto px-4"
                                @click="close"
                            />
                            <MalioButton
                                :label="t('mail.createTaskModal.submit')"
                                button-class="w-auto px-6"
                                :disabled="isSubmitting"
                                @click="handleSubmit"
                            />
                        </div>
                    </div>
                </div>
            </Transition>
        </Teleport>
    </template>
    
    <style scoped>
    .mail-modal-enter-active,
    .mail-modal-leave-active {
        transition: opacity 0.2s ease;
    }
    
    .mail-modal-enter-active > div:last-child,
    .mail-modal-leave-active > div:last-child {
        transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
    }
    
    .mail-modal-enter-from,
    .mail-modal-leave-to {
        opacity: 0;
    }
    
    .mail-modal-enter-from > div:last-child {
        transform: scale(0.95) translateY(8px);
        opacity: 0;
    }
    </style>
    

    Points clés :

    • La priorité passe en IRI /api/task_priorities/{id} dans le payload — conforme au pattern TaskModal.vue.
    • taskGroupId: undefined (pas null) si non sélectionné, pour que le backend ignore le champ (le type MailCreateTaskInput le déclare optionnel).
    • Le backdrop click ferme la modal (même pattern que TaskModal.vue).
    • Le composant ne pré-remplit PAS le titre/description : c'est le backend qui le fait depuis le mail, le frontend ne gère que les affectations.
  • Step 2 : Vérification TypeScript

    cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1 | grep "MailCreateTaskModal" | head -10
    

    Attendu : aucune erreur.

  • Step 3 : Commit

    git -C /home/r-dev/malio-dev/Lesstime add frontend/components/mail/MailCreateTaskModal.vue
    git -C /home/r-dev/malio-dev/Lesstime commit -m "feat(mail) : MailCreateTaskModal — picker projet/groupe/priorité, appel createTaskFromMail"
    

Task 4 : Composant MailLinkTaskModal.vue

Modal pour lier un mail à une tâche existante. Autocomplete en input + dropdown filtré. Debounce 300ms sur la recherche. Filtre optionnel par projet. Statut archivé exclu (paramètre archived: false).

Le service useTaskService().getFiltered() accepte des params libres — utiliser { title: searchTerm, archived: false } + éventuellement project: '/api/projects/{id}'.

  • Step 1 : Créer frontend/components/mail/MailLinkTaskModal.vue

    <script setup lang="ts">
    import type { Task } from '~/services/dto/task'
    import type { Project } from '~/services/dto/project'
    import { useMailService } from '~/services/mail'
    import { useTaskService } from '~/services/tasks'
    import { useProjectService } from '~/services/projects'
    
    const props = defineProps<{
        modelValue: boolean
        /** ID BDD du message à lier */
        messageId: number
    }>()
    
    const emit = defineEmits<{
        'update:modelValue': [value: boolean]
        /** Émis après liaison réussie — payload = id de la tâche liée */
        linked: [taskId: number]
    }>()
    
    const { t } = useI18n()
    const mailService = useMailService()
    const taskService = useTaskService()
    const projectService = useProjectService()
    
    // ─── État recherche ───────────────────────────────────────────────────────
    
    const searchQuery = ref('')
    const filterProjectId = ref<number | null>(null)
    const results = ref<Task[]>([])
    const selectedTask = ref<Task | null>(null)
    const isLoading = ref(false)
    const isSubmitting = ref(false)
    
    // ─── Projets pour le filtre ───────────────────────────────────────────────
    
    const projects = ref<Project[]>([])
    
    const projectFilterOptions = computed(() => [
        { label: t('mail.linkTaskModal.projectAll'), value: null },
        ...projects.value.map(p => ({ label: p.name, value: p.id })),
    ])
    
    onMounted(async () => {
        projects.value = await projectService.getAll({ archived: false })
    })
    
    // ─── Debounce recherche ───────────────────────────────────────────────────
    
    let debounceTimer: ReturnType<typeof setTimeout> | null = null
    
    watch([searchQuery, filterProjectId], () => {
        selectedTask.value = null
        if (debounceTimer) clearTimeout(debounceTimer)
        debounceTimer = setTimeout(() => {
            void runSearch()
        }, 300)
    })
    
    async function runSearch(): Promise<void> {
        const q = searchQuery.value.trim()
        if (!q && !filterProjectId.value) {
            results.value = []
            return
        }
        isLoading.value = true
        try {
            const params: Record<string, string | number | boolean | string[]> = {
                archived: false,
            }
            if (q) params['title'] = q
            if (filterProjectId.value) params['project'] = `/api/projects/${filterProjectId.value}`
            results.value = await taskService.getFiltered(params)
        } finally {
            isLoading.value = false
        }
    }
    
    // ─── Reset à l'ouverture ──────────────────────────────────────────────────
    
    watch(() => props.modelValue, (open) => {
        if (open) {
            searchQuery.value = ''
            filterProjectId.value = null
            results.value = []
            selectedTask.value = null
        }
    })
    
    onBeforeUnmount(() => {
        if (debounceTimer) clearTimeout(debounceTimer)
    })
    
    // ─── Actions ──────────────────────────────────────────────────────────────
    
    function close(): void {
        emit('update:modelValue', false)
    }
    
    function selectTask(task: Task): void {
        selectedTask.value = task
    }
    
    async function handleSubmit(): Promise<void> {
        if (!selectedTask.value) return
        isSubmitting.value = true
        try {
            await mailService.linkTask(props.messageId, selectedTask.value.id)
            emit('linked', selectedTask.value.id)
            close()
        } finally {
            isSubmitting.value = false
        }
    }
    </script>
    
    <template>
        <Teleport v-if="modelValue" to="body">
            <Transition name="mail-modal" appear>
                <div class="fixed inset-0 z-50 flex items-center justify-center p-4">
                    <div
                        class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
                        @click="close"
                    />
    
                    <div
                        class="relative z-10 w-full max-w-lg rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 overflow-hidden"
                        style="max-height: min(90vh, 640px)"
                    >
                        <!-- Header -->
                        <div class="flex items-center justify-between border-b border-neutral-100 bg-neutral-50/80 px-6 py-4">
                            <h2 class="text-base font-bold text-neutral-900">
                                {{ t('mail.linkTaskModal.title') }}
                            </h2>
                            <MalioButtonIcon
                                icon="mdi:close"
                                aria-label="Fermer"
                                variant="ghost"
                                icon-size="20"
                                @click="close"
                            />
                        </div>
    
                        <!-- Corps -->
                        <div class="overflow-y-auto px-6 py-5 space-y-4">
                            <!-- Filtre projet -->
                            <MalioSelect
                                v-model="filterProjectId"
                                :options="projectFilterOptions"
                                :label="t('mail.linkTaskModal.projectFilter')"
                                :empty-option-label="t('mail.linkTaskModal.projectAll')"
                                min-width="w-full"
                            />
    
                            <!-- Recherche tâche -->
                            <div>
                                <label class="mb-1 block text-sm font-medium text-neutral-700">
                                    {{ t('mail.linkTaskModal.title') }}
                                </label>
                                <input
                                    v-model="searchQuery"
                                    type="text"
                                    :placeholder="t('mail.linkTaskModal.searchPlaceholder')"
                                    class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
                                />
                            </div>
    
                            <!-- Résultats -->
                            <div class="max-h-64 overflow-y-auto rounded-md border border-neutral-200">
                                <!-- Chargement -->
                                <div
                                    v-if="isLoading"
                                    class="flex items-center justify-center py-6 text-sm text-neutral-400"
                                >
                                    <Icon name="material-symbols:progress-activity" size="18" class="mr-2 animate-spin" />
                                    {{ t('mail.linkTaskModal.loading') }}
                                </div>
    
                                <!-- Vide -->
                                <div
                                    v-else-if="!isLoading && results.length === 0 && (searchQuery.trim() || filterProjectId)"
                                    class="py-6 text-center text-sm text-neutral-400 italic"
                                >
                                    {{ t('mail.linkTaskModal.empty') }}
                                </div>
    
                                <!-- Liste résultats -->
                                <button
                                    v-for="task in results"
                                    :key="task.id"
                                    type="button"
                                    class="flex w-full items-start gap-3 px-4 py-3 text-left text-sm transition-colors hover:bg-neutral-50"
                                    :class="selectedTask?.id === task.id
                                        ? 'bg-primary-50 border-l-2 border-primary-500'
                                        : 'border-l-2 border-transparent'"
                                    @click="selectTask(task)"
                                >
                                    <Icon
                                        name="material-symbols:task-outline"
                                        size="16"
                                        class="mt-0.5 flex-shrink-0 text-neutral-400"
                                    />
                                    <div class="min-w-0 flex-1">
                                        <p class="truncate font-medium text-neutral-800">
                                            {{ task.title }}
                                        </p>
                                        <p
                                            v-if="task.project"
                                            class="truncate text-xs text-neutral-500"
                                        >
                                            {{ task.project.name }}
                                            <span v-if="task.project.code && task.number">
                                                 {{ task.project.code }}-{{ task.number }}
                                            </span>
                                        </p>
                                    </div>
                                    <Icon
                                        v-if="selectedTask?.id === task.id"
                                        name="material-symbols:check-circle"
                                        size="16"
                                        class="flex-shrink-0 text-primary-500"
                                    />
                                </button>
                            </div>
                        </div>
    
                        <!-- Footer -->
                        <div class="flex justify-end gap-3 border-t border-neutral-100 px-6 py-4">
                            <MalioButton
                                variant="tertiary"
                                label="Annuler"
                                button-class="w-auto px-4"
                                @click="close"
                            />
                            <MalioButton
                                :label="t('mail.linkTaskModal.submit')"
                                button-class="w-auto px-6"
                                :disabled="!selectedTask || isSubmitting"
                                @click="handleSubmit"
                            />
                        </div>
                    </div>
                </div>
            </Transition>
        </Teleport>
    </template>
    
    <style scoped>
    .mail-modal-enter-active,
    .mail-modal-leave-active {
        transition: opacity 0.2s ease;
    }
    
    .mail-modal-enter-active > div:last-child,
    .mail-modal-leave-active > div:last-child {
        transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
    }
    
    .mail-modal-enter-from,
    .mail-modal-leave-to {
        opacity: 0;
    }
    
    .mail-modal-enter-from > div:last-child {
        transform: scale(0.95) translateY(8px);
        opacity: 0;
    }
    </style>
    

    Points clés :

    • Debounce 300ms — le timer est nettoyé dans onBeforeUnmount pour éviter les fuites.
    • Sélection mono-tâche (click toggle sélection visuelle, un seul peut être sélectionné à la fois).
    • Le bouton "Lier" est désactivé si aucune tâche sélectionnée ou si soumission en cours.
    • La recherche ne se déclenche PAS si query vide ET pas de filtre projet (évite de charger toutes les tâches au montage).
  • Step 2 : Vérification TypeScript

    cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1 | grep "MailLinkTaskModal" | head -10
    

    Attendu : aucune erreur.

  • Step 3 : Commit

    git -C /home/r-dev/malio-dev/Lesstime add frontend/components/mail/MailLinkTaskModal.vue
    git -C /home/r-dev/malio-dev/Lesstime commit -m "feat(mail) : MailLinkTaskModal — autocomplete tâches, filtre projet, debounce 300ms"
    

Task 5 : Composant MailPickerModal.vue

Modal utilisé depuis l'onglet "Mails" de TaskModal.vue pour lier un mail existant à la tâche ouverte. Charge les messages récents depuis le dossier INBOX (ou le dernier dossier sélectionné dans le store), affiche une liste filtrable, appelle mailService.linkTask(messageId, taskId).

  • Step 1 : Créer frontend/components/mail/MailPickerModal.vue

    <script setup lang="ts">
    import type { MailMessageHeaderDto } from '~/services/dto/mail'
    import { useMailService } from '~/services/mail'
    import { useMailStore } from '~/stores/mail'
    
    const props = defineProps<{
        modelValue: boolean
        /** ID de la tâche cible (destinataire du lien) */
        taskId: number
    }>()
    
    const emit = defineEmits<{
        'update:modelValue': [value: boolean]
        /** Émis après liaison réussie — payload = id du message lié */
        linked: [messageId: number]
    }>()
    
    const { t } = useI18n()
    const mailService = useMailService()
    const mailStore = useMailStore()
    
    // ─── État ─────────────────────────────────────────────────────────────────
    
    const searchQuery = ref('')
    const allMessages = ref<MailMessageHeaderDto[]>([])
    const selectedMessage = ref<MailMessageHeaderDto | null>(null)
    const isLoading = ref(false)
    const isSubmitting = ref(false)
    
    // ─── Filtrage local (pas d'appel API par frappe — les messages sont déjà chargés) ──
    
    const filteredMessages = computed(() => {
        const q = searchQuery.value.toLowerCase().trim()
        if (!q) return allMessages.value
        return allMessages.value.filter(
            (m) =>
                (m.subject ?? '').toLowerCase().includes(q) ||
                (m.fromName ?? '').toLowerCase().includes(q) ||
                (m.fromEmail ?? '').toLowerCase().includes(q),
        )
    })
    
    // ─── Chargement à l'ouverture ─────────────────────────────────────────────
    
    watch(() => props.modelValue, async (open) => {
        if (!open) return
        searchQuery.value = ''
        selectedMessage.value = null
        isLoading.value = true
        try {
            // Utiliser les messages déjà dans le store si disponibles (dossier courant)
            // Sinon charger depuis INBOX
            const folderPath = mailStore.selectedFolderPath ?? 'INBOX'
            const page = await mailService.listMessages(folderPath, undefined, 50)
            allMessages.value = page.items
        } finally {
            isLoading.value = false
        }
    })
    
    // ─── Actions ──────────────────────────────────────────────────────────────
    
    function close(): void {
        emit('update:modelValue', false)
    }
    
    function selectMessage(msg: MailMessageHeaderDto): void {
        selectedMessage.value = msg
    }
    
    async function handleSubmit(): Promise<void> {
        if (!selectedMessage.value) return
        isSubmitting.value = true
        try {
            await mailService.linkTask(selectedMessage.value.id, props.taskId)
            emit('linked', selectedMessage.value.id)
            close()
        } finally {
            isSubmitting.value = false
        }
    }
    
    // ─── Formatage ────────────────────────────────────────────────────────────
    
    function formatDate(iso: string | null): string {
        if (!iso) return ''
        return new Date(iso).toLocaleDateString('fr', {
            day: '2-digit',
            month: 'short',
            year: 'numeric',
        })
    }
    </script>
    
    <template>
        <Teleport v-if="modelValue" to="body">
            <Transition name="mail-modal" appear>
                <div class="fixed inset-0 z-50 flex items-center justify-center p-4">
                    <div
                        class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
                        @click="close"
                    />
    
                    <div
                        class="relative z-10 w-full max-w-lg rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 overflow-hidden"
                        style="max-height: min(90vh, 640px)"
                    >
                        <!-- Header -->
                        <div class="flex items-center justify-between border-b border-neutral-100 bg-neutral-50/80 px-6 py-4">
                            <h2 class="text-base font-bold text-neutral-900">
                                {{ t('mail.pickerModal.title') }}
                            </h2>
                            <MalioButtonIcon
                                icon="mdi:close"
                                aria-label="Fermer"
                                variant="ghost"
                                icon-size="20"
                                @click="close"
                            />
                        </div>
    
                        <!-- Corps -->
                        <div class="overflow-y-auto px-6 py-5 space-y-4">
                            <!-- Recherche locale -->
                            <input
                                v-model="searchQuery"
                                type="text"
                                :placeholder="t('mail.pickerModal.searchPlaceholder')"
                                class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
                            />
    
                            <!-- Résultats -->
                            <div class="max-h-80 overflow-y-auto rounded-md border border-neutral-200 divide-y divide-neutral-100">
                                <!-- Chargement -->
                                <div
                                    v-if="isLoading"
                                    class="flex items-center justify-center py-8 text-sm text-neutral-400"
                                >
                                    <Icon name="material-symbols:progress-activity" size="18" class="mr-2 animate-spin" />
                                    {{ t('mail.pickerModal.loading') }}
                                </div>
    
                                <!-- Vide -->
                                <div
                                    v-else-if="filteredMessages.length === 0"
                                    class="py-8 text-center text-sm text-neutral-400 italic"
                                >
                                    {{ t('mail.pickerModal.empty') }}
                                </div>
    
                                <!-- Liste -->
                                <button
                                    v-for="msg in filteredMessages"
                                    :key="msg.id"
                                    type="button"
                                    class="flex w-full items-start gap-3 px-4 py-3 text-left text-sm transition-colors hover:bg-neutral-50"
                                    :class="selectedMessage?.id === msg.id
                                        ? 'bg-primary-50 border-l-2 border-primary-500'
                                        : 'border-l-2 border-transparent'"
                                    @click="selectMessage(msg)"
                                >
                                    <Icon
                                        name="material-symbols:mail-outline"
                                        size="16"
                                        class="mt-0.5 flex-shrink-0 text-neutral-400"
                                    />
                                    <div class="min-w-0 flex-1">
                                        <p class="truncate font-medium text-neutral-800">
                                            {{ msg.subject ?? t('mail.noSubject') }}
                                        </p>
                                        <p class="flex items-center gap-2 text-xs text-neutral-500">
                                            <span class="truncate">{{ msg.fromName ?? msg.fromEmail }}</span>
                                            <span class="flex-shrink-0">·</span>
                                            <span class="flex-shrink-0">{{ formatDate(msg.sentAt ?? msg.receivedAt) }}</span>
                                        </p>
                                    </div>
                                    <Icon
                                        v-if="selectedMessage?.id === msg.id"
                                        name="material-symbols:check-circle"
                                        size="16"
                                        class="flex-shrink-0 text-primary-500"
                                    />
                                </button>
                            </div>
                        </div>
    
                        <!-- Footer -->
                        <div class="flex justify-end gap-3 border-t border-neutral-100 px-6 py-4">
                            <MalioButton
                                variant="tertiary"
                                label="Annuler"
                                button-class="w-auto px-4"
                                @click="close"
                            />
                            <MalioButton
                                :label="t('mail.pickerModal.submit')"
                                button-class="w-auto px-6"
                                :disabled="!selectedMessage || isSubmitting"
                                @click="handleSubmit"
                            />
                        </div>
                    </div>
                </div>
            </Transition>
        </Teleport>
    </template>
    
    <style scoped>
    .mail-modal-enter-active,
    .mail-modal-leave-active {
        transition: opacity 0.2s ease;
    }
    
    .mail-modal-enter-active > div:last-child,
    .mail-modal-leave-active > div:last-child {
        transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
    }
    
    .mail-modal-enter-from,
    .mail-modal-leave-to {
        opacity: 0;
    }
    
    .mail-modal-enter-from > div:last-child {
        transform: scale(0.95) translateY(8px);
        opacity: 0;
    }
    </style>
    

    Points clés :

    • Le filtrage est local (sur les 50 messages chargés) — pas de debounce nécessaire, réactif instantané.
    • La note sur linkTask(messageId, taskId) : l'ordre des arguments est (mailId, taskId) dans useMailService. Ici on passe selectedMessage.value.id (mailId) et props.taskId.
    • Utilise mailStore.selectedFolderPath pour charger depuis le dossier courant (meilleure UX si l'utilisateur vient de la page mail). Fallback sur INBOX.
  • Step 2 : Vérification TypeScript

    cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1 | grep "MailPickerModal" | head -10
    

    Attendu : aucune erreur.

  • Step 3 : Commit

    git -C /home/r-dev/malio-dev/Lesstime add frontend/components/mail/MailPickerModal.vue
    git -C /home/r-dev/malio-dev/Lesstime commit -m "feat(mail) : MailPickerModal — sélection mail depuis dossier courant, liaison taskId"
    

Task 6 : Onglet "Mails" dans TaskModal.vue

Ajouter un troisième onglet mails dans TaskModal.vue. Cet onglet est visible uniquement pour les utilisateurs non-ROLE_CLIENT (check authStore.user?.roles). Il charge les mails liés via mailService.listMailsForTask(task.id) à l'ouverture de l'onglet, affiche une liste compacte cliquable, propose un bouton "Lier un mail" qui ouvre MailPickerModal.

Important : TaskModal.vue est un composant lourd. Modifier avec précision — ne pas altérer les onglets details et planning existants.

  • Step 1 : Lire le fichier actuel pour localiser les points de modification

    Lire frontend/components/task/TaskModal.vue pour identifier :

    1. La ligne const activeTab = ref<'details' | 'planning'>('details') — à étendre au type union.
    2. La nav <nav class="flex gap-6"> et sa boucle v-for="tab in ['details', 'planning']" — à étendre.
    3. La section <div v-show="activeTab === 'planning'"> — après laquelle insérer l'onglet mails.
  • Step 2 : Modifier le type activeTab et ajouter les imports

    Dans la section <script setup lang="ts"> (après le bloc de la section <template>), modifier la ligne :

    const activeTab = ref<'details' | 'planning'>('details')
    

    en :

    const activeTab = ref<'details' | 'planning' | 'mails'>('details')
    

    Ajouter les imports nécessaires (après les imports existants) :

    import { useMailService } from '~/services/mail'
    import type { MailMessageHeaderDto } from '~/services/dto/mail'
    

    Ajouter les variables d'état pour l'onglet mails (après const activeTab = ref<...>) :

    const mailService = useMailService()
    const linkedMails = ref<MailMessageHeaderDto[]>([])
    const mailsLoading = ref(false)
    const showMailPickerModal = ref(false)
    
    const isMailUser = computed(() =>
        !(authStore.user?.roles?.includes('ROLE_CLIENT') === true
        && authStore.user?.roles?.includes('ROLE_ADMIN') !== true)
    )
    

    Ajouter la fonction de chargement des mails :

    async function loadLinkedMails(): Promise<void> {
        if (!props.task || !isMailUser.value) return
        mailsLoading.value = true
        try {
            linkedMails.value = await mailService.listMailsForTask(props.task.id)
        } catch {
            linkedMails.value = []
        } finally {
            mailsLoading.value = false
        }
    }
    

    Étendre le watcher existant watch(() => props.modelValue, ...) pour charger les mails quand l'onglet mails est actif, OU ajouter un watcher sur activeTab :

    watch(activeTab, async (tab) => {
        if (tab === 'mails' && props.task) {
            await loadLinkedMails()
        }
    })
    

    Ajouter le handler de liaison réussie (appelé par MailPickerModal) :

    async function handleMailLinked(): Promise<void> {
        showMailPickerModal.value = false
        await loadLinkedMails()
    }
    

    Ajouter la fonction de formatage de date (utilisée dans l'onglet) :

    function formatMailDate(iso: string | null): string {
        if (!iso) return ''
        return new Date(iso).toLocaleDateString('fr', {
            day: '2-digit',
            month: 'short',
        })
    }
    
  • Step 3 : Modifier le template — nav des onglets

    Remplacer la boucle d'onglets :

    <button
        v-for="tab in ['details', 'planning']"
        :key="tab"
        type="button"
        class="px-1 pb-3 text-sm font-semibold transition"
        :class="activeTab === tab
            ? 'border-b-2 border-primary-500 text-primary-500'
            : 'text-neutral-500 hover:text-neutral-700'"
        @click="activeTab = tab as 'details' | 'planning'"
    >
        {{ $t(`tasks.${tab}Tab`) }}
    </button>
    

    par :

    <button
        v-for="tab in availableTabs"
        :key="tab"
        type="button"
        class="px-1 pb-3 text-sm font-semibold transition"
        :class="activeTab === tab
            ? 'border-b-2 border-primary-500 text-primary-500'
            : 'text-neutral-500 hover:text-neutral-700'"
        @click="activeTab = tab as 'details' | 'planning' | 'mails'"
    >
        {{ tab === 'mails' ? $t('mail.taskTab.title') : $t(`tasks.${tab}Tab`) }}
    </button>
    

    Et ajouter le computed availableTabs dans le script :

    const availableTabs = computed(() => {
        const base: Array<'details' | 'planning' | 'mails'> = ['details', 'planning']
        if (isEditing.value && isMailUser.value) base.push('mails')
        return base
    })
    

    Note : l'onglet "Mails" n'apparaît qu'en mode édition (tâche existante avec ID) et uniquement pour les non-ROLE_CLIENT.

  • Step 4 : Ajouter le contenu de l'onglet mails dans le template

    Après la balise fermante </div> du <div v-show="activeTab === 'planning'">, ajouter :

    <!-- Onglet Mails -->
    <div v-show="activeTab === 'mails'" class="space-y-4">
        <!-- Chargement -->
        <div v-if="mailsLoading" class="flex items-center justify-center py-8">
            <Icon name="material-symbols:progress-activity" size="24" class="animate-spin text-neutral-400" />
        </div>
    
        <!-- Vide -->
        <div
            v-else-if="linkedMails.length === 0"
            class="flex flex-col items-center justify-center gap-3 py-8 text-center"
        >
            <Icon name="material-symbols:mail-outline" size="32" class="text-neutral-300" />
            <p class="text-sm text-neutral-400 italic">{{ $t('mail.taskTab.empty') }}</p>
        </div>
    
        <!-- Liste mails liés -->
        <div v-else class="divide-y divide-neutral-100 rounded-lg border border-neutral-200">
            <NuxtLink
                v-for="mail in linkedMails"
                :key="mail.id"
                :to="`/mail?messageId=${mail.id}`"
                class="flex items-start gap-3 px-4 py-3 text-sm transition-colors hover:bg-neutral-50"
                :title="$t('mail.taskTab.openInMailer')"
            >
                <Icon
                    name="material-symbols:mail-outline"
                    size="16"
                    class="mt-0.5 flex-shrink-0 text-neutral-400"
                />
                <div class="min-w-0 flex-1">
                    <p class="truncate font-medium text-neutral-800">
                        {{ mail.subject ?? $t('mail.noSubject') }}
                    </p>
                    <p class="flex items-center gap-2 text-xs text-neutral-500">
                        <span class="truncate">{{ mail.fromName ?? mail.fromEmail }}</span>
                        <span>·</span>
                        <span class="flex-shrink-0">{{ formatMailDate(mail.sentAt ?? mail.receivedAt) }}</span>
                    </p>
                </div>
                <Icon
                    name="material-symbols:open-in-new"
                    size="14"
                    class="flex-shrink-0 text-neutral-300"
                />
            </NuxtLink>
        </div>
    
        <!-- Bouton lier un mail -->
        <div class="pt-2">
            <MalioButton
                :label="$t('mail.taskTab.linkButton')"
                variant="secondary"
                icon-name="material-symbols:link"
                icon-position="left"
                :icon-size="14"
                button-class="w-auto"
                @click="showMailPickerModal = true"
            />
        </div>
    
        <!-- Modal picker mail -->
        <MailPickerModal
            v-if="task"
            v-model="showMailPickerModal"
            :task-id="task.id"
            @linked="handleMailLinked"
        />
    </div>
    
  • Step 5 : Reset de l'onglet actif à l'ouverture

    Dans le watcher existant watch(() => props.modelValue, async (open) => { if (open) { activeTab.value = 'details' ... } }), l'onglet est déjà resetté à 'details' — vérifier que c'est bien le cas (ne pas modifier cette ligne).

    Ajouter le reset de linkedMails :

    // À l'intérieur du bloc `if (open) {`, après `activeTab.value = 'details'`
    linkedMails.value = []
    
  • Step 6 : Vérification TypeScript

    cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1 | grep -E "TaskModal|mail" | head -20
    

    Attendu : aucune nouvelle erreur.

  • Step 7 : Commit

    git -C /home/r-dev/malio-dev/Lesstime add frontend/components/task/TaskModal.vue
    git -C /home/r-dev/malio-dev/Lesstime commit -m "feat(mail) : onglet Mails dans TaskModal — liste mails liés, bouton lier, MailPickerModal"
    

Task 7 : Brancher les handlers dans pages/mail.vue

Remplacer les placeholders console.warn par l'ouverture des modals. Ajouter les refs showCreateTaskModal et showLinkTaskModal avec les states associés.

  • Step 1 : Modifier frontend/pages/mail.vue

    Dans la section <script setup lang="ts">, ajouter les imports :

    import type { Task } from '~/services/dto/task'
    

    Ajouter les refs d'état des modals (après les handlers existants) :

    // ─── Modals Phase 6 ────────────────────────────────────────────────────────
    
    const showCreateTaskModal = ref(false)
    const showLinkTaskModal = ref(false)
    const activeMailIdForModal = ref<number | null>(null)
    

    Remplacer les fonctions placeholder :

    // Avant (à supprimer) :
    function handleCreateTask(mailId: number): void {
        console.warn('[mail] handleCreateTask mailId=', mailId, '— modal à implémenter en Phase 6')
    }
    
    function handleLinkTask(mailId: number): void {
        console.warn('[mail] handleLinkTask mailId=', mailId, '— modal à implémenter en Phase 6')
    }
    
    // Après (à insérer à la place) :
    function handleCreateTask(mailId: number): void {
        activeMailIdForModal.value = mailId
        showCreateTaskModal.value = true
    }
    
    function handleLinkTask(mailId: number): void {
        activeMailIdForModal.value = mailId
        showLinkTaskModal.value = true
    }
    
    function handleTaskCreated(task: Task): void {
        showCreateTaskModal.value = false
        // La tâche est créée et liée côté backend — pas d'action supplémentaire côté page mail
        // Le toast de succès est déjà géré par useMailService.createTaskFromMail (toastSuccessKey)
    }
    
    function handleTaskLinked(taskId: number): void {
        showLinkTaskModal.value = false
        // Idem — toast géré par useMailService.linkTask
    }
    

    Dans le <template>, ajouter les modals juste avant la balise </div> de fermeture principale :

    <!-- Modal créer tâche depuis mail -->
    <MailCreateTaskModal
        v-if="activeMailIdForModal !== null"
        v-model="showCreateTaskModal"
        :message-id="activeMailIdForModal"
        :message-detail="selectedMessageDetail"
        @created="handleTaskCreated"
    />
    
    <!-- Modal lier mail à tâche -->
    <MailLinkTaskModal
        v-if="activeMailIdForModal !== null"
        v-model="showLinkTaskModal"
        :message-id="activeMailIdForModal"
        @linked="handleTaskLinked"
    />
    
  • Step 2 : Vérification TypeScript

    cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1 | grep "mail.vue" | head -10
    

    Attendu : aucune erreur.

  • Step 3 : Commit

    git -C /home/r-dev/malio-dev/Lesstime add frontend/pages/mail.vue
    git -C /home/r-dev/malio-dev/Lesstime commit -m "feat(mail) : pages/mail.vue — branche handlers Phase 6 (MailCreateTaskModal + MailLinkTaskModal)"
    

Task 8 : Validation TypeScript globale + tests manuels end-to-end

  • Step 1 : TypeScript strict — zéro erreur Phase 6

    cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1
    

    Attendu : sortie vide. Erreurs fréquentes à anticiper :

    • Type '"mails"' is not assignable to type '"details" | "planning"' → vérifier que le type union du ref est bien 'details' | 'planning' | 'mails' ET que le cast as 'details' | 'planning' | 'mails' est appliqué dans le handler click de la nav.
    • Property 'id' does not exist on type 'Task | null' → le v-if="task" dans le template assure que task est non-null dans la portée — TypeScript peut ne pas l'inférer dans le script ; utiliser props.task?.id ou un guard explicite.
    • Cannot find name 'MailPickerModal' → Nuxt auto-importe les composants de components/ — vérifier le nom de fichier (PascalCase, chemin components/mail/MailPickerModal.vue).
    • Module '"~/services/mail"' has no exported member 'useMailService' → vérifier l'import dans TaskModal.vue ; le service est un named export.
  • Step 2 : Démarrer le serveur dev

    cd /home/r-dev/malio-dev/Lesstime && make dev-nuxt
    

    Attendu : Nuxt ready sur http://localhost:3002. Aucune erreur de compilation Nuxt.

  • Step 3 : Workflow 1 — Créer une tâche depuis un mail

    • Se connecter avec alice / alice (ROLE_USER)
    • Naviguer vers http://localhost:3002/mail
    • Sélectionner un dossier et un message (si backend Phase 3 dispo) ou forcer via DevTools (injecter selectedMessageDetail dans le store Pinia)
    • Cliquer "Créer une tâche" dans MailMessageViewer
    • Vérifier : MailCreateTaskModal s'ouvre, affiche le sujet + expéditeur en lecture seule
    • Sélectionner un projet (ex : le projet SIRH des fixtures)
    • Optionnel : sélectionner un groupe
    • Cliquer "Créer la tâche"
    • Vérifier : toast succès "Tâche créée depuis le mail." + modal se ferme
    • Naviguer vers le projet correspondant → la nouvelle tâche apparaît avec titre = subject du mail
    • Ouvrir la tâche → onglet "Mails" → le mail source apparaît dans la liste
  • Step 4 : Workflow 2 — Lier une tâche existante à un mail

    • Sur la page /mail, sélectionner un message
    • Cliquer "Lier à une tâche" dans MailMessageViewer
    • Vérifier : MailLinkTaskModal s'ouvre
    • Taper quelques lettres dans la recherche → les tâches s'affichent avec debounce
    • Sélectionner une tâche (ex : "Réunion de suivi hebdomadaire" depuis les fixtures)
    • Cliquer "Lier la tâche"
    • Vérifier : toast succès "Mail lié à la tâche." + modal se ferme
    • Ouvrir la tâche liée → onglet "Mails" → le mail apparaît dans la liste
  • Step 5 : Workflow 3 — Lier un mail depuis l'onglet Mails du TaskModal

    • Naviguer vers un projet, ouvrir une tâche via TaskModal
    • Cliquer sur l'onglet "Mails" (visible uniquement si ROLE_USER/ROLE_ADMIN)
    • Vérifier : l'onglet charge et affiche "Aucun mail lié" si vide
    • Cliquer "Lier un mail"
    • Vérifier : MailPickerModal s'ouvre, charge les mails du dossier courant (ou INBOX)
    • Rechercher/sélectionner un mail, cliquer "Lier ce mail"
    • Vérifier : toast + liste rafraîchie dans l'onglet "Mails"
  • Step 6 : Workflow 4 — Navigation depuis l'onglet Mails vers /mail

    • Dans l'onglet "Mails" du TaskModal, cliquer sur un mail lié
    • Vérifier : navigation vers /mail?messageId={id} + le mail s'ouvre automatiquement (deep-link Phase 5)
  • Step 7 : Vérification ROLE_CLIENT

    • Se connecter avec client-liot / client
    • Naviguer vers /mail → redirect /portal (Phase 5)
    • Via le portal, ouvrir un ticket client → TaskModal ne doit pas afficher l'onglet "Mails" (computed availableTabs l'exclut pour ROLE_CLIENT)
  • Step 8 : Commit de correction si nécessaire

    git -C /home/r-dev/malio-dev/Lesstime add \
        frontend/components/mail/ \
        frontend/components/task/TaskModal.vue \
        frontend/pages/mail.vue
    git -C /home/r-dev/malio-dev/Lesstime commit -m "fix(mail) : corrections post-test Phase 6 (TypeScript + comportement modals)"
    

Exigences techniques

  • <script setup lang="ts"> partout — aucun Options API
  • 4 espaces d'indentation — convention Lesstime
  • TypeScript strict : 0 erreur npx tsc --noEmit à la fin de chaque tâche
  • Modals : pattern <Teleport to="body"> + <Transition name="mail-modal"> + backdrop click (identique à TaskModal.vue)
  • MailCreateTaskInput : priority est transmis en IRI /api/task_priorities/{id} (string), pas en id numérique — conforme au pattern existant dans TaskModal.vue
  • Autocomplete : debounce 300ms dans MailLinkTaskModal — timer nettoyé dans onBeforeUnmount
  • Filtrage mail : local dans MailPickerModal (liste chargée une fois, filtrée en computed) — pas d'appel API par frappe
  • ROLE_CLIENT : l'onglet "Mails" dans TaskModal est exclu via availableTabs computed — double protection (backend bloque déjà les endpoints mail pour ROLE_CLIENT)
  • Toast : géré automatiquement par useApi via toastSuccessKey dans les méthodes du service — ne pas dupliquer dans les composants
  • NuxtLink pour la navigation vers /mail?messageId=X depuis l'onglet "Mails" (pas de router.push) — meilleure accessibilité
  • v-if="task" dans TaskModal : utiliser props.task (non null en mode édition) avec guard explicite pour les appels service
  • Format commits : feat(mail) : <message> (espace avant :), 1 commit par composant/fichier principal
  • Pas de console.warn dans le code final — supprimer les placeholders Phase 5

Fichiers créés/modifiés — récapitulatif

Fichier Type Action
frontend/components/mail/MailCreateTaskModal.vue Composant Vue Créer
frontend/components/mail/MailLinkTaskModal.vue Composant Vue Créer
frontend/components/mail/MailPickerModal.vue Composant Vue Créer
frontend/components/task/TaskModal.vue Composant Vue Modifier (onglet mails, state, watcher)
frontend/pages/mail.vue Page Nuxt Modifier (handlers + modals dans template)
frontend/i18n/locales/fr.json i18n Modifier (sous-clés mail.createTaskModal, mail.linkTaskModal, mail.pickerModal, mail.taskTab)
frontend/i18n/locales/en.json i18n Modifier si existant

Fichiers Phase 4/5 utilisés (ne pas modifier sauf indication) :

Fichier Utilisation dans Phase 6
frontend/services/mail.ts createTaskFromMail, linkTask, listMailsForTask
frontend/services/dto/mail.ts MailMessageDetailDto, MailMessageHeaderDto, MailCreateTaskInput
frontend/services/tasks.ts getFiltered() dans MailLinkTaskModal
frontend/services/projects.ts getAll() dans MailCreateTaskModal, MailLinkTaskModal
frontend/services/task-groups.ts getByProject() dans MailCreateTaskModal
frontend/services/task-priorities.ts getAll() dans MailCreateTaskModal
frontend/stores/mail.ts selectedFolderPath dans MailPickerModal
frontend/components/mail/MailMessageViewer.vue Émet createTask / linkTask (déjà câblé en Phase 5)

Output attendu — critères d'acceptation Phase 6

Critère Vérification
TypeScript strict : 0 erreur cd frontend && npx tsc --noEmit → sortie vide
Workflow 1 complet Mail → "Créer tâche" → tâche créée avec subject comme titre → visible dans onglet Mails du TaskModal
Workflow 2 complet Mail → "Lier à une tâche" → sélection + liaison → mail visible dans onglet Mails
Workflow 3 complet TaskModal onglet Mails → "Lier un mail" → MailPickerModal → liaison → liste rafraîchie
Workflow 4 complet Onglet Mails → click mail → navigation /mail?messageId=X → deep-link fonctionne
ROLE_CLIENT isolé Onglet "Mails" absent pour ROLE_CLIENT (computed availableTabs)
Aucun console.warn résiduel DevTools console → aucun warn [mail] Phase 5
Pas d'erreur Vue/Nuxt DevTools console → aucune erreur rouge

La Phase 7 (Admin config + sidebar + polish) peut commencer dès que tous ces critères sont satisfaits.