Compare commits

...

2 Commits

Author SHA1 Message Date
gitea-actions
2bffff9b83 chore: bump version to v0.4.4
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 57s
2026-05-21 08:48:41 +00:00
d7af8ee138 Correctifs UI workflow — specs + implémentation (8 chantiers) (#6)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Suite à l'arrivée des workflows, correction des régressions UI et améliorations UX mail/modales (reviews Lucile Schnödt, Tristan Schnödtin).

**Specs & décisions :** `docs/superpowers/specs/2026-05-20-workflow-ui-fixes-design.md`
**Plan d'implémentation :** `docs/superpowers/plans/2026-05-21-workflow-ui-fixes.md`

Cette PR contient désormais **les specs ET l'implémentation complète**.

## Chantiers livrés

| # | Chantier | Détail |
|---|----------|--------|
| 2 | Sélecteur de statut filtré par workflow | `statusOptions` dérivé de `project.workflow.statuses`, statut courant conservé s'il est hors workflow |
| 1 | Drag & drop « Mes tâches » | handlers `@dragover/@drop` ; résolution par workflow/catégorie (0→refus, 1→PATCH, ≥2→popover `StatusPickerPopover`) |
| 4 | Couleurs | (a) migration Doctrine remettant les hex classiques sur le workflow Standard ; (b) entêtes kanban teintées via `STATUS_CATEGORY_COLOR` + contraste auto ; (c) couleur par défaut par catégorie dans `WorkflowDrawer` |
| 5 | Suppression du bouton « Lier un mail » | + retrait de `MailPickerModal` et i18n associée |
| 6 | Création de tâche depuis un mail | back : `assigneeId` + `statusId` (défaut = 1er statut du workflow), priorité retirée (TDD) ; front : `MailCreateTaskModal` sur `AppModal` + sélecteurs user/statut |
| 7 | Modale réutilisable | nouveau `components/ui/AppModal.vue` (footer sticky) ; footer de `TaskModal` sorti du form scrollable |
| 3 | Cartes responsive | badges en `flex-wrap` pleine taille (plus aucun débordement) |
| 8 | (dette) Sélecteur de catégorie en `MalioSelect` | la lib supporte les valeurs `string` ; note CLAUDE.md corrigée |

## Vérifications
- Build frontend OK ; PHPUnit **34 tests verts** (nouveau test fonctionnel TDD sur `create-task`).
- Vérif navigateur (Chrome MCP) sur **données prod importées en local** : #2, #3, #4, #5, #6, #7 confirmés.
- Revue de code finale : **APPROVED_WITH_NITS**.

## À noter
- ⚠️ **#1 (D&D)** : le drag & drop HTML5 natif n'est pas auto-testable → **test manuel requis**.
- 🗄️ **#4 (migration)** : `migrations/Version20260521094948.php` s'appliquera en **prod au prochain `make migration-migrate`**.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Matthieu <mtholot19@gmail.com>
Reviewed-on: #6
2026-05-21 08:48:31 +00:00
18 changed files with 1701 additions and 505 deletions

View File

@@ -101,7 +101,7 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
- Middleware global `auth.global.ts` protège les routes
- Traductions dans `frontend/i18n/locales/` (le module résout `langDir` depuis `i18n/`)
- 4 espaces d'indentation
- MalioSelect : options `{ label: string, value: number | null }` uniquement — pas de string values, utiliser `<select>` natif pour les enums string
- MalioSelect : options `{ label: string, value: string | number | null }` — supporte les valeurs `string` (donc les enums string comme `StatusCategory`), pas seulement `number`. Vérifié dans la source `@malio/layer-ui` (`Select.vue` : `modelValue: string | number | null`). L'option vide `null` n'est ajoutée que si `empty-option-label` est passé (ne pas le passer pour un champ requis)
- Portal client : pages sous `/portal/`, layout `portal.vue`, middleware redirige `ROLE_CLIENT` (sans `ROLE_ADMIN`) vers `/portal`
- Users admin+client : ne pas bloquer — vérifier `ROLE_CLIENT && !ROLE_ADMIN` pour les restrictions

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.4.3'
app.version: '0.4.4'

View File

@@ -0,0 +1,988 @@
# 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`.

View File

@@ -0,0 +1,239 @@
# Specs — Correctifs UI suite au système de workflow + UX mail/modales
> Date : 2026-05-20
> Contexte : suite à l'introduction des **workflows** (`docs/superpowers/specs/2026-05-19-project-workflows-design.md`),
> plusieurs régressions UI et points UX sont apparus. Reviews faites par Lucile Schnödt et Tristan Schnödtin.
> Ce document liste les 7 chantiers à traiter, avec problème, fichiers concernés, solution validée et points ouverts.
## Vue d'ensemble
| # | Chantier | Type | Décision |
|---|----------|------|----------|
| 1 | Drag & drop cassé dans « mes tâches » | régression workflow | Drop = changer de statut (gérer ambiguïté multi-statuts) |
| 2 | Sélecteur de statut mélange les workflows | régression workflow | Filtrer par le workflow du projet de la tâche |
| 3 | Cartes de tâche non responsive (tags) | UI | Refonte responsive + troncature |
| 4 | Couleurs de statut du workflow de base | data/UI | Remettre la palette classique |
| 5 | Bouton « lier un mail » dans l'onglet mail d'un ticket | UX mail | Supprimer le bouton |
| 6 | Création de ticket depuis un mail | UX mail | + sélecteur user, + sélecteur statut (remplace priorité), modale agrandie |
| 7 | Footer collant des modales centrées | UX global | Composant modale réutilisable (header / body scrollable / footer sticky) |
| 8 | Sélecteur de catégorie en `<select>` natif (WorkflowDrawer) | UI/dette | Migrer vers `MalioSelect` (la lib supporte les valeurs `string`) |
## Décisions actées (2026-05-21)
Suite à la reproduction des bugs sur données prod (import local) et discussion :
- **#1** — Désambiguïsation au drop : **popover** de choix quand la catégorie cible a ≥2 statuts (0 → drop refusé + feedback ; 1 → PATCH direct ; ≥2 → popover ancré au point de drop). Résolution **par tâche** (workflow de son projet).
- **#2** — Source des statuts : `project.workflow.statuses` (déjà embarqué dans `GET /projects` et `task.project.workflow`). Le statut courant est ajouté en tête s'il est hors du workflow (pas de perte à l'enregistrement).
- **#3** — Cartes : `min-w-0` partout, titre `line-clamp-2`, badges `truncate`, **2-3 tags max + badge « +N »** (tooltip), hauteur de carte fixe.
- **#4** — Deux facettes :
- **(a) Statuts** (badges cartes + kanban projet) : dérive **data en prod** (fixtures OK). Correction par **migration Doctrine** idempotente remettant les hex classiques sur le workflow Standard.
- **(b) Catégories** (entêtes multi-projets de « Mes tâches », aujourd'hui grises) : nouvelle constante front **`STATUS_CATEGORY_COLOR`** (5 hex classiques) → **bandeau teinté** sur les entêtes, **texte auto noir/blanc** selon la luminance.
- **(c)** Couleur par défaut **par catégorie** dans `addStatus()` (au lieu de `#222783` systématique) pour éviter une nouvelle dérive.
- **#5** — Suppression du bouton « Lier un mail » + `MailPickerModal` + état/handler + clé i18n (`MailPickerModal` n'est utilisé que par TaskModal — vérifié).
- **#6** — + sélecteur **user** (défaut pré-rempli) ; priorité **remplacée** par sélecteur **statut** (filtré workflow, rechargé au changement de projet) ; **`priority`/`priorityId` retiré** du payload et de l'endpoint backend ; statut par défaut = **1er statut du workflow par `position`** ; modale élargie via #7.
- **#7** — Composant `frontend/components/ui/AppModal.vue` (header fixe / body `flex-1 min-h-0 overflow-y-auto` / footer `shrink-0` sticky, `max-h-[90vh]`, prop `width`). Migration d'abord : TaskModal puis MailCreateTaskModal.
- **#8** — ✅ Fait : `<select>` catégorie → `MalioSelect` (la lib accepte `value: string | number | null` ; note CLAUDE.md corrigée).
---
## 1. Drag & drop cassé dans « mes tâches »
**Problème.** Le drag & drop des cartes entre colonnes ne fonctionne plus depuis l'arrivée des workflows.
**Fichiers.**
- `frontend/pages/my-tasks.vue` — colonnes kanban construites sur les **catégories canoniques** fixes (`CATEGORIES = ['todo','in_progress','blocked','review','done']`, ~l.118-127), template kanban ~l.396-424.
- `frontend/components/task/TaskCard.vue``@dragstart` / `@dragend` HTML5 natif (~l.154-162), `dataTransfer` = `task.id`.
- Pas de librairie externe (HTML5 natif).
**Cause racine.** Les colonnes sont des **catégories canoniques** (la vue agrège plusieurs projets/workflows, donc on ne peut pas afficher une colonne par statut d'un workflow précis). Or un workflow peut désormais mapper **plusieurs statuts sur une même catégorie** (ex. deux statuts « in_progress »). Au drop dans une colonne, le statut cible devient ambigu — ce qui a cassé / rendu indéterminé le changement de statut.
**Solution retenue.** Drop dans une colonne ⇒ change le statut de la tâche vers un statut de cette catégorie **dans le workflow du projet de la tâche**. Gestion de l'ambiguïté :
- Récupérer les statuts du workflow du projet de la tâche déposée, filtrés par la catégorie de la colonne cible.
- **0 statut** dans cette catégorie pour ce workflow → drop refusé (feedback visuel, pas de changement).
- **1 statut** → appliquer directement.
- **≥ 2 statuts** → afficher un mini-sélecteur (popover/menu) au point de drop pour choisir le statut exact.
**Points ouverts.**
- ⚠️ **À trancher demain** : confirmer la stratégie de désambiguïsation (popover au drop vs. choisir le statut de plus petite `position` dans la catégorie). Le popover est plus sûr mais plus de travail ; le « plus petite position » est transparent mais peut surprendre.
- Ajouter les **handlers de drop** sur les colonnes (`@dragover.prevent`, `@drop`) — actuellement absents dans `my-tasks.vue`.
- Comme la vue est multi-projets, la résolution du statut cible doit se faire **par tâche** (selon son projet → son workflow), pas globalement.
---
## 2. Sélecteur de statut mélange les statuts de tous les workflows
**Problème.** En ouvrant une tâche (modale), le sélecteur « Statut » liste **tous les statuts globaux**, donc ceux du workflow de base **et** ceux des autres workflows.
**Fichiers.**
- `frontend/components/task/TaskModal.vue``<MalioSelect v-model="form.statusId" :options="statusOptions" />` (~l.103-109) ; `statusOptions = props.statuses.map(...)` (~l.674-676).
- `frontend/pages/my-tasks.vue``:statuses="statuses"` (~l.489), chargés via `statusService.getAll()` (~l.132).
- `frontend/services/task-statuses.ts``getAll()``GET /task_statuses` (tous les statuts).
- DTO : `frontend/services/dto/task-status.ts` — le champ `workflow` existe déjà (`{ '@id', id } | string`).
**Solution retenue.** Le sélecteur ne doit proposer que les statuts du **workflow du projet de la tâche éditée**.
- Filtrer `statusOptions` sur `status.workflow` correspondant au workflow du projet de la tâche.
- Source du filtre : soit filtrer côté front la liste déjà chargée par `workflow.id`, soit charger les statuts du workflow via le service workflow (`useWorkflowService()` existe mais n'est pas utilisé ici).
- S'assurer que le statut courant de la tâche reste affiché même si édge case (ex. statut hérité d'un ancien workflow).
**Points ouverts.**
- Vérifier que la tâche/le projet expose bien l'`@id`/`id` du workflow côté payload pour faire le filtre sans appel supplémentaire.
---
## 3. Cartes de tâche non responsive (tags mal placés / trop gros)
**Problème.** Depuis l'ajout d'éléments dans la carte (statut, priorité, effort, tags, deadline, avatars…), les tags débordent ou se chevauchent quand les textes sont longs ; la carte n'est plus responsive.
**Fichiers.**
- `frontend/components/task/TaskCard.vue` — badges statut (~l.43-49) et tags (~l.58-64) : `rounded-full px-2 py-0.5 text-xs font-semibold text-white`, **sans `truncate`, sans `max-w`, sans `line-clamp`** ; conteneur `flex` sans contrainte de largeur (~l.42-106).
**Solution retenue.** Refonte du layout de la carte pour rester contenue quelle que soit la longueur des textes :
- Conteneur des badges en `flex flex-wrap gap-1` (retour à la ligne propre).
- Tags : `max-w-[...] truncate` (ou `line-clamp-1`) + `title`/tooltip pour le texte complet au survol.
- Hiérarchiser l'info : titre prioritaire (`line-clamp-2`), badges secondaires qui passent à la ligne ou se condensent.
- Option : limiter le nombre de tags affichés (ex. 2-3 + « +N »).
**Points ouverts.**
- ⚠️ Choix d'UX à valider : `flex-wrap` (tous les tags visibles, carte plus haute) vs. troncature « +N » (hauteur fixe). Décision visuelle à prendre demain (éventuellement via mockup).
---
## 4. Couleurs de statut du workflow de base à remettre
**Problème.** Les couleurs « classiques » des statuts du workflow de base ont changé ; il faut remettre les couleurs d'origine.
**Investigation faite.** Le commit `4775cbf` (palette élargie 9→18 teintes + couleur perso) ne touche **que** `ColorPicker.vue` et `ProjectDrawer.vue` — il n'a pas modifié les couleurs des statuts. Les couleurs d'origine du **workflow Standard** sont dans les fixtures :
| Catégorie | Statut | Hex classique |
|---|---|---|
| todo | À faire | `#222783` |
| in_progress | En cours | `#4A90D9` |
| blocked | Bloqué | `#C62828` |
| review | En attente de validation | `#FF8F00` |
| done | Terminé | `#26A69A` |
Source : `src/DataFixtures/AppFixtures.php:101-105` (statuts rattachés au `$standardWorkflow`, ~l.93-116).
Migration de rattachement : `migrations/Version20260519175114.php` (attache les statuts existants au workflow Standard).
**Solution retenue.** Remettre ces 5 couleurs sur les statuts du **workflow Standard/de base**.
- Vérifier en prod (et en base de dev si dérive) que les statuts du workflow Standard portent bien ces hex ; corriger ceux qui ont dérivé (via l'UI d'admin des statuts, ou un script/migration de correction des couleurs).
- Ne **pas** toucher aux couleurs des statuts des autres workflows.
**Points ouverts.**
- Confirmer où la dérive a eu lieu (prod vs. nouveaux workflows créés via l'UI avec d'autres couleurs). Si c'est un workflow Standard en prod avec des couleurs erronées → correction data ; si c'est par défaut à la création d'un workflow → ajuster les couleurs proposées par défaut.
---
## 5. Supprimer le bouton « lier un mail » dans l'onglet mail d'un ticket
**Problème.** En ouvrant un ticket, l'onglet mail propose un bouton « lier un mail » qui n'a pas d'utilité de ce côté.
**Fichiers.**
- `frontend/components/task/TaskModal.vue` — bouton « lier un mail » (~l.486-494, `mail.taskTab.linkButton`, ouvre `showMailPickerModal`) ; `<MailPickerModal>` (~l.498-503), visible si `isEditing && isMailUser`, onglet `mails`.
- Composant lié : `frontend/components/mail/MailPickerModal.vue`.
**Solution retenue.** Supprimer le bouton « lier un mail » de l'onglet mail du ticket.
- Retirer le bouton et, si plus aucun usage, le `MailPickerModal` associé + l'état `showMailPickerModal` + `handleMailLinked`.
- Nettoyer la clé i18n `mail.taskTab.linkButton` si elle n'est plus utilisée ailleurs.
**Points ouverts.**
- Vérifier que `MailPickerModal` n'est pas réutilisé ailleurs avant de le supprimer (sinon ne retirer que le bouton).
---
## 6. Création d'un ticket depuis un mail
**Problème.** Le formulaire de création depuis un mail est incomplet et la modale dépasse de l'écran.
**Fichiers.**
- `frontend/components/mail/MailCreateTaskModal.vue` — champs actuels (~l.119-224) : info source (lecture seule), Projet (requis), Groupe (optionnel), **Priorité** (optionnelle). Footer non sticky (~l.210-224).
- `frontend/services/mail.ts``createTaskFromMail()` (~l.184-192) → `POST /mail/messages/{id}/create-task` avec `{ projectId, taskGroupId, priority }`.
**Solution retenue.**
1. **Sélecteur de user (assigné).** Ajouter un sélecteur d'utilisateur. Un user par défaut est déjà appliqué ; le sélecteur permet d'en choisir un autre. (Charger la liste via le service users.)
2. **Statut à la place de la priorité.** **Retirer** le sélecteur de priorité, le **remplacer** par un sélecteur de statut. Le statut proposé doit respecter le workflow du projet sélectionné (cf. chantier #2 — réutiliser la même logique de filtrage par workflow). Au changement de projet, recharger les statuts du workflow correspondant.
3. **Agrandir la modale.** Élargir la largeur (et gérer la hauteur via body scrollable + footer sticky, cf. chantier #7) pour qu'elle ne dépasse plus.
4. **Backend.** Adapter le payload / l'endpoint `create-task` : accepter `assigneeId` (ou IRI user) et `statusId` (ou IRI statut) ; retirer/garder `priority` selon décision (ici : remplacé par statut côté UI — décider si on garde la priorité par défaut côté backend ou non).
**Points ouverts.**
- Confirmer le statut **par défaut** présélectionné (statut initial du workflow du projet).
- Décider si la priorité disparaît totalement du payload ou reste à une valeur par défaut côté backend.
---
## 7. Footer collant pour les modales centrées
**Problème.** Les modales qui s'ouvrent au milieu de l'écran ont leurs boutons d'action en bas, qui défilent avec le contenu et finissent hors écran. On veut un footer **toujours visible**.
**Constat.** Il n'existe **aucun composant modale réutilisable** (`MalioModal`/`AppModal`). Chaque modale réimplémente `Teleport` + `Transition` + header/body/footer. Footers actuels en `border-t` mais **non sticky** :
- `frontend/components/task/TaskModal.vue` (footer ~l.507-549)
- `frontend/components/mail/MailCreateTaskModal.vue` (~l.210-224)
- `frontend/components/mail/MailLinkTaskModal.vue` (~l.226-239)
- `frontend/components/mail/MailPickerModal.vue` (~l.187-201)
- `frontend/components/ui/ConfirmDeleteTaskModal.vue` (~l.11-24)
- `frontend/components/project/ProjectWorkflowSwitchModal.vue` (~l.63-76)
**Solution retenue (décision : composant réutilisable).** Créer un composant modale réutilisable dans `frontend/components/ui/` (ex. `AppModal.vue` / `MalioModal.vue` local) :
- Structure : `Teleport to="body"` + `Transition`, overlay `fixed inset-0`, conteneur avec **hauteur max** (`max-h-[90vh]`), en flex-col :
- **header** (titre + fermeture) fixe en haut,
- **body** `flex-1 overflow-y-auto`,
- **footer** sticky en bas (`shrink-0 border-t`), via slot `#footer`.
- Props : largeur (`sm`/`md`/`lg`/`xl`), titre, `v-model` ouverture ; slots `default` (body) et `footer`.
- **Migration progressive** des modales existantes vers ce composant (commencer par celles citées par les reviews : TaskModal, MailCreateTaskModal). Ne pas tout migrer d'un coup.
**Points ouverts.**
- Nom et emplacement définitifs du composant.
- Ordre de migration (au minimum : MailCreateTaskModal #6 et TaskModal #2/#5, qui sont déjà touchées par les autres chantiers).
---
## 8. Sélecteur de catégorie en `<select>` natif (WorkflowDrawer) → MalioSelect
**Problème.** Dans l'éditeur de statut d'un workflow, le champ « Catégorie » est un `<select>` HTML natif, visuellement incohérent avec le reste des formulaires (qui utilisent `MalioSelect`).
**Investigation faite (2026-05-21).** La note de `Lesstime/CLAUDE.md` affirmait que `MalioSelect` n'accepte que des `value: number | null` et qu'il fallait un `<select>` natif pour les enums string. **C'est faux.** Vérifié dans la source `@malio/layer-ui` (v1.4.8 installée **et** repo dev `malio-layer-ui`) :
```ts
// app/components/malio/select/Select.vue
type Option = { label: string; value: string | number | null } // string supporté
const props = withDefaults(defineProps<{ modelValue: string | number | null; ... }>(), ...)
emit('update:modelValue', v: string | number | null)
// normalizedOptions n'ajoute l'option vide {value:null} QUE si empty-option-label est passé
```
La comparaison interne utilise `===`, donc les valeurs `string` (ex. l'enum `StatusCategory` : `todo | in_progress | blocked | review | done`) fonctionnent nativement.
**Fichiers.**
- `frontend/components/admin/WorkflowDrawer.vue``<select v-model="s.category">` (~l.49-57), `categoryOptions: { value: StatusCategory, label }[]` (~l.145-151).
- Note corrigée : `Lesstime/CLAUDE.md` (section « Frontend »).
**Solution retenue (faite).** Remplacer le `<select>` natif par :
```vue
<MalioSelect
v-model="s.category"
:options="categoryOptions"
label="Catégorie"
min-width="w-44"
group-class="shrink-0"
/>
```
- Pas d'`empty-option-label` (catégorie requise → pas d'option « Aucune » `null`).
- `min-width="w-44"` pour rester compact dans la ligne `flex` (sinon défaut `w-96`).
**Points ouverts / suite possible.**
- D'autres `<select>` natifs subsistent pour des enums string et pourraient être migrés de la même façon (candidats : `AdminClientTicketTab.vue`, `AdminMailTab.vue`, `ProjectClientTickets.vue`, `ProjectWorkflowSwitchModal.vue`, `TaskModal.vue`). À traiter au cas par cas, hors scope immédiat.
---
## Découpage suggéré pour l'implémentation
Regrouper par zone pour limiter les conflits :
- **Lot A — Workflow / statuts** : #2 (filtrage statut) → réutilisé par #1 (D&D) et #6 (statut à la création mail).
- **Lot B — Cartes** : #3 (responsive) + #4 (couleurs classiques).
- **Lot C — Mail** : #5 (suppr. bouton) + #6 (form création) — dépend de #2 et #7.
- **Lot D — Modale réutilisable** : #7 (composant) puis migration de TaskModal et MailCreateTaskModal.
Ordre conseillé : **#7 (composant modale)** et **#2 (filtrage statut)** d'abord (briques réutilisées), puis #1, #6, #5, puis #3/#4.

View File

@@ -46,15 +46,13 @@
label="Libellé"
input-class="w-full"
/>
<select
<MalioSelect
v-model="s.category"
class="h-10 rounded border border-neutral-300 px-2 text-sm"
aria-label="Catégorie"
>
<option v-for="c in categoryOptions" :key="c.value" :value="c.value">
{{ c.label }}
</option>
</select>
:options="categoryOptions"
label="Catégorie"
min-width="w-44"
group-class="shrink-0"
/>
<button
type="button"
class="h-10 px-2 text-red-600 hover:text-red-800"
@@ -97,6 +95,7 @@
<script setup lang="ts">
import type { Workflow, StatusCategory } from '~/services/dto/workflow'
import { STATUS_CATEGORY_COLOR } from '~/services/dto/workflow'
import type { TaskStatusWrite } from '~/services/dto/task-status'
import { useWorkflowService } from '~/services/workflows'
import { useTaskStatusService } from '~/services/task-statuses'
@@ -171,10 +170,20 @@ watch(() => props.modelValue, (open) => {
touched.name = false
})
watch(() => form.statuses.map(s => s.category), (cats, prev) => {
if (!prev) return
cats.forEach((cat, i) => {
const s = form.statuses[i]
if (s && cat !== prev[i] && s.color === STATUS_CATEGORY_COLOR[prev[i] as StatusCategory]) {
s.color = STATUS_CATEGORY_COLOR[cat as StatusCategory]
}
})
})
function addStatus() {
form.statuses.push({
label: '',
color: '#222783',
color: STATUS_CATEGORY_COLOR.todo,
position: form.statuses.length,
isFinal: false,
category: 'todo',

View File

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

View File

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

View File

@@ -0,0 +1,35 @@
<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>

View File

@@ -27,7 +27,7 @@
:title="$t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') })"
/>
</div>
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
<h4 class="line-clamp-2 text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
</div>
<MalioButtonIcon
:icon="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'"
@@ -39,7 +39,7 @@
/>
</div>
<div class="mt-2 flex items-center gap-1.5">
<div class="mt-2 flex flex-wrap items-center gap-1.5">
<span
v-if="showStatusBadge && task.status"
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"

View File

@@ -55,7 +55,7 @@
</div>
<!-- Body -->
<form @submit.prevent="handleSubmit" class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
<form @submit.prevent="handleSubmit" class="min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
<!-- Tabs -->
<div class="border-b border-neutral-100 -mx-4 px-4 sm:-mx-8 sm:px-8 mb-4">
<nav class="flex gap-6">
@@ -481,73 +481,54 @@
</NuxtLink>
</div>
<!-- Bouton lier un mail -->
<div class="pt-2">
<MalioButton
:label="$t('mail.taskTab.linkButton')"
variant="secondary"
icon-name="material-symbols:link"
icon-position="left"
:icon-size="14"
button-class="w-auto"
@click="showMailPickerModal = true"
/>
</div>
<!-- Modal picker mail -->
<MailPickerModal
v-if="task"
v-model="showMailPickerModal"
:task-id="task.id"
@linked="handleMailLinked"
/>
</div>
<!-- Footer -->
<div
class="mt-6 flex items-center border-t border-neutral-100 pt-5"
:class="isEditing ? 'justify-between' : 'justify-end'"
>
</form>
<!-- Footer -->
<div
class="shrink-0 flex items-center border-t border-neutral-100 bg-white px-4 py-4 sm:px-8 sm:py-5"
:class="isEditing ? 'justify-between' : 'justify-end'"
>
<MalioButton
v-if="isEditing"
variant="danger"
label="Supprimer"
button-class="w-auto px-4"
:disabled="isSubmitting"
@click="confirmDeleteOpen = true"
/>
<div class="flex gap-3">
<MalioButton
v-if="isEditing"
variant="danger"
label="Supprimer"
v-if="canArchive"
variant="tertiary"
:label="$t('archive.archiveButton')"
button-class="w-auto px-4"
:disabled="isSubmitting"
@click="confirmDeleteOpen = true"
@click="handleArchive"
/>
<MalioButton
v-if="canUnarchive"
variant="tertiary"
:label="$t('archive.unarchiveButton')"
button-class="w-auto px-4"
:disabled="isSubmitting"
@click="handleUnarchive"
/>
<MalioButton
variant="tertiary"
label="Annuler"
button-class="w-auto px-4"
@click="close"
/>
<MalioButton
label="Enregistrer"
button-class="w-auto px-6"
:disabled="isSubmitting"
@click="handleSubmit"
/>
<div class="flex gap-3">
<MalioButton
v-if="canArchive"
variant="tertiary"
:label="$t('archive.archiveButton')"
button-class="w-auto px-4"
:disabled="isSubmitting"
@click="handleArchive"
/>
<MalioButton
v-if="canUnarchive"
variant="tertiary"
:label="$t('archive.unarchiveButton')"
button-class="w-auto px-4"
:disabled="isSubmitting"
@click="handleUnarchive"
/>
<MalioButton
variant="tertiary"
label="Annuler"
button-class="w-auto px-4"
@click="close"
/>
<MalioButton
label="Enregistrer"
button-class="w-auto px-6"
:disabled="isSubmitting"
@click="handleSubmit"
/>
</div>
</div>
</form>
</div>
<ConfirmDeleteTaskModal
v-model="confirmDeleteOpen"
@@ -624,7 +605,6 @@ const activeTab = ref<'details' | 'planning' | 'mails'>('details')
const mailService = useMailService()
const linkedMails = ref<MailMessageHeaderDto[]>([])
const mailsLoading = ref(false)
const showMailPickerModal = ref(false)
const giteaUrl = ref('')
const { getSettings: getGiteaSettings } = useGiteaService()
@@ -671,10 +651,27 @@ const touched = reactive({
project: false,
})
const statusOptions = computed(() =>
props.statuses.map(s => ({ label: s.label, value: s.id }))
const showProjectSelect = computed(() => !!props.projects?.length && !isEditing.value)
const projectOptions = computed(() =>
(props.projects ?? []).map(p => ({ label: p.name, value: p.id }))
)
const resolvedProjectId = computed(() =>
showProjectSelect.value ? form.projectId : props.projectId
)
const statusOptions = computed(() => {
const project = props.projects?.find(p => p.id === resolvedProjectId.value)
const wfStatuses = project?.workflow?.statuses ?? props.statuses
const opts = wfStatuses.map(s => ({ label: s.label, value: s.id }))
const current = props.task?.status
if (current && !wfStatuses.some(s => s.id === current.id)) {
opts.unshift({ label: current.label, value: current.id })
}
return opts
})
const effortOptions = computed(() =>
props.efforts.map(e => ({ label: e.label, value: e.id }))
)
@@ -707,16 +704,6 @@ const groupOptions = computed(() => {
return filtered.map(g => ({ label: g.title, value: g.id }))
})
const showProjectSelect = computed(() => !!props.projects?.length && !isEditing.value)
const projectOptions = computed(() =>
(props.projects ?? []).map(p => ({ label: p.name, value: p.id }))
)
const resolvedProjectId = computed(() =>
showProjectSelect.value ? form.projectId : props.projectId
)
const canArchive = computed(() => {
if (!isEditing.value || !props.task) return false
if (props.task.archived) return false
@@ -933,11 +920,6 @@ watch(activeTab, async (tab) => {
}
})
async function handleMailLinked(): Promise<void> {
showMailPickerModal.value = false
await loadLinkedMails()
}
function formatMailDate(iso: string | null): string {
if (!iso) return ''
return new Date(iso).toLocaleDateString('fr', {

View File

@@ -0,0 +1,87 @@
<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>

View File

@@ -236,7 +236,8 @@
"sortBy": "Trier par",
"sortDefault": "Par défaut",
"sortDeadline": "Échéance",
"sortScheduledStart": "Date planifiée"
"sortScheduledStart": "Date planifiée",
"dropRefused": "Aucun statut de cette colonne dans le workflow de ce projet"
},
"dashboard": {
"title": "Tableau de bord",
@@ -570,8 +571,9 @@
"projectPlaceholder": "Sélectionner un projet",
"groupLabel": "Groupe (optionnel)",
"groupPlaceholder": "Aucun groupe",
"priorityLabel": "Priorité (optionnelle)",
"priorityPlaceholder": "Aucune priorité",
"statusLabel": "Statut",
"assigneeLabel": "Assigné à",
"assigneePlaceholder": "Aucun",
"titleHint": "Le titre sera rempli depuis le sujet du mail.",
"descriptionHint": "La description sera remplie depuis le corps du mail."
},
@@ -584,17 +586,9 @@
"empty": "Aucune tâche correspondante.",
"loading": "Recherche en cours…"
},
"pickerModal": {
"title": "Lier un mail à cette tâche",
"searchPlaceholder": "Rechercher un mail (sujet, expéditeur)…",
"empty": "Aucun mail correspondant.",
"loading": "Chargement des mails…",
"submit": "Lier ce mail"
},
"taskTab": {
"title": "Mails",
"empty": "Aucun mail lié à cette tâche.",
"linkButton": "Lier un mail",
"openInMailer": "Ouvrir dans la messagerie",
"unlinkConfirm": "Délier ce mail ?"
},

View File

@@ -8,7 +8,7 @@ import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project'
import type { StatusCategory } from '~/services/dto/workflow'
import { STATUS_CATEGORY_LABEL } from '~/services/dto/workflow'
import { STATUS_CATEGORY_LABEL, STATUS_CATEGORY_COLOR, contrastText } from '~/services/dto/workflow'
import { useTaskService } from '~/services/tasks'
import { useTaskStatusService } from '~/services/task-statuses'
import { useTaskEffortService } from '~/services/task-efforts'
@@ -71,6 +71,48 @@ const selectedTask = ref<Task | null>(null)
// Timer
const timerStore = useTimerStore()
// Toast
const toast = useToast()
// Drag & drop
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[] {
// GET /tasks n'embarque que l'IRI du workflow ; on résout depuis la liste projects chargée (qui embarque workflow.statuses).
const project = projects.value.find(p => p.id === task.project?.id)
const wf = project?.workflow
if (!wf || typeof wf === 'string') 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()
}
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({ message: t('myTasks.dropRefused') })
return
}
if (candidates.length === 1) {
void applyStatus(task, candidates[0])
return
}
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
}
function isTimerOnTask(task: Task): boolean {
const entry = timerStore.activeEntry
if (!entry?.task) return false
@@ -397,9 +439,16 @@ onMounted(async () => {
<div
v-for="cat in CATEGORIES"
:key="cat"
class="flex min-w-40 flex-1 flex-col rounded-lg bg-neutral-50"
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 bg-neutral-200 px-4 py-3 text-sm font-bold text-neutral-800">
<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>
<div class="min-h-0 flex-1 overflow-y-auto p-3">
@@ -481,6 +530,16 @@ onMounted(async () => {
</p>
</div>
<!-- StatusPickerPopover (D&D ambiguity resolution) -->
<StatusPickerPopover
v-if="pendingPicker"
:statuses="pendingPicker.statuses"
:x="pendingPicker.x"
:y="pendingPicker.y"
@pick="onPickerChoice"
@cancel="pendingPicker = null"
/>
<!-- TaskModal -->
<TaskModal
v-model="taskModalOpen"

View File

@@ -107,7 +107,8 @@ export type MailMessageFlagInput = {
export type MailCreateTaskInput = {
projectId: number
taskGroupId?: number | null
priority?: string | null
assigneeId?: number
statusId?: number
}
// Input : lier une tâche existante à un mail

View File

@@ -10,6 +10,25 @@ export const STATUS_CATEGORY_LABEL: Record<StatusCategory, string> = {
done: 'Terminé',
}
/** 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'
}
export type Workflow = {
id: number
'@id'?: string

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260521094948 extends AbstractMigration
{
public function getDescription(): string
{
return 'Remet les couleurs classiques sur les statuts du workflow Standard (derive data prod).';
}
public function up(Schema $schema): void
{
$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 = '".$category."' AND workflow_id = (SELECT id FROM workflow WHERE name = 'Standard' ORDER BY id ASC LIMIT 1)"
);
}
}
public function down(Schema $schema): void
{
$this->throwIrreversibleMigration('Correction de couleurs non reversible.');
}
}

View File

@@ -8,7 +8,8 @@ use App\Entity\Project;
use App\Entity\Task;
use App\Entity\TaskGroup;
use App\Entity\TaskMailLink;
use App\Entity\TaskPriority;
use App\Entity\TaskStatus;
use App\Entity\User;
use App\Repository\MailMessageRepository;
use App\Repository\TaskRepository;
use App\Security\MailAccessChecker;
@@ -74,13 +75,25 @@ class MailCreateTaskController extends AbstractController
}
}
if (isset($body['priorityId']) && null !== $body['priorityId']) {
$priority = $this->em->getRepository(TaskPriority::class)->find($body['priorityId']);
if (null !== $priority) {
$task->setPriority($priority);
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);
}
$this->em->persist($task);
$link = new TaskMailLink();

View File

@@ -4,7 +4,12 @@ declare(strict_types=1);
namespace App\Tests\Functional\Controller\Mail;
use App\Entity\MailFolder;
use App\Entity\MailMessage;
use App\Entity\Project;
use App\Entity\Task;
use App\Entity\User;
use DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
@@ -69,4 +74,67 @@ class MailTaskIntegrationControllerTest extends WebTestCase
self::assertResponseStatusCodeSame(401);
}
public function testCreateTaskAppliesStatusAndAssigneeAndIgnoresPriority(): void
{
$client = static::createClient();
$container = static::getContainer();
$em = $container->get('doctrine.orm.entity_manager');
$admin = $em->getRepository(User::class)->findOneBy(['username' => 'admin']);
$client->loginUser($admin);
$project = $em->getRepository(Project::class)->findOneBy([]);
self::assertNotNull($project);
$status = $project->getWorkflow()->getStatuses()->first();
self::assertNotFalse($status);
// Create a mail folder + message in the test DB (none in fixtures)
$folder = new MailFolder();
$folder->setDisplayName('Boîte de réception');
$folder->setUnreadCount(0);
$folder->setTotalCount(0);
$em->persist($folder);
$rand = random_int(100000, 999999);
$folder->setPath('INBOX.'.$rand);
$message = new MailMessage();
$message->setMessageId('test-'.$rand.'@example.com');
$message->setFolder($folder);
$message->setUid($rand);
$message->setFromAddress('sender@example.com');
$message->setToAddresses([]);
$message->setSentAt(new DateTimeImmutable());
$message->setIsRead(false);
$message->setIsFlagged(false);
$message->setHasAttachments(false);
$message->setSyncedAt(new DateTimeImmutable());
$message->setSubject('Sujet de test');
$em->persist($message);
$em->flush();
$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);
$em->clear();
$task = $em->getRepository(Task::class)->find($payload['taskId']);
self::assertSame($status->getId(), $task->getStatus()?->getId());
self::assertSame($admin->getId(), $task->getAssignee()?->getId());
self::assertNull($task->getPriority());
}
}