Compare commits

...

9 Commits

Author SHA1 Message Date
gitea-actions
8f2a688740 chore: bump version to v0.4.6
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 48s
2026-05-21 09:19:01 +00:00
Matthieu
6491943930 docs : ajoute la section Messagerie au centre d'aide + maj admin/intégrations (mail OVH)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:18:42 +02:00
gitea-actions
a9f05fd819 chore: bump version to v0.4.5
All checks were successful
Build & Push Docker Image / build (push) Successful in 53s
Auto Tag Develop / tag (push) Successful in 6s
2026-05-21 08:57:40 +00:00
Matthieu
925be5d181 fix(ui) : sélecteur de statut Malio dans le drawer de création de workflow
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Remplace le <select> natif du navigateur par MalioSelect (catégorie de statut).
MalioSelect accepte les valeurs string (enum StatusCategory), contrairement à ce
qu'indiquait la note CLAUDE.md — note corrigée en conséquence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 10:53:19 +02:00
Matthieu
5da165f739 docs : corrige le déploiement prod (Docker) et documente les variables d'env mail
- README : section Variables d'environnement (ENCRYPTION_KEY, LOCK_DSN) + section Déploiement passée au flow Docker (deploy.sh)
- mail-cron-setup : sépare dev (make, php-lesstime-fpm) et prod (lesstime-app, docker compose exec), cron prod réel
- infra/prod/.env.example : ajoute ENCRYPTION_KEY et LOCK_DSN (manquaient, requis pour la sync mail)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 10:48:55 +02:00
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
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
27 changed files with 1972 additions and 595 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 - Middleware global `auth.global.ts` protège les routes
- Traductions dans `frontend/i18n/locales/` (le module résout `langDir` depuis `i18n/`) - Traductions dans `frontend/i18n/locales/` (le module résout `langDir` depuis `i18n/`)
- 4 espaces d'indentation - 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 }` — accepte les valeurs **string** (enums string OK, ex `category`/`StatusCategory`), pas seulement `number` (vérifié dans la source `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). Largeur via `group-class` (pas de prop `minWidth`/`min-width`). ⚠️ Le `COMPONENTS.md` de la lib est inexact sur ce composant (il indique une clé `text` et une prop `minWidth` inexistantes) : la clé d'affichage réelle est `label`. Ne jamais modifier la lib `malio-layer-ui` depuis ce projet.
- Portal client : pages sous `/portal/`, layout `portal.vue`, middleware redirige `ROLE_CLIENT` (sans `ROLE_ADMIN`) vers `/portal` - 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 - Users admin+client : ne pas bloquer — vérifier `ROLE_CLIENT && !ROLE_ADMIN` pour les restrictions

View File

@@ -45,6 +45,10 @@ make install
L'application est accessible sur **http://localhost:8082**. L'application est accessible sur **http://localhost:8082**.
Les valeurs par défaut du `.env` committé suffisent pour démarrer en local. Pour la prod
(et pour activer la messagerie), surcharger les variables sensibles dans `.env.local`
voir « Variables d'environnement » ci-dessous.
### Comptes de test (fixtures) ### Comptes de test (fixtures)
| Utilisateur | Mot de passe | Rôle | Détails | | Utilisateur | Mot de passe | Rôle | Détails |
@@ -56,6 +60,25 @@ L'application est accessible sur **http://localhost:8082**.
| `client-liot` | `client` | ROLE_CLIENT | Client LIOT (projet SIRH) | | `client-liot` | `client` | ROLE_CLIENT | Client LIOT (projet SIRH) |
| `client-acme` | `client` | ROLE_CLIENT | Client ACME (projet CRM) | | `client-acme` | `client` | ROLE_CLIENT | Client ACME (projet CRM) |
## Variables d'environnement
Les variables sont définies dans `.env` (committé, valeurs par défaut pour le dev) et
peuvent être surchargées dans `.env.local` (jamais committé). En prod, elles vont dans le
`.env` du serveur (`/var/www/lesstime/.env`, voir `infra/prod/.env.example`).
| Variable | Rôle | Défaut dev | À fixer en prod |
|----------|------|-----------|-----------------|
| `APP_SECRET` | Secret Symfony | placeholder | ✅ (hex 32) |
| `JWT_PASSPHRASE` | Passphrase des clés JWT | placeholder | ✅ |
| `DATABASE_URL` | Connexion PostgreSQL | container `db` | ✅ (`host.docker.internal`) |
| `CORS_ALLOW_ORIGIN` | Origines CORS autorisées | localhost | ✅ (domaine prod) |
| **`ENCRYPTION_KEY`** | **Clé hex 32 bytes chiffrant les credentials IMAP/SMTP (feature mail)** | placeholder | ✅ — doit rester **stable**, sinon les credentials mail stockés deviennent illisibles |
| **`LOCK_DSN`** | **Store de verrous Symfony pour la sync mail (anti-chevauchement)** | `flock` | `flock` suffit |
> **Messagerie** : `ENCRYPTION_KEY` et `LOCK_DSN` sont introduites par l'intégration mail.
> Détails de config et cron de synchronisation : `docs/mail-integration.md` et `docs/mail-cron-setup.md`.
> Générer une clé : `php -r "echo bin2hex(random_bytes(32));"`.
## Commandes ## Commandes
### Docker ### Docker
@@ -218,13 +241,19 @@ docker exec -u www-data php-lesstime-fpm php bin/console app:generate-api-token
## Déploiement ## Déploiement
1. Déployer le code sur le serveur La prod tourne en **Docker** : l'image est buildée par la CI Gitea sur push de tag `v*`
2. `composer install --no-dev --optimize-autoloader` (`gitea.malio.fr/malio-dev/lesstime:<tag>`), puis déployée par le script `deploy.sh` sur
3. `php bin/console doctrine:migrations:migrate --no-interaction` le serveur (dossier `/var/www/lesstime`, container `lesstime-app`).
4. `php bin/console cache:clear --env=prod`
5. `cd frontend && npm install && npm run build:dist` ```bash
6. `docker restart nginx-lesstime` # Sur le serveur, depuis /var/www/lesstime
7. Ouvrir le port 8082 sur le firewall (LAN uniquement) sudo ./deploy.sh # déploie la dernière image (latest)
sudo ./deploy.sh v0.4.2 # déploie une version précise
```
Le script active la maintenance, pull l'image, redémarre le container, lance les migrations
et vide le cache. Guide complet (première installation, BDD, Nginx, JWT, rollback) :
**`doc/deployment-docker.md`**.
## Licence ## Licence

View File

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.4.2' app.version: '0.4.6'

View File

@@ -3,44 +3,24 @@
## Vue d'ensemble ## Vue d'ensemble
La synchronisation IMAP est déclenchée par un cron OS toutes les 10 minutes. La synchronisation IMAP est déclenchée par un cron OS toutes les 10 minutes.
Elle appelle la commande Symfony `app:mail:sync` qui s'exécute dans le container PHP. Elle appelle la commande Symfony `app:mail:sync` qui s'exécute **dans le container PHP**.
Un Symfony Lock (`mail.sync`, TTL 10 min, store `flock` via `LOCK_DSN=flock`) empêche Un Symfony Lock (`mail.sync`, TTL 10 min, store `flock` via `LOCK_DSN=flock`) empêche
les runs de se chevaucher si une sync prend plus de 10 min. les runs de se chevaucher si une sync prend plus de 10 min.
> **Dev vs prod** — en dev le container s'appelle `php-lesstime-fpm` et on passe par `make`.
> En **production** le container s'appelle `lesstime-app` (service `app` du `docker-compose.yml`
> dans `/var/www/lesstime`), il n'y a **pas de `make`** : tout passe par `docker compose` / `docker exec`.
## Prérequis ## Prérequis
- Container `php-lesstime-fpm` démarré (`make start`) - `MailConfiguration.enabled = true` (configurable depuis l'admin — onglet « Mail »)
- `MailConfiguration.enabled = true` (configurable depuis l'admin — Phase 7) - `ENCRYPTION_KEY` (clé hex 32 bytes) défini dans l'environnement :
- `ENCRYPTION_KEY` défini dans `infra/dev/.env.docker.local` (ou production env) - **dev** : `infra/dev/.env.docker.local`
- **prod** : `/var/www/lesstime/.env`
## Installation du cron - Container démarré :
- **dev** : `make start` (container `php-lesstime-fpm`)
Sur la **machine hôte** (pas dans le container) : - **prod** : déployé via `sudo ./deploy.sh` (container `lesstime-app`)
```bash
crontab -e
```
Ajouter la ligne suivante (adapter le chemin) :
```cron
*/10 * * * * cd /home/r-dev/malio-dev/Lesstime && make mail-sync >> /var/log/lesstime-mail-sync.log 2>&1
```
Ou directement via `docker exec` (sans dépendance à `make`) :
```cron
*/10 * * * * docker exec php-lesstime-fpm php bin/console app:mail:sync >> /var/log/lesstime-mail-sync.log 2>&1
```
### Avec un utilisateur système dédié
Si le cron est configuré pour un utilisateur système spécifique (ex: `www-data` ou `deploy`) :
```bash
sudo crontab -u deploy -e
```
## Variables d'environnement nécessaires ## Variables d'environnement nécessaires
@@ -49,63 +29,100 @@ sudo crontab -u deploy -e
| `ENCRYPTION_KEY` | Clé hex 32 bytes pour déchiffrer le password IMAP | `$(php -r "echo bin2hex(random_bytes(32));")` | | `ENCRYPTION_KEY` | Clé hex 32 bytes pour déchiffrer le password IMAP | `$(php -r "echo bin2hex(random_bytes(32));")` |
| `LOCK_DSN` | DSN du store de verrous Symfony | `flock` (défaut, fichier local) | | `LOCK_DSN` | DSN du store de verrous Symfony | `flock` (défaut, fichier local) |
La clé doit être la même que celle utilisée pour chiffrer le password lors de la configuration. La clé `ENCRYPTION_KEY` doit être **identique** à celle utilisée pour chiffrer le password
lors de la configuration depuis l'admin. Si elle change, les credentials stockés deviennent illisibles.
## Checklist setup production ---
1. [ ] Définir `ENCRYPTION_KEY` dans les variables d'environnement production ## Dev
2. [ ] Créer le compte mail dédié (ex: `lesstime@votre-domaine.fr`) chez OVH
3. [ ] Accéder à `/admin` → onglet "Mail" → renseigner les credentials IMAP/SMTP
4. [ ] Cliquer "Tester la connexion" → vérifier le succès
5. [ ] Cocher "Activer la synchronisation" → Enregistrer
6. [ ] Installer le cron OS (voir section "Installation du cron")
7. [ ] Vérifier les logs après la première sync : `make logs-dev` (chercher `mail.sync`)
## Commandes utiles ### Lancer une sync à la main
```bash ```bash
# Sync complète (toutes les boîtes) make mail-sync # sync complète (toutes les boîtes)
make mail-sync make mail-sync FOLDER=INBOX # un seul dossier (doit déjà exister en base)
make mail-sync DRYRUN=1 # simulation (dry-run, pas d'écriture BDD)
```
# Sync d'un seul dossier (le dossier doit déjà exister en base) Ou directement dans le container :
make mail-sync FOLDER=INBOX
# Simulation (dry-run, pas d'écriture BDD) ```bash
make mail-sync DRYRUN=1
# Directement dans le container
docker exec php-lesstime-fpm php bin/console app:mail:sync docker exec php-lesstime-fpm php bin/console app:mail:sync
docker exec php-lesstime-fpm php bin/console app:mail:sync --folder=INBOX docker exec php-lesstime-fpm php bin/console app:mail:sync --folder=INBOX
docker exec php-lesstime-fpm php bin/console app:mail:sync --dry-run docker exec php-lesstime-fpm php bin/console app:mail:sync --dry-run
``` ```
## Logs ### Logs (dev)
Les logs Symfony sont dans `var/log/dev.log` (ou `prod.log` en production).
Suivre les logs en temps réel :
```bash ```bash
make logs-dev make logs-dev # tail -f var/log/dev.log
``` ```
Les messages loggés par `MailSyncService` sont préfixés `mail.sync`. Les messages loggés par `MailSyncService` sont préfixés `mail.sync`.
---
## Production
En prod, l'app tourne dans le container `lesstime-app` déployé par `sudo ./deploy.sh`
(dossier `/var/www/lesstime`). La commande s'exécute en tant que `www-data` (uid 33),
comme les migrations lancées par `deploy.sh`.
### Lancer une sync à la main
Depuis `/var/www/lesstime` :
```bash
sudo docker compose exec -T -u www-data app php bin/console app:mail:sync
sudo docker compose exec -T -u www-data app php bin/console app:mail:sync --folder=INBOX
sudo docker compose exec -T -u www-data app php bin/console app:mail:sync --dry-run
```
### Installer le cron
Sur la **machine hôte** (pas dans le container). Comme `docker` requiert `sudo` en prod,
installer le cron sous root :
```bash
sudo crontab -e
```
Ajouter :
```cron
*/10 * * * * cd /var/www/lesstime && docker compose exec -T -u www-data app php bin/console app:mail:sync >> /var/log/lesstime-mail-sync.log 2>&1
```
> Le crontab de root exécute déjà les commandes en root → pas de `sudo` à l'intérieur de la ligne cron.
> La commande est **idempotente** (UIDs uniques en base) : la relancer ne duplique pas les données.
### Logs (prod)
```bash
cd /var/www/lesstime
docker compose logs -f --tail=100 app # logs container
docker compose exec app cat var/log/prod.log # log Symfony (volume lesstime_logs)
```
### Checklist setup production
1. [ ] Définir `ENCRYPTION_KEY` (hex 32 bytes) et `LOCK_DSN=flock` dans `/var/www/lesstime/.env`
2. [ ] Créer le compte mail dédié (ex: `lesstime@votre-domaine.fr`) chez OVH
3. [ ] Accéder à `/admin` → onglet « Mail » → renseigner les credentials IMAP/SMTP
4. [ ] Cliquer « Tester la connexion » → vérifier le succès
5. [ ] Cocher « Activer la synchronisation » → Enregistrer
6. [ ] Lancer une sync manuelle pour valider (commande ci-dessus)
7. [ ] Installer le cron OS (voir « Installer le cron »)
8. [ ] Vérifier les logs après la première sync (`docker compose logs -f app`, chercher `mail.sync`)
---
## Sécurité ## Sécurité
- Le password IMAP est **toujours stocké chiffré** (libsodium secretbox) - Le password IMAP est **toujours stocké chiffré** (libsodium secretbox)
- Les corps de mails, passwords et pièces jointes ne sont **jamais loggés** - Les corps de mails, passwords et pièces jointes ne sont **jamais loggés**
- Le lock `flock` évite les runs parallèles (fichier dans `/tmp/sf.mail.sync.<hash>.lock`) - Le lock `flock` évite les runs parallèles (fichier dans `/tmp/sf.mail.sync.<hash>.lock`)
## Rappels sécurité
- La page `/mail` et tous les endpoints `/api/mail/*` sont refusés aux `ROLE_CLIENT` exclusifs - La page `/mail` et tous les endpoints `/api/mail/*` sont refusés aux `ROLE_CLIENT` exclusifs
- Le sidebar "Messagerie" est masqué pour les utilisateurs ROLE_CLIENT sans ROLE_USER - Le sidebar « Messagerie » est masqué pour les utilisateurs `ROLE_CLIENT` sans `ROLE_USER`
- Le password IMAP est chiffré via libsodium secretbox avant stockage (jamais en clair en base) - Les corps de mails sont sanitisés via DOMPurify avant affichage (`frontend/utils/sanitizeMailHtml.ts`)
- Les corps de mails sont sanitisés via DOMPurify avant affichage (voir `frontend/utils/sanitizeMailHtml.ts`) - Les pixels de tracking distants sont remplacés par un placeholder
- Les pixels tracking distants sont remplacés par un placeholder
- Aucun body mail, password ou contenu de pièce jointe n'est loggé
## Production
En production, préférer un cron système ou un job scheduler (Kubernetes CronJob, ECS Scheduled Task, etc.).
La commande est idempotente : relancer plusieurs fois ne duplique pas les données (UIDs uniques en base).

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,12 @@
label="Libellé" label="Libellé"
input-class="w-full" input-class="w-full"
/> />
<select <MalioSelect
v-model="s.category" v-model="s.category"
class="h-10 rounded border border-neutral-300 px-2 text-sm" :options="categoryOptions"
aria-label="Catégorie" label="Catégorie"
> group-class="w-48 shrink-0"
<option v-for="c in categoryOptions" :key="c.value" :value="c.value"> />
{{ c.label }}
</option>
</select>
<button <button
type="button" type="button"
class="h-10 px-2 text-red-600 hover:text-red-800" class="h-10 px-2 text-red-600 hover:text-red-800"
@@ -97,6 +94,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Workflow, StatusCategory } from '~/services/dto/workflow' 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 type { TaskStatusWrite } from '~/services/dto/task-status'
import { useWorkflowService } from '~/services/workflows' import { useWorkflowService } from '~/services/workflows'
import { useTaskStatusService } from '~/services/task-statuses' import { useTaskStatusService } from '~/services/task-statuses'
@@ -171,10 +169,20 @@ watch(() => props.modelValue, (open) => {
touched.name = false 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() { function addStatus() {
form.statuses.push({ form.statuses.push({
label: '', label: '',
color: '#222783', color: STATUS_CATEGORY_COLOR.todo,
position: form.statuses.length, position: form.statuses.length,
isFinal: false, isFinal: false,
category: 'todo', category: 'todo',

View File

@@ -3,74 +3,63 @@ import type { MailMessageDetailDto } from '~/services/dto/mail'
import type { Task } from '~/services/dto/task' import type { Task } from '~/services/dto/task'
import type { Project } from '~/services/dto/project' import type { Project } from '~/services/dto/project'
import type { TaskGroup } from '~/services/dto/task-group' 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 { useMailService } from '~/services/mail'
import { useProjectService } from '~/services/projects' import { useProjectService } from '~/services/projects'
import { useTaskGroupService } from '~/services/task-groups' 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<{ const props = defineProps<{
/** v-model: true = modal ouvert */
modelValue: boolean modelValue: boolean
/** ID BDD du message source */
messageId: number messageId: number
/** Détail du message (pour afficher sujet/expéditeur en lecture seule) */
messageDetail: MailMessageDetailDto | null messageDetail: MailMessageDetailDto | null
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: boolean] 'update:modelValue': [value: boolean]
/** Émis après création réussie — payload = tâche créée */
created: [task: Task] created: [task: Task]
}>() }>()
const { t } = useI18n() const { t } = useI18n()
const auth = useAuthStore()
const mailService = useMailService() const mailService = useMailService()
const projectService = useProjectService() const projectService = useProjectService()
const taskGroupService = useTaskGroupService() const taskGroupService = useTaskGroupService()
const priorityService = useTaskPriorityService() const userService = useUserService()
// ─── État formulaire ──────────────────────────────────────────────────────
const projectId = ref<number | null>(null) const projectId = ref<number | null>(null)
const taskGroupId = 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 isSubmitting = ref(false)
const touchedProject = ref(false) const touchedProject = ref(false)
// ─── Données de référence ─────────────────────────────────────────────────
const projects = ref<Project[]>([]) const projects = ref<Project[]>([])
const groups = ref<TaskGroup[]>([]) const groups = ref<TaskGroup[]>([])
const priorities = ref<TaskPriority[]>([]) const users = ref<UserData[]>([])
const loadingGroups = ref(false) const loadingGroups = ref(false)
const projectOptions = computed(() => const projectOptions = computed(() => projects.value.map(p => ({ label: p.name, value: p.id })))
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(() => const selectedProject = computed(() => projects.value.find(p => p.id === projectId.value) ?? null)
groups.value.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id })), 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 () => { onMounted(async () => {
const [projs, prios] = await Promise.all([ const [projs, us] = await Promise.all([
projectService.getAll({ archived: false }), projectService.getAll({ archived: false }),
priorityService.getAll(), userService.getAll(),
]) ])
projects.value = projs projects.value = projs
priorities.value = prios users.value = us
}) })
// Recharger les groupes quand le projet change
watch(projectId, async (pid) => { watch(projectId, async (pid) => {
taskGroupId.value = null taskGroupId.value = null
statusId.value = selectedProject.value?.workflow?.statuses?.[0]?.id ?? null
groups.value = [] groups.value = []
if (!pid) return if (!pid) return
loadingGroups.value = true loadingGroups.value = true
@@ -81,18 +70,16 @@ watch(projectId, async (pid) => {
} }
}) })
// Reset formulaire à l'ouverture
watch(() => props.modelValue, (open) => { watch(() => props.modelValue, (open) => {
if (open) { if (open) {
projectId.value = null projectId.value = null
taskGroupId.value = null taskGroupId.value = null
priorityId.value = null statusId.value = null
assigneeId.value = auth.user?.id ?? null
touchedProject.value = false touchedProject.value = false
} }
}) })
// ─── Actions ──────────────────────────────────────────────────────────────
function close(): void { function close(): void {
emit('update:modelValue', false) emit('update:modelValue', false)
} }
@@ -100,13 +87,13 @@ function close(): void {
async function handleSubmit(): Promise<void> { async function handleSubmit(): Promise<void> {
touchedProject.value = true touchedProject.value = true
if (!projectId.value) return if (!projectId.value) return
isSubmitting.value = true isSubmitting.value = true
try { try {
const task = await mailService.createTaskFromMail(props.messageId, { const task = await mailService.createTaskFromMail(props.messageId, {
projectId: projectId.value, projectId: projectId.value,
taskGroupId: taskGroupId.value ?? undefined, taskGroupId: taskGroupId.value ?? undefined,
priority: priorityId.value ? `/api/task_priorities/${priorityId.value}` : undefined, assigneeId: assigneeId.value ?? undefined,
statusId: statusId.value ?? undefined,
}) })
emit('created', task) emit('created', task)
close() close()
@@ -117,135 +104,41 @@ async function handleSubmit(): Promise<void> {
</script> </script>
<template> <template>
<Teleport v-if="modelValue" to="body"> <AppModal
<Transition name="mail-modal" appear> :model-value="modelValue"
<div class="fixed inset-0 z-50 flex items-center justify-center p-4"> width="lg"
<!-- Backdrop --> :title="t('mail.createTaskModal.title')"
<div @update:model-value="emit('update:modelValue', $event)"
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm" >
@click="close" <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>
<!-- Modal --> <p class="mt-0.5 truncate text-xs text-neutral-500">{{ messageDetail.header.fromName ?? messageDetail.header.fromEmail }}</p>
<div <p class="mt-2 text-xs italic text-neutral-400">{{ t('mail.createTaskModal.titleHint') }}</p>
class="relative z-10 w-full max-w-lg rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 overflow-hidden" <p class="text-xs italic text-neutral-400">{{ t('mail.createTaskModal.descriptionHint') }}</p>
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>
</div> </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> </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

@@ -2,13 +2,13 @@
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('projects.editProject') : $t('projects.addProject')"> <MalioDrawer v-model="isOpen" :title="isEditing ? $t('projects.editProject') : $t('projects.addProject')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2"> <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText <MalioInputText
v-model="form.code" v-model="codeProxy"
label="Code" label="Code"
input-class="w-full uppercase" input-class="w-full"
:max-length="10"
:disabled="isEditing" :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" @blur="touched.code = true"
@input="form.code = form.code.toUpperCase().replace(/[^A-Z]/g, '')"
/> />
<MalioInputText <MalioInputText
v-model="form.name" v-model="form.name"
@@ -186,6 +186,17 @@ const touched = reactive({
name: false, 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(() => const clientOptions = computed(() =>
props.clients.map(c => ({ label: c.name, value: c.id })) props.clients.map(c => ({ label: c.name, value: c.id }))
) )
@@ -222,7 +233,7 @@ async function handleSubmit() {
touched.name = true touched.name = true
touched.code = true touched.code = true
if (!form.name.trim()) return 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 isSubmitting.value = true
try { try {
@@ -254,7 +265,7 @@ async function handleSubmit() {
if (isEditing.value && props.project) { if (isEditing.value && props.project) {
await update(props.project.id, payload) await update(props.project.id, payload)
} else { } else {
payload.code = form.code.trim() payload.code = form.code
await create(payload) await create(payload)
} }

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

View File

@@ -55,7 +55,7 @@
</div> </div>
<!-- Body --> <!-- 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 --> <!-- Tabs -->
<div class="border-b border-neutral-100 -mx-4 px-4 sm:-mx-8 sm:px-8 mb-4"> <div class="border-b border-neutral-100 -mx-4 px-4 sm:-mx-8 sm:px-8 mb-4">
<nav class="flex gap-6"> <nav class="flex gap-6">
@@ -481,73 +481,54 @@
</NuxtLink> </NuxtLink>
</div> </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> </div>
<!-- Footer --> </form>
<div
class="mt-6 flex items-center border-t border-neutral-100 pt-5" <!-- Footer -->
:class="isEditing ? 'justify-between' : 'justify-end'" <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 <MalioButton
v-if="isEditing" v-if="canArchive"
variant="danger" variant="tertiary"
label="Supprimer" :label="$t('archive.archiveButton')"
button-class="w-auto px-4" button-class="w-auto px-4"
:disabled="isSubmitting" :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> </div>
</form> </div>
<ConfirmDeleteTaskModal <ConfirmDeleteTaskModal
v-model="confirmDeleteOpen" v-model="confirmDeleteOpen"
@@ -624,7 +605,6 @@ const activeTab = ref<'details' | 'planning' | 'mails'>('details')
const mailService = useMailService() const mailService = useMailService()
const linkedMails = ref<MailMessageHeaderDto[]>([]) const linkedMails = ref<MailMessageHeaderDto[]>([])
const mailsLoading = ref(false) const mailsLoading = ref(false)
const showMailPickerModal = ref(false)
const giteaUrl = ref('') const giteaUrl = ref('')
const { getSettings: getGiteaSettings } = useGiteaService() const { getSettings: getGiteaSettings } = useGiteaService()
@@ -671,10 +651,27 @@ const touched = reactive({
project: false, project: false,
}) })
const statusOptions = computed(() => const showProjectSelect = computed(() => !!props.projects?.length && !isEditing.value)
props.statuses.map(s => ({ label: s.label, value: s.id }))
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(() => const effortOptions = computed(() =>
props.efforts.map(e => ({ label: e.label, value: e.id })) 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 })) 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(() => { const canArchive = computed(() => {
if (!isEditing.value || !props.task) return false if (!isEditing.value || !props.task) return false
if (props.task.archived) 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 { function formatMailDate(iso: string | null): string {
if (!iso) return '' if (!iso) return ''
return new Date(iso).toLocaleDateString('fr', { 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

@@ -1,22 +1,45 @@
<template> <template>
<div> <div>
<p class="mb-2 text-sm font-medium text-neutral-700">Couleur</p> <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 <button
v-for="color in colors" v-for="color in presets"
:key="color" :key="color"
type="button" type="button"
class="h-10 w-10 rounded-full border-2 transition-transform hover:scale-110" 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 }" :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>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ const props = defineProps<{
modelValue: string modelValue: string
}>() }>()
@@ -24,8 +47,26 @@ const emit = defineEmits<{
(e: 'update:modelValue', value: string): void (e: 'update:modelValue', value: string): void
}>() }>()
const colors = [ // Les 9 premières sont historiques (couleurs déjà en base) — ne pas réordonner
'#222783', '#26A69A', '#E91E63', '#4A90D9', // pour que les projets/tags existants restent associés à une pastille.
'#7E57C2', '#8BC34A', '#FDD835', '#80DEEA', '#FF7043', 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> </script>

View File

@@ -64,3 +64,13 @@ L'admin (`/admin`) est divisé en plusieurs onglets, chacun gérant une ressourc
- Configure le calendrier CalDav par défaut - Configure le calendrier CalDav par défaut
- Test de connexion intégré - Test de connexion intégré
- Active la **sync calendrier** sur les tâches planifiées - Active la **sync calendrier** sur les tâches planifiées
## Onglet *Mail*
Configure la **boîte mail partagée** (OVH) lue dans la section *Messagerie*.
- **Réception (IMAP)** : hôte, port (993 par défaut), chiffrement (SSL / TLS / aucun)
- **Envoi (SMTP)** : hôte, port (465 par défaut), chiffrement
- **Identifiants** : nom d'utilisateur + mot de passe (chiffré côté serveur, jamais réaffiché — un indicateur signale qu'un mot de passe est déjà enregistré), et chemin du dossier *Envoyés*
- **Toggle `enabled`** : active la messagerie
- **Test de connexion** intégré (vérifie l'accès IMAP et compte les dossiers)

View File

@@ -1,6 +1,6 @@
# Intégrations # Intégrations
Lesstime s'intègre avec **3 outils externes** pour fluidifier le workflow dev. Lesstime s'intègre avec **4 outils externes** pour fluidifier le workflow dev.
## 🌳 Gitea ## 🌳 Gitea
@@ -64,3 +64,29 @@ Sur une tâche avec **scheduled start + end** :
- **Pas de retour Zimbra → Lesstime** : si tu modifies l'événement dans Zimbra, Lesstime ne le voit pas - **Pas de retour Zimbra → Lesstime** : si tu modifies l'événement dans Zimbra, Lesstime ne le voit pas
- **Récurrences** : les patterns RRULE basiques sont supportés (daily, weekly avec jours, monthly) - **Récurrences** : les patterns RRULE basiques sont supportés (daily, weekly avec jours, monthly)
## 📧 Messagerie (Mail OVH)
Boîte mail partagée OVH (IMAP) lue directement dans Lesstime.
### Configuration
1. **Admin → Mail** :
- Réception **IMAP** (hôte, port, chiffrement) et envoi **SMTP** (hôte, port, chiffrement)
- Identifiants (mot de passe chiffré côté serveur) + dossier *Envoyés*
- **Test de connexion** intégré
2. Active la config (toggle `enabled`)
### Utilisation
- La section **Messagerie** (barre latérale) affiche dossiers, messages et lecteur
- **Synchronisation IMAP à la demande** via le bouton *Rafraîchir* (traitée en asynchrone par Messenger)
- Depuis un mail : **créer une tâche** pré-remplie ou **lier à une tâche** existante
- Badge de non-lus dans la barre latérale, rafraîchi automatiquement (toutes les 30 s)
> 📖 Le guide complet de la messagerie est dans la section *Messagerie*.
### Limites
- **Lecture seule** : pas de rédaction / réponse / suppression de mail depuis l'interface
- Réservée aux rôles **admin** et **user** (pas les clients)

View File

@@ -0,0 +1,40 @@
# Messagerie
Lesstime intègre une **boîte mail partagée** (OVH, protocole IMAP) directement dans l'application. Tu lis les mails de l'équipe et tu les transformes en tâches sans quitter Lesstime.
> 📥 La messagerie est accessible depuis l'entrée **Messagerie** de la barre latérale (icône enveloppe). Un **badge** y affiche le nombre de mails non lus, toutes boîtes confondues.
> 🛡️ Réservée aux rôles **ROLE_ADMIN** et **ROLE_USER**. Les utilisateurs *client* sont redirigés vers leur portail.
## L'interface
L'écran est organisé en **3 colonnes** :
1. **Dossiers** — l'arborescence de la boîte (INBOX, Envoyés, sous-dossiers…), avec le compteur de non-lus par dossier. INBOX est sélectionné par défaut.
2. **Messages** — la liste du dossier sélectionné (expéditeur, objet, date). Les mails non lus sont mis en avant. Un bouton **Charger plus** récupère les messages suivants (pagination).
3. **Lecteur** — le mail sélectionné : en-tête (expéditeur, destinataires, date), corps du message et **pièces jointes**.
## Lire un message
- Clique sur un message dans la liste : son détail s'affiche et il est **automatiquement marqué comme lu**.
- Tu peux le repasser **non lu** ou l'**étoiler** (flag) pour le retrouver plus tard.
- Les **pièces jointes** sont listées dans le lecteur : clique pour les télécharger, les images peuvent être prévisualisées.
## Synchronisation
- Le bouton **Rafraîchir** (en haut de l'écran) déclenche une **synchronisation IMAP à la demande** : Lesstime va chercher les nouveaux mails sur le serveur. Le traitement est asynchrone, la liste se met à jour quelques secondes après.
- En arrière-plan, le **compteur de non-lus** de la barre latérale se rafraîchit automatiquement (toutes les 30 s).
## Transformer un mail en action
Depuis le lecteur, deux boutons relient un mail au suivi de projet :
- **Créer une tâche** — ouvre une tâche pré-remplie à partir du mail (objet, contenu). Tu choisis le projet et les métadonnées, le mail reste lié à la tâche.
- **Lier à une tâche** — rattache le mail à une tâche **existante**.
> 💡 C'est le pont entre la boîte mail de l'équipe et le kanban : une demande reçue par mail devient une tâche traçable en deux clics.
## Limites
- **Lecture seule** : l'interface ne permet pas (encore) de **rédiger, répondre ou transférer** un mail, ni de supprimer un message.
- La configuration du serveur (IMAP/SMTP, identifiants) se fait dans **Admin → Mail** — voir la section *Administration*.

View File

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

View File

@@ -24,6 +24,7 @@ const META: Record<string, { title: string, icon: string, accent: string, roles:
'07-admin': { title: 'Administration', icon: 'mdi:shield-crown-outline', accent: 'from-rose-500 to-pink-600', roles: ['admin'] }, '07-admin': { title: 'Administration', icon: 'mdi:shield-crown-outline', accent: 'from-rose-500 to-pink-600', roles: ['admin'] },
'08-integrations': { title: 'Intégrations', icon: 'mdi:puzzle-outline', accent: 'from-blue-500 to-indigo-500', roles: ['admin', 'user'] }, '08-integrations': { title: 'Intégrations', icon: 'mdi:puzzle-outline', accent: 'from-blue-500 to-indigo-500', roles: ['admin', 'user'] },
'09-mcp-api': { title: 'Token MCP & API', icon: 'mdi:robot-outline', accent: 'from-slate-700 to-slate-900', roles: ['admin', 'user'] }, '09-mcp-api': { title: 'Token MCP & API', icon: 'mdi:robot-outline', accent: 'from-slate-700 to-slate-900', roles: ['admin', 'user'] },
'10-messaging': { title: 'Messagerie', icon: 'mdi:email-outline', accent: 'from-teal-500 to-cyan-600', roles: ['admin', 'user'] },
} }
const sections = computed<Section[]>(() => { const sections = computed<Section[]>(() => {

View File

@@ -8,7 +8,7 @@ import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data' import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project' import type { Project } from '~/services/dto/project'
import type { StatusCategory } from '~/services/dto/workflow' 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 { useTaskService } from '~/services/tasks'
import { useTaskStatusService } from '~/services/task-statuses' import { useTaskStatusService } from '~/services/task-statuses'
import { useTaskEffortService } from '~/services/task-efforts' import { useTaskEffortService } from '~/services/task-efforts'
@@ -71,6 +71,48 @@ const selectedTask = ref<Task | null>(null)
// Timer // Timer
const timerStore = useTimerStore() 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 { function isTimerOnTask(task: Task): boolean {
const entry = timerStore.activeEntry const entry = timerStore.activeEntry
if (!entry?.task) return false if (!entry?.task) return false
@@ -397,9 +439,16 @@ onMounted(async () => {
<div <div
v-for="cat in CATEGORIES" v-for="cat in CATEGORIES"
:key="cat" :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 }}) {{ STATUS_CATEGORY_LABEL[cat] }} ({{ tasksByCategory(cat).length }})
</div> </div>
<div class="min-h-0 flex-1 overflow-y-auto p-3"> <div class="min-h-0 flex-1 overflow-y-auto p-3">
@@ -481,6 +530,16 @@ onMounted(async () => {
</p> </p>
</div> </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 -->
<TaskModal <TaskModal
v-model="taskModalOpen" v-model="taskModalOpen"

View File

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

View File

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

View File

@@ -15,6 +15,13 @@ JWT_COOKIE_SAMESITE=lax
JWT_TOKEN_TTL=86400 JWT_TOKEN_TTL=86400
JWT_COOKIE_TTL=86400 JWT_COOKIE_TTL=86400
# Mail (intégration IMAP/SMTP)
# Clé hex 32 bytes chiffrant les credentials mail stockés en base.
# Générer : php -r "echo bin2hex(random_bytes(32));" — doit rester STABLE.
ENCRYPTION_KEY=change-me
# Store de verrous Symfony pour la sync mail (anti-chevauchement du cron).
LOCK_DSN=flock
# CORS # CORS
CORS_ALLOW_ORIGIN='^https?://project\.malio-dev\.fr$' CORS_ALLOW_ORIGIN='^https?://project\.malio-dev\.fr$'

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

View File

@@ -4,7 +4,12 @@ declare(strict_types=1);
namespace App\Tests\Functional\Controller\Mail; 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 App\Entity\User;
use DateTimeImmutable;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/** /**
@@ -69,4 +74,67 @@ class MailTaskIntegrationControllerTest extends WebTestCase
self::assertResponseStatusCodeSame(401); 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());
}
} }