# Correctifs UI workflow — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommandé) ou superpowers:executing-plans pour exécuter ce plan tâche par tâche. Les étapes utilisent la syntaxe checkbox (`- [ ]`).

**Goal:** Corriger les régressions UI introduites par les workflows (D&D, sélecteur de statut, cartes, couleurs) et améliorer l'UX mail/modales, sur la base de `docs/superpowers/specs/2026-05-20-workflow-ui-fixes-design.md`.

**Architecture:** Une brique partagée (filtrage des statuts par workflow + palette de catégories + composant modale réutilisable) consommée par les autres chantiers. Backend modifié uniquement pour l'endpoint `create-task` (#6). Correction de données prod (#4) via migration Doctrine.

**Tech Stack:** Symfony 8 / API Platform 4 / Doctrine (backend, PHPUnit) ; Nuxt 4 / Vue 3 / Pinia / Tailwind / `@malio/layer-ui` (frontend).

> **Note testing (importante).** Lesstime **n'a pas de test runner frontend** (vérifié : pas de vitest/jest dans `frontend/package.json`). La discipline TDD ne s'applique donc qu'au **backend** (PHPUnit via `make test`). Pour le **frontend**, chaque tâche se vérifie par : (1) `npm run build:dist` qui doit réussir (exit 0), puis (2) contrôle navigateur via Chrome DevTools MCP sur `http://localhost:8082` (DOM/visuel). **Toujours hard-reload sans cache** après build (le navigateur cache les chunks JS hashés). Login dev avec données prod importées : `Matthieu` / `admin`.

> **Branche.** Créer une branche d'implémentation depuis `develop` (ex. `fix/workflow-ui-fixes`) avant de commencer. Commits fréquents, format `<type>(<scope>) : <message>`.

---

## Ordre d'exécution (dépendances)

1. **Task 1** — Brique front : palette de catégories + helper contraste (`#4b`, réutilisé par #1)
2. **Task 2** — Composant `AppModal` réutilisable (`#7`)
3. **Task 3** — Filtrage du sélecteur de statut par workflow dans TaskModal (`#2`)
4. **Task 4** — Drag & drop dans « Mes tâches » + entêtes teintées (`#1` + `#4b`)
5. **Task 5** — Backend : endpoint `create-task` (statut + assigné, sans priorité) (`#6` back)
6. **Task 6** — Frontend : modale de création depuis mail (`#6` front, sur AppModal)
7. **Task 7** — Suppression du bouton « Lier un mail » (`#5`)
8. **Task 8** — Cartes responsive (`#3`)
9. **Task 9** — Couleurs par défaut par catégorie + migration data prod (`#4a` + `#4c`)
10. **Task 10** — Migration de TaskModal vers AppModal (`#7`)

---

## Task 1 : Palette de catégories + helper de contraste

**Files:**
- Modify: `frontend/services/dto/workflow.ts`

- [ ] **Step 1 : Ajouter la palette et le helper de contraste**

Dans `frontend/services/dto/workflow.ts`, après `STATUS_CATEGORY_LABEL` (l.5-11), ajouter :

```ts
/** Palette canonique des catégories (couleurs « classiques »), indépendante des workflows. */
export const STATUS_CATEGORY_COLOR: Record<StatusCategory, string> = {
    todo: '#222783',
    in_progress: '#4A90D9',
    blocked: '#C62828',
    review: '#FF8F00',
    done: '#26A69A',
}

/** Renvoie '#1f2937' (foncé) ou '#ffffff' (blanc) selon la luminance du fond, pour rester lisible. */
export function contrastText(hex: string): string {
    const c = hex.replace('#', '')
    const r = parseInt(c.slice(0, 2), 16)
    const g = parseInt(c.slice(2, 4), 16)
    const b = parseInt(c.slice(4, 6), 16)
    const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255
    return lum > 0.6 ? '#1f2937' : '#ffffff'
}
```

- [ ] **Step 2 : Vérifier le build**

Run: `cd frontend && npm run build:dist`
Expected: exit 0, aucune erreur TypeScript.

- [ ] **Step 3 : Commit**

```bash
git add frontend/services/dto/workflow.ts
git commit -m "feat(workflow) : palette de catégories canonique + helper de contraste"
```

---

## Task 2 : Composant modale réutilisable `AppModal` (#7)

**Files:**
- Create: `frontend/components/ui/AppModal.vue`

- [ ] **Step 1 : Créer `AppModal.vue`**

```vue
<script setup lang="ts">
const props = withDefaults(defineProps<{
    modelValue: boolean
    title?: string
    /** Largeur max du panneau */
    width?: 'sm' | 'md' | 'lg' | 'xl'
}>(), {
    title: '',
    width: 'md',
})

const emit = defineEmits<{
    'update:modelValue': [value: boolean]
}>()

const WIDTH_CLASS: Record<NonNullable<typeof props.width>, string> = {
    sm: 'max-w-md',
    md: 'max-w-lg',
    lg: 'max-w-2xl',
    xl: 'max-w-4xl',
}

function close(): void {
    emit('update:modelValue', false)
}
</script>

<template>
    <Teleport v-if="modelValue" to="body">
        <Transition name="app-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 flex max-h-[90vh] w-full flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5"
                    :class="WIDTH_CLASS[width]"
                >
                    <!-- Header (fixe) -->
                    <div class="flex shrink-0 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">
                            <slot name="title">{{ title }}</slot>
                        </h2>
                        <MalioButtonIcon
                            icon="mdi:close"
                            aria-label="Fermer"
                            variant="ghost"
                            icon-size="20"
                            @click="close"
                        />
                    </div>

                    <!-- Body (scrollable) -->
                    <div class="min-h-0 flex-1 overflow-y-auto px-6 py-5">
                        <slot />
                    </div>

                    <!-- Footer (sticky) -->
                    <div
                        v-if="$slots.footer"
                        class="flex shrink-0 justify-end gap-3 border-t border-neutral-100 bg-white px-6 py-4"
                    >
                        <slot name="footer" />
                    </div>
                </div>
            </div>
        </Transition>
    </Teleport>
</template>

<style scoped>
.app-modal-enter-active,
.app-modal-leave-active {
    transition: opacity 0.2s ease;
}
.app-modal-enter-active > div:last-child,
.app-modal-leave-active > div:last-child {
    transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
}
.app-modal-enter-from,
.app-modal-leave-to {
    opacity: 0;
}
.app-modal-enter-from > div:last-child {
    transform: scale(0.95) translateY(8px);
    opacity: 0;
}
</style>
```

- [ ] **Step 2 : Build**

Run: `cd frontend && npm run build:dist`
Expected: exit 0.

- [ ] **Step 3 : Commit**

```bash
git add frontend/components/ui/AppModal.vue
git commit -m "feat(ui) : composant AppModal réutilisable (header fixe / body scrollable / footer sticky)"
```

> AppModal sera consommé par MailCreateTaskModal (Task 6) et TaskModal (Task 10).

---

## Task 3 : Filtrer le sélecteur de statut par workflow dans TaskModal (#2)

**Files:**
- Modify: `frontend/components/task/TaskModal.vue` (statusOptions ~l.674-676)

**Contexte vérifié :** TaskModal reçoit déjà `:projects` (`Project[]` avec `workflow.statuses`). Le projet effectif est `showProjectSelect ? form.projectId : props.projectId` (cf. l.717). `props.statuses` (global) devient un fallback.

- [ ] **Step 1 : Remplacer `statusOptions`**

Remplacer (l.674-676) :

```ts
const statusOptions = computed(() =>
    props.statuses.map(s => ({ label: s.label, value: s.id }))
)
```

par :

```ts
const effectiveProjectId = computed(() =>
    showProjectSelect.value ? form.projectId : props.projectId,
)

const statusOptions = computed(() => {
    const project = props.projects?.find(p => p.id === effectiveProjectId.value)
    const wfStatuses = project?.workflow?.statuses ?? props.statuses
    const opts = wfStatuses.map(s => ({ label: s.label, value: s.id }))
    // Garder le statut courant s'il n'appartient pas (plus) au workflow, pour ne pas le perdre.
    const current = props.task?.status
    if (current && !wfStatuses.some(s => s.id === current.id)) {
        opts.unshift({ label: current.label, value: current.id })
    }
    return opts
})
```

> Si une variable `effectiveProjectId`/`activeProjectId` existe déjà (vérifier autour de l.717), réutiliser celle-ci au lieu d'en redéclarer une.

- [ ] **Step 2 : Build**

Run: `cd frontend && npm run build:dist`
Expected: exit 0.

- [ ] **Step 3 : Vérification navigateur (Chrome MCP)**

1. Hard-reload `http://localhost:8082` (cache ignoré), login `Matthieu`/`admin`.
2. Ouvrir une tâche d'un projet **Standard** (ex. `LST-49` via « Mes tâches »).
3. Ouvrir le sélecteur « Statut ».
Expected : **5 options** (les statuts du workflow Standard) — plus aucun statut ERP (« Prêt à dev », « En dev », « Mergé », « Validation client », « Validé prod », « Abandonné »).
4. Ouvrir une tâche du projet **STARSEED** (workflow ERP, code `ERP-…`).
Expected : uniquement les statuts ERP.

- [ ] **Step 4 : Commit**

```bash
git add frontend/components/task/TaskModal.vue
git commit -m "fix(task) : sélecteur de statut filtré par le workflow du projet"
```

---

## Task 4 : Drag & drop « Mes tâches » + entêtes teintées (#1 + #4b)

**Files:**
- Create: `frontend/components/task/StatusPickerPopover.vue`
- Modify: `frontend/pages/my-tasks.vue` (template kanban ~l.394-424 ; script ~l.118-140)
- Modify: `frontend/services/tasks.ts` (réutiliser `update()` existant)

**Contexte vérifié :** `TaskCard.vue` pose déjà `dataTransfer.setData('text/plain', task.id)` au `dragstart`. `my-tasks.vue` n'a **aucun** handler `@drop`/`@dragover`. Les colonnes itèrent sur `CATEGORIES` (l.119). `tasks.value` contient les tâches affichées. `tasks.ts` expose `update(id, payload: Partial<TaskWrite>)` → `PATCH /tasks/{id}` ; le statut s'écrit en IRI (`status: '/api/task_statuses/{id}'`, cf. TaskModal l.1070).

- [ ] **Step 1 : Créer le popover de désambiguïsation**

`frontend/components/task/StatusPickerPopover.vue` :

```vue
<script setup lang="ts">
import type { TaskStatus } from '~/services/dto/task-status'

defineProps<{
    statuses: TaskStatus[]
    x: number
    y: number
}>()

const emit = defineEmits<{
    pick: [status: TaskStatus]
    cancel: []
}>()
</script>

<template>
    <Teleport to="body">
        <div class="fixed inset-0 z-[60]" @click="emit('cancel')" />
        <div
            class="fixed z-[61] min-w-44 rounded-lg border border-neutral-200 bg-white py-1 shadow-xl"
            :style="{ left: x + 'px', top: y + 'px' }"
        >
            <button
                v-for="s in statuses"
                :key="s.id"
                type="button"
                class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-neutral-50"
                @click="emit('pick', s)"
            >
                <span class="h-3 w-3 shrink-0 rounded-full" :style="{ backgroundColor: s.color }" />
                {{ s.label }}
            </button>
        </div>
    </Teleport>
</template>
```

- [ ] **Step 2 : Ajouter la logique de drop dans `my-tasks.vue` (script)**

Dans `<script setup>`, ajouter les imports et l'état (près des helpers kanban, l.118+) :

```ts
import { STATUS_CATEGORY_COLOR, contrastText } from '~/services/dto/workflow'
import type { TaskStatus } from '~/services/dto/task-status'

const dragOverCategory = ref<StatusCategory | null>(null)
const pendingPicker = ref<{ statuses: TaskStatus[], task: Task, x: number, y: number } | null>(null)

function statusesForTaskCategory(task: Task, category: StatusCategory): TaskStatus[] {
    const wf = task.project?.workflow
    if (!wf) return []
    return wf.statuses.filter(s => s.category === category)
}

async function applyStatus(task: Task, status: TaskStatus): Promise<void> {
    await taskService.update(task.id, { status: `/api/task_statuses/${status.id}` })
    await loadTasks()   // recharge la liste (utiliser la fonction de rechargement existante)
}

function onDrop(category: StatusCategory, event: DragEvent): void {
    dragOverCategory.value = null
    const taskId = Number(event.dataTransfer?.getData('text/plain'))
    const task = tasks.value.find(t => t.id === taskId)
    if (!task) return

    const candidates = statusesForTaskCategory(task, category)
    if (candidates.length === 0) {
        toast.error(t('myTasks.dropRefused'))   // 0 statut dans cette catégorie pour ce workflow
        return
    }
    if (candidates.length === 1) {
        void applyStatus(task, candidates[0])
        return
    }
    // ≥2 : popover de choix ancré au point de drop
    pendingPicker.value = { statuses: candidates, task, x: event.clientX, y: event.clientY }
}

function onPickerChoice(status: TaskStatus): void {
    if (pendingPicker.value) void applyStatus(pendingPicker.value.task, status)
    pendingPicker.value = null
}
```

> Adapter `loadTasks()` / `toast` / `t` aux noms réels du fichier (vérifier la fonction de rechargement des tâches et l'import du toast déjà utilisés dans `my-tasks.vue`).

- [ ] **Step 3 : Brancher le template kanban (#1) + entêtes teintées (#4b)**

Remplacer le bloc colonne (l.397-404) par :

```vue
<div
    v-for="cat in CATEGORIES"
    :key="cat"
    class="flex min-w-40 flex-1 flex-col rounded-lg bg-neutral-50 transition"
    :class="dragOverCategory === cat ? 'ring-2 ring-primary-400' : ''"
    @dragover.prevent="dragOverCategory = cat"
    @dragleave="dragOverCategory = null"
    @drop="onDrop(cat, $event)"
>
    <div
        class="shrink-0 rounded-t-lg px-4 py-3 text-sm font-bold"
        :style="{ backgroundColor: STATUS_CATEGORY_COLOR[cat], color: contrastText(STATUS_CATEGORY_COLOR[cat]) }"
    >
        {{ STATUS_CATEGORY_LABEL[cat] }} ({{ tasksByCategory(cat).length }})
    </div>
```

Puis, juste avant la fermeture du `<template>` (à côté de la TaskModal), ajouter le popover :

```vue
<StatusPickerPopover
    v-if="pendingPicker"
    :statuses="pendingPicker.statuses"
    :x="pendingPicker.x"
    :y="pendingPicker.y"
    @pick="onPickerChoice"
    @cancel="pendingPicker = null"
/>
```

- [ ] **Step 4 : Ajouter la clé i18n `myTasks.dropRefused`**

Dans `frontend/i18n/locales/fr.json` (et les autres locales présentes), sous `myTasks` : `"dropRefused": "Aucun statut de cette colonne dans le workflow de ce projet"`.

- [ ] **Step 5 : Build**

Run: `cd frontend && npm run build:dist`
Expected: exit 0.

- [ ] **Step 6 : Vérification navigateur (Chrome MCP)**

1. Hard-reload, login, aller à « Mes tâches » (vue kanban).
Expected : entêtes de colonnes **colorées** (todo indigo, in_progress bleu, blocked rouge, review ambre **texte foncé**, done sarcelle).
2. Glisser une carte d'un projet **Standard** de « À faire » vers « En cours ».
Expected : le statut passe à « En cours » (1 seul statut in_progress → direct), la carte se déplace.
3. Glisser une carte du projet **STARSEED** (workflow ERP) vers « En validation » (la catégorie `review` a ≥2 statuts ERP : En review, Mergé, Validation client).
Expected : **popover** au point de drop listant ces statuts ; le choix applique le statut.

- [ ] **Step 7 : Commit**

```bash
git add frontend/components/task/StatusPickerPopover.vue frontend/pages/my-tasks.vue frontend/i18n/locales/
git commit -m "fix(my-tasks) : drag & drop par workflow (popover si ambigu) + entêtes de colonnes teintées"
```

---

## Task 5 : Backend `create-task` — statut + assigné, sans priorité (#6 back)

**Files:**
- Modify: `src/Controller/Mail/MailCreateTaskController.php`
- Test: `tests/Functional/Controller/Mail/MailTaskIntegrationControllerTest.php`

**Contexte vérifié :** `Task::setStatus(?TaskStatus)`, `Task::setAssignee(?User)` existent. `Project::getWorkflow()` ; `Workflow::getStatuses()` est ordonné `position ASC`. Accès mail = ROLE_USER/ROLE_ADMIN (cf. `MailAccessChecker`).

- [ ] **Step 1 : Écrire le test fonctionnel (TDD) — assigné + statut, priorité ignorée**

Ajouter dans `MailTaskIntegrationControllerTest.php` (crée ses prérequis via l'EntityManager) :

```php
public function testCreateTaskAppliesStatusAndAssigneeAndIgnoresPriority(): void
{
    $client    = static::createClient();
    $container = static::getContainer();
    $em        = $container->get('doctrine.orm.entity_manager');

    $admin = $em->getRepository(\App\Entity\User::class)->findOneBy(['username' => 'admin']);
    $client->loginUser($admin);

    // Projet existant (fixtures) + son workflow / premier statut + un message mail existant
    $project = $em->getRepository(\App\Entity\Project::class)->findOneBy([]);
    self::assertNotNull($project, 'Au moins un projet doit exister dans les fixtures');
    $status  = $project->getWorkflow()->getStatuses()->first();
    $message = $em->getRepository(\App\Entity\MailMessage::class)->findOneBy([]);
    self::assertNotNull($message, 'Au moins un message mail doit exister (fixtures ou sync)');

    $client->request(
        'POST',
        '/api/mail/messages/'.$message->getId().'/create-task',
        [], [], ['CONTENT_TYPE' => 'application/json'],
        json_encode([
            'projectId'  => $project->getId(),
            'assigneeId' => $admin->getId(),
            'statusId'   => $status->getId(),
            'priorityId' => 999, // doit être ignoré
        ])
    );

    self::assertResponseStatusCodeSame(201);
    $payload = json_decode($client->getResponse()->getContent(), true);

    $task = $em->getRepository(\App\Entity\Task::class)->find($payload['taskId']);
    self::assertSame($status->getId(), $task->getStatus()?->getId());
    self::assertSame($admin->getId(), $task->getAssignee()?->getId());
    self::assertNull($task->getPriority(), 'priorityId ne doit plus être pris en compte');
}
```

> Si les fixtures ne contiennent pas de `MailMessage`, créer dans le test un `MailConfiguration` + `MailFolder` + `MailMessage` minimal via l'EM (adapter aux champs requis des entités), ou charger un dump mail. Le test échoue tant que le contrôleur n'est pas modifié.

- [ ] **Step 2 : Lancer le test (doit échouer)**

Run: `make test` (ou `docker exec php-lesstime-fpm php bin/phpunit --filter testCreateTaskAppliesStatusAndAssigneeAndIgnoresPriority`)
Expected : FAIL (assignee/status non appliqués, priorityId encore lu).

- [ ] **Step 3 : Modifier le contrôleur**

Dans `MailCreateTaskController.php` :

a) Remplacer l'import `use App\Entity\TaskPriority;` par :
```php
use App\Entity\TaskStatus;
use App\Entity\User;
```

b) Dans la transaction (l.62-96), **remplacer** le bloc priorité (l.77-82) par l'assigné + le statut :

```php
            if (isset($body['assigneeId']) && null !== $body['assigneeId']) {
                $assignee = $this->em->getRepository(User::class)->find($body['assigneeId']);
                if (null !== $assignee) {
                    $task->setAssignee($assignee);
                }
            }

            // Statut : celui fourni, sinon le premier statut du workflow du projet (par position)
            $status = null;
            if (isset($body['statusId']) && null !== $body['statusId']) {
                $status = $this->em->getRepository(TaskStatus::class)->find($body['statusId']);
            }
            if (null === $status) {
                $status = $project->getWorkflow()?->getStatuses()->first() ?: null;
            }
            if (null !== $status) {
                $task->setStatus($status);
            }
```

- [ ] **Step 4 : Lancer le test (doit passer)**

Run: `make test`
Expected : PASS. Lancer aussi `make php-cs-fixer-allow-risky`.

- [ ] **Step 5 : Commit**

```bash
git add src/Controller/Mail/MailCreateTaskController.php tests/Functional/Controller/Mail/MailTaskIntegrationControllerTest.php
git commit -m "feat(mail) : create-task applique statut + assigné, retire la priorité"
```

---

## Task 6 : Modale de création depuis un mail (#6 front)

**Files:**
- Modify: `frontend/components/mail/MailCreateTaskModal.vue`
- Modify: `frontend/services/mail.ts` (`createTaskFromMail`, ~l.184-192)
- Modify: `frontend/i18n/locales/*.json` (libellés user/statut)

- [ ] **Step 1 : Adapter le service `createTaskFromMail`**

Dans `frontend/services/mail.ts`, modifier le payload de `createTaskFromMail` : retirer `priority`, accepter `assigneeId?: number` et `statusId?: number`. Le corps POST devient :

```ts
{
    projectId,
    taskGroupId,
    assigneeId,
    statusId,
}
```

(adapter la signature TypeScript de la fonction en conséquence ; supprimer toute référence à `priority`).

- [ ] **Step 2 : Réécrire `MailCreateTaskModal.vue` sur AppModal + user + statut**

Remplacer le `<script setup>` : retirer `useTaskPriorityService`/`priorities`/`priorityId`/`priorityOptions`, ajouter le service users, le service statuts par workflow, et l'état `assigneeId` / `statusId`.

```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 { useMailService } from '~/services/mail'
import { useProjectService } from '~/services/projects'
import { useTaskGroupService } from '~/services/task-groups'
import { useUserService } from '~/services/users'

const props = defineProps<{
    modelValue: boolean
    messageId: number
    messageDetail: MailMessageDetailDto | null
}>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; created: [task: Task] }>()

const { t } = useI18n()
const auth = useAuthStore()
const mailService = useMailService()
const projectService = useProjectService()
const taskGroupService = useTaskGroupService()
const userService = useUserService()

const projectId = ref<number | null>(null)
const taskGroupId = ref<number | null>(null)
const assigneeId = ref<number | null>(null)
const statusId = ref<number | null>(null)
const isSubmitting = ref(false)
const touchedProject = ref(false)

const projects = ref<Project[]>([])
const groups = ref<TaskGroup[]>([])
const users = ref<{ id: number, username: string }[]>([])
const loadingGroups = ref(false)

const projectOptions = computed(() => projects.value.map(p => ({ label: p.name, value: p.id })))
const groupOptions = computed(() => groups.value.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id })))
const userOptions = computed(() => users.value.map(u => ({ label: u.username, value: u.id })))

// Statuts filtrés par le workflow du projet sélectionné (#2 réutilisé)
const selectedProject = computed(() => projects.value.find(p => p.id === projectId.value) ?? null)
const statusOptions = computed(() =>
    (selectedProject.value?.workflow?.statuses ?? []).map(s => ({ label: s.label, value: s.id })),
)

onMounted(async () => {
    const [projs, us] = await Promise.all([
        projectService.getAll({ archived: false }),
        userService.getAll(),
    ])
    projects.value = projs
    users.value = us
})

// Au changement de projet : recharger les groupes + présélectionner le 1er statut du workflow
watch(projectId, async (pid) => {
    taskGroupId.value = null
    statusId.value = selectedProject.value?.workflow?.statuses?.[0]?.id ?? null
    groups.value = []
    if (!pid) return
    loadingGroups.value = true
    try {
        groups.value = await taskGroupService.getByProject(pid)
    } finally {
        loadingGroups.value = false
    }
})

// Reset + user par défaut = utilisateur connecté
watch(() => props.modelValue, (open) => {
    if (open) {
        projectId.value = null
        taskGroupId.value = null
        statusId.value = null
        assigneeId.value = auth.user?.id ?? null
        touchedProject.value = false
    }
})

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,
            assigneeId: assigneeId.value ?? undefined,
            statusId: statusId.value ?? undefined,
        })
        emit('created', task)
        close()
    } finally {
        isSubmitting.value = false
    }
}
```

Puis remplacer tout le `<template>` (et le `<style>` devient inutile — AppModal gère l'animation) par :

```vue
<template>
    <AppModal :model-value="modelValue" width="lg" :title="t('mail.createTaskModal.title')" @update:model-value="emit('update:modelValue', $event)">
        <div class="space-y-5">
            <div v-if="messageDetail" class="rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-3 text-sm">
                <p class="truncate font-medium text-neutral-800">{{ messageDetail.header.subject ?? t('mail.noSubject') }}</p>
                <p class="mt-0.5 truncate text-xs text-neutral-500">{{ messageDetail.header.fromName ?? messageDetail.header.fromEmail }}</p>
                <p class="mt-2 text-xs italic text-neutral-400">{{ t('mail.createTaskModal.titleHint') }}</p>
                <p class="text-xs italic text-neutral-400">{{ t('mail.createTaskModal.descriptionHint') }}</p>
            </div>

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

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

            <div v-if="projectId">
                <MalioSelect v-model="statusId" :options="statusOptions" :label="t('mail.createTaskModal.statusLabel')" min-width="w-full" />
            </div>

            <div>
                <MalioSelect v-model="assigneeId" :options="userOptions" :label="t('mail.createTaskModal.assigneeLabel')" :empty-option-label="t('mail.createTaskModal.assigneePlaceholder')" min-width="w-full" />
            </div>
        </div>

        <template #footer>
            <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" />
        </template>
    </AppModal>
</template>
```

- [ ] **Step 3 : Ajouter les clés i18n**

Dans `mail.createTaskModal` (toutes les locales) : `statusLabel` (« Statut »), `assigneeLabel` (« Assigné à »), `assigneePlaceholder` (« Aucun »). Retirer `priorityLabel`/`priorityPlaceholder` si plus utilisées ailleurs.

- [ ] **Step 4 : Build**

Run: `cd frontend && npm run build:dist`
Expected : exit 0.

- [ ] **Step 5 : Vérification navigateur (Chrome MCP)**

1. Hard-reload, login, Messagerie → ouvrir un message → « Créer une tâche ».
Expected : modale **élargie**, footer **toujours visible**, champs = Projet / Groupe / **Statut** / **Assigné** (défaut = Matthieu). Plus de champ Priorité.
2. Choisir un projet → le statut se présélectionne sur le 1er statut du workflow ; les options statut = celles du workflow du projet.
3. Créer la tâche → succès, tâche liée au mail avec le bon statut/assigné.

- [ ] **Step 6 : Commit**

```bash
git add frontend/components/mail/MailCreateTaskModal.vue frontend/services/mail.ts frontend/i18n/locales/
git commit -m "feat(mail) : création de tâche depuis mail — sélecteur user + statut (workflow), modale agrandie"
```

---

## Task 7 : Supprimer le bouton « Lier un mail » (#5)

**Files:**
- Modify: `frontend/components/task/TaskModal.vue` (bouton ~l.487-493 ; `<MailPickerModal>` ~l.498-503 ; état `showMailPickerModal` l.627 ; `handleMailLinked` ~l.936-938)
- Delete: `frontend/components/mail/MailPickerModal.vue`
- Modify: `frontend/i18n/locales/*.json` (clé `mail.taskTab.linkButton`)

**Contexte vérifié :** `MailPickerModal` n'est utilisé **que** par TaskModal.

- [ ] **Step 1 : Retirer le bouton, la modale, l'état et le handler dans TaskModal.vue**

- Supprimer le `<MalioButton ... :label="$t('mail.taskTab.linkButton')" ... @click="showMailPickerModal = true" />` (~l.487-493).
- Supprimer le bloc `<MailPickerModal ... v-model="showMailPickerModal" ... @linked="handleMailLinked" />` (~l.498-503).
- Supprimer `const showMailPickerModal = ref(false)` (l.627).
- Supprimer la fonction `handleMailLinked` (~l.936-938).
- Retirer l'éventuel `import MailPickerModal` (si import explicite ; sinon auto-import, rien à faire).

- [ ] **Step 2 : Supprimer le composant et la clé i18n**

```bash
git rm frontend/components/mail/MailPickerModal.vue
```
Retirer la clé `mail.taskTab.linkButton` dans toutes les locales (vérifier qu'elle n'est plus référencée : `grep -rn "taskTab.linkButton" frontend/`).

- [ ] **Step 3 : Build**

Run: `cd frontend && npm run build:dist`
Expected : exit 0, aucune référence cassée.

- [ ] **Step 4 : Vérification navigateur (Chrome MCP)**

Ouvrir une tâche → onglet « Mails ».
Expected : plus de bouton « Lier un mail ». La liste des mails liés et le bouton de suppression de lien (s'il existe) restent fonctionnels.

- [ ] **Step 5 : Commit**

```bash
git add -A frontend/components/task/TaskModal.vue frontend/i18n/locales/
git commit -m "refactor(task) : suppression du bouton « Lier un mail » et de MailPickerModal"
```

---

## Task 8 : Cartes responsive (#3)

**Files:**
- Modify: `frontend/components/task/TaskCard.vue` (ligne badges ~l.42-106)

**Contexte vérifié :** badges en `rounded-full px-2 py-0.5 ... text-white` sans contrainte ; conteneur `mt-2 flex items-center gap-1.5` sans `min-w-0` ni `flex-wrap`. Décision : **2-3 tags max + « +N »**, hauteur fixe, troncature.

- [ ] **Step 1 : Titre — `line-clamp-2`**

Ligne 30, remplacer :
```vue
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
```
par :
```vue
<h4 class="line-clamp-2 text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
```

- [ ] **Step 2 : Conteneur badges — `min-w-0` + troncature des badges**

Sur le conteneur (l.42) ajouter `min-w-0` : `class="mt-2 flex min-w-0 items-center gap-1.5"`.

Sur les badges statut/priorité/tag/deadline, ajouter `max-w-[7rem] truncate shrink-0` à la classe `rounded-full ...`. Exemple pour le statut (l.45) :
```vue
class="shrink-0 max-w-[7rem] truncate rounded-full px-2 py-0.5 text-xs font-semibold text-white"
```

- [ ] **Step 3 : Limiter les tags à 2-3 + badge « +N »**

Remplacer la boucle des tags (l.57-64) par :
```vue
<span
    v-for="tag in task.tags.slice(0, 2)"
    :key="tag.id"
    class="shrink-0 max-w-[7rem] truncate rounded-full px-2 py-0.5 text-xs font-semibold text-white"
    :style="{ backgroundColor: tag.color }"
    :title="tag.label"
>
    {{ tag.label }}
</span>
<span
    v-if="task.tags.length > 2"
    class="shrink-0 rounded-full bg-neutral-200 px-2 py-0.5 text-xs font-semibold text-neutral-600"
    :title="task.tags.slice(2).map(t => t.label).join(', ')"
>
    +{{ task.tags.length - 2 }}
</span>
```

- [ ] **Step 4 : Build**

Run: `cd frontend && npm run build:dist`
Expected : exit 0.

- [ ] **Step 5 : Vérification navigateur (Chrome MCP)**

Sur « Mes tâches » avec données prod (cartes à nombreux tags) : vérifier via le DOM qu'aucune carte ne déborde (mesurer `scrollWidth - clientWidth` ≤ 1 sur la ligne de badges) ; les cartes à >2 tags montrent un badge « +N » ; titres longs tronqués sur 2 lignes.

- [ ] **Step 6 : Commit**

```bash
git add frontend/components/task/TaskCard.vue
git commit -m "fix(task) : cartes responsive — troncature badges, max 2 tags + « +N », titre line-clamp"
```

---

## Task 9 : Couleurs par défaut par catégorie + migration data prod (#4a + #4c)

**Files:**
- Modify: `frontend/components/admin/WorkflowDrawer.vue` (`addStatus`, l.172-180 ; `categoryOptions` l.143-151)
- Create: `migrations/VersionYYYYMMDDHHMMSS.php`
- Modify: `src/DataFixtures/AppFixtures.php` (déjà correct — vérifier, ne rien changer si OK)

- [ ] **Step 1 : Couleur par défaut par catégorie dans `addStatus` (front)**

Dans `WorkflowDrawer.vue`, importer la palette et l'utiliser à la création :

```ts
import { STATUS_CATEGORY_COLOR } from '~/services/dto/workflow'
```
```ts
function addStatus() {
    form.statuses.push({
        label: '',
        color: STATUS_CATEGORY_COLOR.todo,   // défaut cohérent (catégorie initiale = todo)
        position: form.statuses.length,
        isFinal: false,
        category: 'todo',
    })
}
```

Et, pour aligner la couleur quand l'utilisateur change la catégorie d'un statut, ajouter un watcher dans le `<script setup>` :

```ts
import type { StatusCategory } from '~/services/dto/workflow'
// (déjà importé pour le type ; sinon ajouter)

watch(() => form.statuses.map(s => s.category), (cats, prev) => {
    cats.forEach((cat, i) => {
        // si la catégorie vient de changer ET que la couleur correspond encore au défaut de l'ancienne catégorie, réaligner
        if (prev && cat !== prev[i] && form.statuses[i] && form.statuses[i].color === STATUS_CATEGORY_COLOR[prev[i] as StatusCategory]) {
            form.statuses[i].color = STATUS_CATEGORY_COLOR[cat]
        }
    })
}, { deep: false })
```

> Ce watcher ne réécrase **pas** une couleur personnalisée (il n'agit que si la couleur courante = défaut de l'ancienne catégorie).

- [ ] **Step 2 : Build + vérif front**

Run: `cd frontend && npm run build:dist` (exit 0). Vérifier en navigateur : ajouter un statut → couleur par défaut indigo ; changer sa catégorie vers « En cours » alors qu'il a la couleur par défaut → la couleur passe au bleu `#4A90D9`.

- [ ] **Step 3 : Générer la migration de correction data**

Run: `make shell` puis `php bin/console make:migration` **n'est pas adapté** (pas de diff de schéma). Créer manuellement `migrations/VersionYYYYMMDDHHMMSS.php` (timestamp courant) :

```php
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class VersionYYYYMMDDHHMMSS extends AbstractMigration
{
    public function getDescription(): string
    {
        return 'Remet les couleurs classiques sur les statuts du workflow Standard (dérive data prod #4).';
    }

    public function up(Schema $schema): void
    {
        // Cible : statuts du workflow nommé "Standard", par catégorie. Ne touche pas aux autres workflows.
        $map = [
            'todo'        => '#222783',
            'in_progress' => '#4A90D9',
            'blocked'     => '#C62828',
            'review'      => '#FF8F00',
            'done'        => '#26A69A',
        ];
        foreach ($map as $category => $hex) {
            $this->addSql(
                "UPDATE task_status SET color = :hex
                 WHERE category = :cat
                 AND workflow_id = (SELECT id FROM workflow WHERE name = 'Standard' ORDER BY id ASC LIMIT 1)",
                ['hex' => $hex, 'cat' => $category]
            );
        }
    }

    public function down(Schema $schema): void
    {
        // Pas de rollback des couleurs (correction one-shot).
        $this->throwIrreversibleMigration('Correction de couleurs non réversible.');
    }
}
```

> Vérifier la signature `addSql` avec paramètres nommés de la version Doctrine Migrations utilisée ; sinon utiliser des valeurs inline (couleurs et catégories sont des constantes sûres). Confirmer le nom de colonne `workflow_id` via `\d task_status`.

- [ ] **Step 4 : Tester la migration en local (sur données prod importées)**

Run: `make migration-migrate`
Puis vérifier : 
```bash
docker exec -e PGPASSWORD=root lesstime-db-1 psql -U root -p 5435 -d lesstime -c "select label,color from task_status ts join workflow w on w.id=ts.workflow_id where w.name='Standard' order by ts.position;"
```
Expected : `#222783 / #4A90D9 / #C62828 / #FF8F00 / #26A69A`.

- [ ] **Step 5 : Vérif navigateur**

Kanban d'un projet Standard + badges de cartes : couleurs classiques de retour.

- [ ] **Step 6 : Commit**

```bash
git add frontend/components/admin/WorkflowDrawer.vue migrations/
git commit -m "fix(workflow) : couleurs par défaut par catégorie + migration de correction du workflow Standard"
```

---

## Task 10 : Migrer TaskModal vers AppModal (#7)

**Files:**
- Modify: `frontend/components/task/TaskModal.vue` (coque de la modale uniquement : Teleport/Transition/overlay + header + footer)

> À faire en dernier car TaskModal est touché par #2 et #5 ; on stabilise d'abord son contenu. La migration ne change que la **coque** (structure header/body/footer), pas la logique métier.

- [ ] **Step 1 : Remplacer la coque par AppModal**

Envelopper le contenu existant dans `<AppModal :model-value="isOpen" width="lg" @update:model-value="isOpen = $event">`, déplacer le titre dans le slot `#title` (ou prop `title`), placer le corps actuel dans le slot par défaut et la barre d'actions (Supprimer / Annuler / Enregistrer, ~l.507-549) dans `<template #footer>`. Retirer le `Teleport`/`Transition`/overlay et le `max-h`/`overflow` manuels désormais gérés par AppModal.

> Conserver tels quels les sous-modales internes (ConfirmDeleteTaskModal, etc.) et la logique `close()` (qui bloque la fermeture si une confirmation est ouverte) — la connecter au `@update:model-value` d'AppModal.

- [ ] **Step 2 : Build**

Run: `cd frontend && npm run build:dist`
Expected : exit 0.

- [ ] **Step 3 : Vérification navigateur (Chrome MCP)**

Ouvrir une tâche avec beaucoup de contenu (description longue) sur un viewport normal.
Expected : header et **footer (Supprimer/Annuler/Enregistrer) toujours visibles**, body scrollable au milieu. Mesurer que le bouton « Enregistrer » est dans le viewport (`getBoundingClientRect().bottom <= window.innerHeight`).

- [ ] **Step 4 : Commit**

```bash
git add frontend/components/task/TaskModal.vue
git commit -m "refactor(task) : TaskModal migré sur AppModal (footer sticky)"
```

---

## Self-Review — couverture spec

| Chantier spec | Task(s) | Couvert |
|---|---|---|
| #1 D&D | Task 4 | ✅ handlers + popover + par-workflow |
| #2 Sélecteur statut | Task 3 (+ réutilisé Task 6) | ✅ |
| #3 Cartes responsive | Task 8 | ✅ troncature + N |
| #4 Couleurs | Task 1 (palette), Task 4 (entêtes), Task 9 (migration + défauts) | ✅ a/b/c |
| #5 Bouton lier mail | Task 7 | ✅ |
| #6 Création depuis mail | Task 5 (back) + Task 6 (front) | ✅ |
| #7 Modale réutilisable | Task 2 (composant) + Task 6/10 (migrations) | ✅ |
| #8 MalioSelect catégorie | déjà fait (hors plan) | ✅ |

**Risques / points de vigilance pour l'exécutant :**
- Noms de fonctions/variables existants dans `my-tasks.vue` (rechargement des tâches, toast, `t`) et `TaskModal.vue` (projet effectif) — à raccorder aux noms réels.
- `MailMessage` non garanti dans les fixtures → adapter le test backend (Task 5) ou importer un dump mail.
- Toujours **hard-reload sans cache** après chaque `build:dist`.
