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

1510 lines
65 KiB
Markdown

# 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**
```bash
git -C /home/r-dev/malio-dev/Lesstime branch --show-current
```
Attendu : `feat/mail-integration`. Si non :
```bash
git -C /home/r-dev/malio-dev/Lesstime checkout feat/mail-integration
```
- [ ] **Step 2 : Vérifier que les fichiers Phase 5 existent**
```bash
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**
```bash
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": { ... }` :
```json
"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**
```bash
ls /home/r-dev/malio-dev/Lesstime/frontend/i18n/locales/
```
Si `en.json` existe, ajouter dans le bloc `"mail"` :
```json
"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**
```bash
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**
```bash
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`**
```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**
```bash
cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1 | grep "MailCreateTaskModal" | head -10
```
Attendu : aucune erreur.
- [ ] **Step 3 : Commit**
```bash
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`**
```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**
```bash
cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1 | grep "MailLinkTaskModal" | head -10
```
Attendu : aucune erreur.
- [ ] **Step 3 : Commit**
```bash
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`**
```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**
```bash
cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1 | grep "MailPickerModal" | head -10
```
Attendu : aucune erreur.
- [ ] **Step 3 : Commit**
```bash
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 :
```typescript
const activeTab = ref<'details' | 'planning'>('details')
```
en :
```typescript
const activeTab = ref<'details' | 'planning' | 'mails'>('details')
```
Ajouter les imports nécessaires (après les imports existants) :
```typescript
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<...>`) :
```typescript
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 :
```typescript
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` :
```typescript
watch(activeTab, async (tab) => {
if (tab === 'mails' && props.task) {
await loadLinkedMails()
}
})
```
Ajouter le handler de liaison réussie (appelé par `MailPickerModal`) :
```typescript
async function handleMailLinked(): Promise<void> {
showMailPickerModal.value = false
await loadLinkedMails()
}
```
Ajouter la fonction de formatage de date (utilisée dans l'onglet) :
```typescript
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 :
```html
<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 :
```html
<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 :
```typescript
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 :
```html
<!-- 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` :
```typescript
// À l'intérieur du bloc `if (open) {`, après `activeTab.value = 'details'`
linkedMails.value = []
```
- [ ] **Step 6 : Vérification TypeScript**
```bash
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**
```bash
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 :
```typescript
import type { Task } from '~/services/dto/task'
```
Ajouter les refs d'état des modals (après les handlers existants) :
```typescript
// ─── Modals Phase 6 ────────────────────────────────────────────────────────
const showCreateTaskModal = ref(false)
const showLinkTaskModal = ref(false)
const activeMailIdForModal = ref<number | null>(null)
```
Remplacer les fonctions placeholder :
```typescript
// 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')
}
```
```typescript
// 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 :
```html
<!-- 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**
```bash
cd /home/r-dev/malio-dev/Lesstime/frontend && npx tsc --noEmit 2>&1 | grep "mail.vue" | head -10
```
Attendu : aucune erreur.
- [ ] **Step 3 : Commit**
```bash
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**
```bash
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**
```bash
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**
```bash
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.