Compare commits

...

5 Commits

Author SHA1 Message Date
Matthieu
e33322e793 docs : specs correctifs UI workflow + UX mail/modales
Liste les 7 chantiers issus des reviews (Lucile Schnödt, Tristan
Schnödtin) suite à l'arrivée des workflows : drag & drop mes tâches,
filtrage du sélecteur de statut par workflow, cartes responsive,
couleurs classiques du workflow de base, suppression du bouton lier un
mail, création de ticket depuis un mail (user + statut), et composant
modale réutilisable avec footer sticky.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:06:44 +02:00
gitea-actions
eb2adc9fdc chore: bump version to v0.4.3
All checks were successful
Build & Push Docker Image / build (push) Successful in 53s
Auto Tag Develop / tag (push) Successful in 7s
2026-05-20 08:22:48 +00:00
Matthieu
4775cbf184 feat(ui) : palette de couleurs élargie + couleur personnalisée, fix champ code projet
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
ColorPicker : passe de 9 à 18 teintes prédéfinies (les 9 historiques
conservées en tête pour ne pas désassocier les couleurs existantes) et
ajoute une pastille « couleur personnalisée » (input natif type=color)
permettant n'importe quel hex. Partagé, donc bénéficie aussi aux tags,
priorités, groupes et workflows.

fix(project) : le champ code restait en minuscules. Le @input mutait
form.code à partir de l'ancienne valeur, puis l'émission update:modelValue
de MalioInputText l'écrasait avec la saisie brute → form.code en
minuscules (affiché en majuscules via CSS) → /^[A-Z]{2,10}$/ en échec →
création bloquée. Remplacé par un computed setter (source unique de
vérité : majuscules + lettres uniquement + max 10) et maxLength sur le
champ.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:22:38 +02:00
gitea-actions
8be96bce0c chore: bump version to v0.4.2
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 27s
2026-05-20 07:56:32 +00:00
Matthieu
fb97b8d4e3 fix(mail) : sync à la demande synchrone pour le bouton rafraîchir
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Route MailSyncRequested vers le transport sync au lieu d'async : la
synchro IMAP s'exécute pendant la requête HTTP du clic « rafraîchir »,
donc le re-fetch du front voit immédiatement les nouveaux mails, sans
worker messenger:consume à maintenir en prod. La sync de fond reste
assurée par le cron OS (app:mail:sync, synchrone, indépendant du bus).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:56:23 +02:00
5 changed files with 255 additions and 16 deletions

View File

@@ -18,7 +18,12 @@ framework:
failed: 'doctrine://default?queue_name=failed&auto_setup=0'
routing:
'App\Message\MailSyncRequested': async
# Sync à la demande (bouton « rafraîchir ») : exécutée pendant la requête HTTP
# pour que le re-fetch du front voie immédiatement les nouveaux mails, sans worker
# messenger:consume à maintenir. La sync de fond reste assurée par le cron OS
# (app:mail:sync, synchrone, indépendant du bus). Repasser à `async` + worker si
# la boîte grossit au point que la sync à la demande approche le timeout PHP.
'App\Message\MailSyncRequested': sync
when@test:
framework:

View File

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

View File

@@ -0,0 +1,182 @@
# 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) |
---
## 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).
---
## 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

@@ -2,13 +2,13 @@
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('projects.editProject') : $t('projects.addProject')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.code"
v-model="codeProxy"
label="Code"
input-class="w-full uppercase"
input-class="w-full"
:max-length="10"
:disabled="isEditing"
:error="touched.code && !form.code.trim() ? 'Le code est requis' : touched.code && !/^[A-Z]{2,10}$/.test(form.code.trim()) ? '2 à 10 lettres majuscules' : ''"
:error="touched.code && !form.code ? 'Le code est requis' : touched.code && !/^[A-Z]{2,10}$/.test(form.code) ? '2 à 10 lettres majuscules' : ''"
@blur="touched.code = true"
@input="form.code = form.code.toUpperCase().replace(/[^A-Z]/g, '')"
/>
<MalioInputText
v-model="form.name"
@@ -186,6 +186,17 @@ const touched = reactive({
name: false,
})
// Source unique de vérité : on sanitise dans le setter (majuscules, lettres
// uniquement, max 10) plutôt que via @input — sinon course entre la mutation
// manuelle et l'émission update:modelValue de MalioInputText, qui laissait
// form.code en minuscules et bloquait la création.
const codeProxy = computed({
get: () => form.code,
set: (value: string) => {
form.code = (value ?? '').toUpperCase().replace(/[^A-Z]/g, '').slice(0, 10)
},
})
const clientOptions = computed(() =>
props.clients.map(c => ({ label: c.name, value: c.id }))
)
@@ -222,7 +233,7 @@ async function handleSubmit() {
touched.name = true
touched.code = true
if (!form.name.trim()) return
if (!isEditing.value && (!form.code.trim() || !/^[A-Z]{2,10}$/.test(form.code.trim()))) return
if (!isEditing.value && !/^[A-Z]{2,10}$/.test(form.code)) return
isSubmitting.value = true
try {
@@ -254,7 +265,7 @@ async function handleSubmit() {
if (isEditing.value && props.project) {
await update(props.project.id, payload)
} else {
payload.code = form.code.trim()
payload.code = form.code
await create(payload)
}

View File

@@ -1,22 +1,45 @@
<template>
<div>
<p class="mb-2 text-sm font-medium text-neutral-700">Couleur</p>
<div class="flex flex-wrap gap-3">
<div class="flex flex-wrap items-center gap-3">
<button
v-for="color in colors"
v-for="color in presets"
:key="color"
type="button"
class="h-10 w-10 rounded-full border-2 transition-transform hover:scale-110"
:class="modelValue === color ? 'border-neutral-900 scale-110' : 'border-transparent'"
:class="isSelected(color) ? 'border-neutral-900 scale-110' : 'border-transparent'"
:style="{ backgroundColor: color }"
@click="emit('update:modelValue', color)"
:aria-label="`Choisir la couleur ${color}`"
@click="select(color)"
/>
<!-- Couleur personnalisée : input natif déguisé en pastille -->
<label
class="relative flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border-2 transition-transform hover:scale-110"
:class="isCustom ? 'border-neutral-900 scale-110' : 'border-dashed border-neutral-400'"
:style="isCustom ? { backgroundColor: modelValue } : {}"
title="Couleur personnalisée"
>
<input
type="color"
class="absolute inset-0 h-full w-full cursor-pointer opacity-0"
:value="modelValue"
aria-label="Choisir une couleur personnalisée"
@input="select(($event.target as HTMLInputElement).value)"
>
<Icon
v-if="!isCustom"
name="mdi:plus"
class="text-neutral-500"
size="20"
/>
</label>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
const props = defineProps<{
modelValue: string
}>()
@@ -24,8 +47,26 @@ const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const colors = [
'#222783', '#26A69A', '#E91E63', '#4A90D9',
'#7E57C2', '#8BC34A', '#FDD835', '#80DEEA', '#FF7043',
// Les 9 premières sont historiques (couleurs déjà en base) — ne pas réordonner
// pour que les projets/tags existants restent associés à une pastille.
const presets = [
'#222783', '#26A69A', '#E91E63', '#4A90D9', '#7E57C2',
'#8BC34A', '#FDD835', '#80DEEA', '#FF7043', '#EF4444',
'#F97316', '#F59E0B', '#22C55E', '#10B981', '#06B6D4',
'#3B82F6', '#8B5CF6', '#64748B',
]
const norm = (value: string): string => (value ?? '').toUpperCase()
function isSelected(color: string): boolean {
return norm(props.modelValue) === norm(color)
}
const isCustom = computed(
() => !!props.modelValue && !presets.some((c) => norm(c) === norm(props.modelValue)),
)
function select(value: string): void {
emit('update:modelValue', norm(value))
}
</script>