Files
Lesstime/docs/superpowers/plans/2026-05-21-workflow-ui-fixes.md
2026-05-21 09:10:07 +02:00

39 KiB

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 :

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

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

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

par :

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

<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+) :

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 :

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

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

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 :

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 :

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

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

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 :

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

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

<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>

par :

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

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 :

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

import { STATUS_CATEGORY_COLOR } from '~/services/dto/workflow'
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> :

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

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 :

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