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 viamake test). Pour le frontend, chaque tâche se vérifie par : (1)npm run build:distqui doit réussir (exit 0), puis (2) contrôle navigateur via Chrome DevTools MCP surhttp://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)
- Task 1 — Brique front : palette de catégories + helper contraste (
#4b, réutilisé par #1) - Task 2 — Composant
AppModalréutilisable (#7) - Task 3 — Filtrage du sélecteur de statut par workflow dans TaskModal (
#2) - Task 4 — Drag & drop dans « Mes tâches » + entêtes teintées (
#1+#4b) - Task 5 — Backend : endpoint
create-task(statut + assigné, sans priorité) (#6back) - Task 6 — Frontend : modale de création depuis mail (
#6front, sur AppModal) - Task 7 — Suppression du bouton « Lier un mail » (
#5) - Task 8 — Cartes responsive (
#3) - Task 9 — Couleurs par défaut par catégorie + migration data prod (
#4a+#4c) - 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/activeProjectIdexiste 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)
- Hard-reload
http://localhost:8082(cache ignoré), loginMatthieu/admin. - Ouvrir une tâche d'un projet Standard (ex.
LST-49via « Mes tâches »). - 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é »).
- 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éutiliserupdate()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/taux noms réels du fichier (vérifier la fonction de rechargement des tâches et l'import du toast déjà utilisés dansmy-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)
- 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).
- 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.
- Glisser une carte du projet STARSEED (workflow ERP) vers « En validation » (la catégorie
reviewa ≥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 unMailConfiguration+MailFolder+MailMessageminimal 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.vuesur 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)
- 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é.
- Choisir un projet → le statut se présélectionne sur le 1er statut du workflow ; les options statut = celles du workflow du projet.
- 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 ; étatshowMailPickerModall.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 ;categoryOptionsl.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
addSqlavec 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 colonneworkflow_idvia\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-valued'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) etTaskModal.vue(projet effectif) — à raccorder aux noms réels. MailMessagenon 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.