Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
325a7b07f9 | ||
|
|
bcbc04325e | ||
|
|
8f2a688740 | ||
|
|
6491943930 | ||
|
|
a9f05fd819 | ||
|
|
925be5d181 | ||
|
|
5da165f739 | ||
|
|
2bffff9b83 | ||
| d7af8ee138 | |||
|
|
eb2adc9fdc | ||
|
|
4775cbf184 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
43
README.md
43
README.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.4.2'
|
app.version: '0.4.7'
|
||||||
|
|||||||
@@ -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).
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
> make mail-sync # synchro complète
|
> make mail-sync # synchro complète
|
||||||
> docker exec -i -u www-data php-lesstime-fpm php bin/console app:mail:sync --folder=INBOX -v
|
> docker exec -i -u www-data php-lesstime-fpm php bin/console app:mail:sync --folder=INBOX -v
|
||||||
> docker exec -i -u www-data php-lesstime-fpm php bin/console messenger:consume async -vv # worker (fait marcher le bouton)
|
> docker exec -i -u www-data php-lesstime-fpm php bin/console messenger:consume async -vv # worker (fait marcher le bouton)
|
||||||
|
> docker exec -i php-lesstime-fpm php bin/console app:mail:redecode-headers [--dry-run] # re-décode les en-têtes MIME déjà en base (backfill)
|
||||||
> make test # 33 tests
|
> make test # 33 tests
|
||||||
> ```
|
> ```
|
||||||
> Fixtures `make fixtures` plantent sur un état legacy `workflow_id` (hors-scope mail) — configurer la boîte via l'UI admin.
|
> Fixtures `make fixtures` plantent sur un état legacy `workflow_id` (hors-scope mail) — configurer la boîte via l'UI admin.
|
||||||
@@ -45,6 +46,8 @@
|
|||||||
- Navigation par dossiers (arbre récursif avec compteurs non-lus)
|
- Navigation par dossiers (arbre récursif avec compteurs non-lus)
|
||||||
- Liste paginée des messages (infinite scroll, cursor-based)
|
- Liste paginée des messages (infinite scroll, cursor-based)
|
||||||
- Lecture des corps de mail sanitisés (DOMPurify — protection XSS + pixels tracking)
|
- Lecture des corps de mail sanitisés (DOMPurify — protection XSS + pixels tracking)
|
||||||
|
- Décodage des en-têtes MIME encodés (RFC 2047, ex `=?UTF-8?Q?...`) sur sujet + nom d'expéditeur (`App\Mail\MimeHeaderDecoder`, appliqué dans `ImapMailProvider`)
|
||||||
|
- Aperçu inline des pièces jointes images + PDF (visionneuse modale plein écran), téléchargement pour les autres types
|
||||||
- Création d'une tâche Lesstime depuis un mail (sujet → titre, texte → description)
|
- Création d'une tâche Lesstime depuis un mail (sujet → titre, texte → description)
|
||||||
- Lien mail ↔ tâche (bidirectionnel)
|
- Lien mail ↔ tâche (bidirectionnel)
|
||||||
- Onglet "Mails" dans le TaskDrawer pour retrouver les mails liés à une tâche
|
- Onglet "Mails" dans le TaskDrawer pour retrouver les mails liés à une tâche
|
||||||
@@ -107,7 +110,7 @@ Tous les endpoints `/api/mail/*` refusent explicitement `ROLE_CLIENT`.
|
|||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
- `frontend/pages/mail.vue` — page principale 3 colonnes
|
- `frontend/pages/mail.vue` — page principale 3 colonnes
|
||||||
- `frontend/components/mail/` — MailFolderTree, MailMessageList, MailMessageViewer, MailRefreshButton
|
- `frontend/components/mail/` — MailFolderTree, MailMessageList, MailMessageViewer, MailRefreshButton, MailAttachmentPreview (visionneuse modale image/PDF)
|
||||||
- `frontend/components/admin/AdminMailTab.vue` — onglet config admin
|
- `frontend/components/admin/AdminMailTab.vue` — onglet config admin
|
||||||
- `frontend/stores/mail.ts` — store Pinia (folders, messages, polling)
|
- `frontend/stores/mail.ts` — store Pinia (folders, messages, polling)
|
||||||
- `frontend/services/mail.ts` — service API (toutes les méthodes)
|
- `frontend/services/mail.ts` — service API (toutes les méthodes)
|
||||||
|
|||||||
988
docs/superpowers/plans/2026-05-21-workflow-ui-fixes.md
Normal file
988
docs/superpowers/plans/2026-05-21-workflow-ui-fixes.md
Normal 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`.
|
||||||
239
docs/superpowers/specs/2026-05-20-workflow-ui-fixes-design.md
Normal file
239
docs/superpowers/specs/2026-05-20-workflow-ui-fixes-design.md
Normal 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.
|
||||||
@@ -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',
|
||||||
|
|||||||
121
frontend/components/mail/MailAttachmentPreview.vue
Normal file
121
frontend/components/mail/MailAttachmentPreview.vue
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
/** Ouverture de la visionneuse. */
|
||||||
|
modelValue: boolean
|
||||||
|
/** Nom du fichier affiché dans la barre. */
|
||||||
|
filename: string
|
||||||
|
/** Type MIME — détermine le rendu (image vs PDF). */
|
||||||
|
mimeType: string
|
||||||
|
/** Object URL du Blob de la pièce jointe. null tant que le contenu charge. */
|
||||||
|
url: string | null
|
||||||
|
/** Téléchargement en cours du contenu. */
|
||||||
|
loading: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
download: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const isImage = computed(() => props.mimeType.startsWith('image/'))
|
||||||
|
const isPdf = computed(() => props.mimeType === 'application/pdf')
|
||||||
|
|
||||||
|
function close(): void {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent): void {
|
||||||
|
if (e.key === 'Escape') close()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(open) => {
|
||||||
|
if (open) {
|
||||||
|
window.addEventListener('keydown', onKeydown)
|
||||||
|
} else {
|
||||||
|
window.removeEventListener('keydown', onKeydown)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport v-if="modelValue" to="body">
|
||||||
|
<Transition name="mail-preview" appear>
|
||||||
|
<div class="fixed inset-0 z-50 flex flex-col bg-slate-900/80 backdrop-blur-sm">
|
||||||
|
<!-- Barre supérieure -->
|
||||||
|
<div class="flex flex-shrink-0 items-center justify-between gap-4 px-4 py-3 text-white">
|
||||||
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
|
<Icon
|
||||||
|
:name="isImage ? 'material-symbols:image-outline' : 'material-symbols:picture-as-pdf-outline'"
|
||||||
|
size="18"
|
||||||
|
class="flex-shrink-0 text-white/70"
|
||||||
|
/>
|
||||||
|
<span class="truncate text-sm font-medium">{{ filename }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-shrink-0 items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-white/90 transition-colors hover:bg-white/10"
|
||||||
|
@click="emit('download')"
|
||||||
|
>
|
||||||
|
<Icon name="material-symbols:download" size="18" />
|
||||||
|
<span class="hidden sm:inline">{{ t('mail.actions.download') }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md p-1.5 text-white/90 transition-colors hover:bg-white/10"
|
||||||
|
:aria-label="t('mail.preview.close')"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:close" size="20" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contenu -->
|
||||||
|
<div class="flex min-h-0 flex-1 items-center justify-center overflow-auto p-4" @click.self="close">
|
||||||
|
<div v-if="loading" class="flex flex-col items-center gap-3 text-white/70">
|
||||||
|
<Icon name="material-symbols:progress-activity" size="32" class="animate-spin" />
|
||||||
|
<span class="text-sm">{{ t('mail.preview.loading') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<img
|
||||||
|
v-else-if="isImage && url"
|
||||||
|
:src="url"
|
||||||
|
:alt="filename"
|
||||||
|
class="max-h-full max-w-full rounded-lg object-contain shadow-2xl"
|
||||||
|
>
|
||||||
|
|
||||||
|
<iframe
|
||||||
|
v-else-if="isPdf && url"
|
||||||
|
:src="url"
|
||||||
|
:title="filename"
|
||||||
|
class="h-full w-full max-w-5xl rounded-lg bg-white shadow-2xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-else class="flex flex-col items-center gap-3 text-white/70">
|
||||||
|
<Icon name="material-symbols:visibility-off-outline" size="32" />
|
||||||
|
<span class="text-sm">{{ t('mail.preview.unavailable') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mail-preview-enter-active,
|
||||||
|
.mail-preview-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.mail-preview-enter-from,
|
||||||
|
.mail-preview-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Modal -->
|
|
||||||
<div
|
|
||||||
class="relative z-10 w-full max-w-lg rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 overflow-hidden"
|
|
||||||
style="max-height: min(90vh, 640px)"
|
|
||||||
>
|
>
|
||||||
<!-- Header -->
|
<div class="space-y-5">
|
||||||
<div class="flex items-center justify-between border-b border-neutral-100 bg-neutral-50/80 px-6 py-4">
|
<div v-if="messageDetail" class="rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-3 text-sm">
|
||||||
<h2 class="text-base font-bold text-neutral-900">
|
<p class="truncate font-medium text-neutral-800">{{ messageDetail.header.subject ?? t('mail.noSubject') }}</p>
|
||||||
{{ t('mail.createTaskModal.title') }}
|
<p class="mt-0.5 truncate text-xs text-neutral-500">{{ messageDetail.header.fromName ?? messageDetail.header.fromEmail }}</p>
|
||||||
</h2>
|
<p class="mt-2 text-xs italic text-neutral-400">{{ t('mail.createTaskModal.titleHint') }}</p>
|
||||||
<MalioButtonIcon
|
<p class="text-xs italic text-neutral-400">{{ t('mail.createTaskModal.descriptionHint') }}</p>
|
||||||
icon="mdi:close"
|
|
||||||
aria-label="Fermer"
|
|
||||||
variant="ghost"
|
|
||||||
icon-size="20"
|
|
||||||
@click="close"
|
|
||||||
/>
|
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<MalioSelect
|
<MalioSelect v-model="projectId" :options="projectOptions" :label="t('mail.createTaskModal.projectLabel')" :empty-option-label="t('mail.createTaskModal.projectPlaceholder')" min-width="w-full" />
|
||||||
v-model="projectId"
|
<p v-if="touchedProject && !projectId" class="mt-1 text-xs text-red-500">{{ t('mail.createTaskModal.projectLabel').replace(' *', '') }} requis</p>
|
||||||
: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>
|
||||||
|
|
||||||
<!-- Sélection groupe (optionnel, chargé après projet) -->
|
|
||||||
<div v-if="projectId">
|
<div v-if="projectId">
|
||||||
<MalioSelect
|
<MalioSelect v-model="taskGroupId" :options="groupOptions" :label="t('mail.createTaskModal.groupLabel')" :empty-option-label="t('mail.createTaskModal.groupPlaceholder')" min-width="w-full" :disabled="loadingGroups" />
|
||||||
v-model="taskGroupId"
|
</div>
|
||||||
:options="groupOptions"
|
|
||||||
:label="t('mail.createTaskModal.groupLabel')"
|
<div v-if="projectId">
|
||||||
:empty-option-label="t('mail.createTaskModal.groupPlaceholder')"
|
<MalioSelect v-model="statusId" :options="statusOptions" :label="t('mail.createTaskModal.statusLabel')" min-width="w-full" />
|
||||||
min-width="w-full"
|
|
||||||
:disabled="loadingGroups"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sélection priorité (optionnelle) — MalioSelect car les values sont number | null -->
|
|
||||||
<div>
|
<div>
|
||||||
<MalioSelect
|
<MalioSelect v-model="assigneeId" :options="userOptions" :label="t('mail.createTaskModal.assigneeLabel')" :empty-option-label="t('mail.createTaskModal.assigneePlaceholder')" min-width="w-full" />
|
||||||
v-model="priorityId"
|
|
||||||
:options="priorityOptions"
|
|
||||||
:label="t('mail.createTaskModal.priorityLabel')"
|
|
||||||
:empty-option-label="t('mail.createTaskModal.priorityPlaceholder')"
|
|
||||||
min-width="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<template #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
|
<MalioButton :label="t('mail.createTaskModal.submit')" button-class="w-auto px-6" :disabled="isSubmitting" @click="handleSubmit" />
|
||||||
variant="tertiary"
|
</template>
|
||||||
label="Annuler"
|
</AppModal>
|
||||||
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>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</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>
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { MailMessageDetailDto, MailAddressDto } from '~/services/dto/mail'
|
import type { MailMessageDetailDto, MailAddressDto, MailAttachmentDto } from '~/services/dto/mail'
|
||||||
import { sanitizeMailHtml } from '~/utils/sanitizeMailHtml'
|
import { sanitizeMailHtml } from '~/utils/sanitizeMailHtml'
|
||||||
import { useMailService } from '~/services/mail'
|
import { useMailService } from '~/services/mail'
|
||||||
|
|
||||||
@@ -24,22 +24,101 @@ const sanitizedBody = computed((): string => {
|
|||||||
return sanitizeMailHtml(props.detail.bodyHtml, { allowImages: showImages.value })
|
return sanitizeMailHtml(props.detail.bodyHtml, { allowImages: showImages.value })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ─── Pièces jointes : aperçu / téléchargement ──────────────────────────────
|
||||||
|
|
||||||
|
function isImage(mime: string): boolean {
|
||||||
|
return mime.startsWith('image/')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPdf(mime: string): boolean {
|
||||||
|
return mime === 'application/pdf'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPreviewable(mime: string): boolean {
|
||||||
|
return isImage(mime) || isPdf(mime)
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachmentIcon(mime: string): string {
|
||||||
|
if (isImage(mime)) return 'material-symbols:image-outline'
|
||||||
|
if (isPdf(mime)) return 'material-symbols:picture-as-pdf-outline'
|
||||||
|
return 'material-symbols:attach-file'
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewOpen = ref(false)
|
||||||
|
const previewLoading = ref(false)
|
||||||
|
const previewAtt = ref<MailAttachmentDto | null>(null)
|
||||||
|
const previewUrl = ref<string | null>(null)
|
||||||
|
let previewBlob: Blob | null = null
|
||||||
|
|
||||||
|
function revokePreview(): void {
|
||||||
|
if (previewUrl.value) {
|
||||||
|
URL.revokeObjectURL(previewUrl.value)
|
||||||
|
previewUrl.value = null
|
||||||
|
}
|
||||||
|
previewBlob = null
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.detail?.header.id,
|
() => props.detail?.header.id,
|
||||||
() => {
|
() => {
|
||||||
showImages.value = false
|
showImages.value = false
|
||||||
|
previewOpen.value = false
|
||||||
|
revokePreview()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
async function handleDownload(downloadId: string, filename: string): Promise<void> {
|
watch(previewOpen, (open) => {
|
||||||
|
if (!open) revokePreview()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(revokePreview)
|
||||||
|
|
||||||
|
async function handleAttachmentClick(att: MailAttachmentDto): Promise<void> {
|
||||||
|
if (!isPreviewable(att.mimeType)) {
|
||||||
|
await handleDownload(att.downloadId, att.filename)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
previewAtt.value = att
|
||||||
|
previewUrl.value = null
|
||||||
|
previewLoading.value = true
|
||||||
|
previewOpen.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data } = await mailService.downloadAttachment(downloadId)
|
const { data } = await mailService.downloadAttachment(att.downloadId)
|
||||||
const url = URL.createObjectURL(data)
|
previewBlob = data
|
||||||
|
previewUrl.value = URL.createObjectURL(data)
|
||||||
|
} catch {
|
||||||
|
// useApi affiche déjà le toast — on referme la visionneuse.
|
||||||
|
previewOpen.value = false
|
||||||
|
} finally {
|
||||||
|
previewLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadFromPreview(): void {
|
||||||
|
const att = previewAtt.value
|
||||||
|
if (!att) return
|
||||||
|
if (previewBlob) {
|
||||||
|
triggerBlobDownload(previewBlob, att.filename)
|
||||||
|
} else {
|
||||||
|
void handleDownload(att.downloadId, att.filename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerBlobDownload(blob: Blob, filename: string): void {
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = url
|
a.href = url
|
||||||
a.download = filename
|
a.download = filename
|
||||||
a.click()
|
a.click()
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownload(downloadId: string, filename: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { data } = await mailService.downloadAttachment(downloadId)
|
||||||
|
triggerBlobDownload(data, filename)
|
||||||
} catch {
|
} catch {
|
||||||
// L'erreur est gérée par useApi (toast automatique)
|
// L'erreur est gérée par useApi (toast automatique)
|
||||||
}
|
}
|
||||||
@@ -169,15 +248,31 @@ function joinAddresses(addresses: MailAddressDto[]): string {
|
|||||||
:key="att.downloadId"
|
:key="att.downloadId"
|
||||||
type="button"
|
type="button"
|
||||||
class="flex items-center gap-1.5 rounded border border-neutral-200 bg-neutral-50 px-2.5 py-1.5 text-xs text-neutral-700 transition-colors hover:bg-neutral-100 hover:border-neutral-300"
|
class="flex items-center gap-1.5 rounded border border-neutral-200 bg-neutral-50 px-2.5 py-1.5 text-xs text-neutral-700 transition-colors hover:bg-neutral-100 hover:border-neutral-300"
|
||||||
:title="att.filename"
|
:title="isPreviewable(att.mimeType) ? t('mail.preview.open') : t('mail.actions.download')"
|
||||||
@click="handleDownload(att.downloadId, att.filename)"
|
@click="handleAttachmentClick(att)"
|
||||||
>
|
>
|
||||||
<Icon name="material-symbols:attach-file" size="14" class="flex-shrink-0 text-neutral-400" />
|
<Icon :name="attachmentIcon(att.mimeType)" size="14" class="flex-shrink-0 text-neutral-400" />
|
||||||
<span class="max-w-[180px] truncate">{{ att.filename }}</span>
|
<span class="max-w-[180px] truncate">{{ att.filename }}</span>
|
||||||
<span class="text-neutral-400">({{ Math.round(att.size / 1024) }} Ko)</span>
|
<span class="text-neutral-400">({{ Math.round(att.size / 1024) }} Ko)</span>
|
||||||
|
<Icon
|
||||||
|
v-if="isPreviewable(att.mimeType)"
|
||||||
|
name="material-symbols:visibility-outline"
|
||||||
|
size="13"
|
||||||
|
class="flex-shrink-0 text-neutral-400"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MailAttachmentPreview
|
||||||
|
v-if="previewAtt"
|
||||||
|
v-model="previewOpen"
|
||||||
|
:filename="previewAtt.filename"
|
||||||
|
:mime-type="previewAtt.mimeType"
|
||||||
|
:url="previewUrl"
|
||||||
|
:loading="previewLoading"
|
||||||
|
@download="downloadFromPreview"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
35
frontend/components/task/StatusPickerPopover.vue
Normal file
35
frontend/components/task/StatusPickerPopover.vue
Normal 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>
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,31 +481,13 @@
|
|||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Modal picker mail -->
|
</form>
|
||||||
<MailPickerModal
|
|
||||||
v-if="task"
|
|
||||||
v-model="showMailPickerModal"
|
|
||||||
:task-id="task.id"
|
|
||||||
@linked="handleMailLinked"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div
|
<div
|
||||||
class="mt-6 flex items-center border-t border-neutral-100 pt-5"
|
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'"
|
:class="isEditing ? 'justify-between' : 'justify-end'"
|
||||||
>
|
>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
@@ -547,7 +529,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
|
|
||||||
<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', {
|
||||||
|
|||||||
87
frontend/components/ui/AppModal.vue
Normal file
87
frontend/components/ui/AppModal.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
40
frontend/content/help/10-messaging.md
Normal file
40
frontend/content/help/10-messaging.md
Normal 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*.
|
||||||
@@ -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",
|
||||||
@@ -527,6 +528,12 @@
|
|||||||
"list": "Aucun message dans ce dossier.",
|
"list": "Aucun message dans ce dossier.",
|
||||||
"viewer": "Sélectionnez un message pour le lire."
|
"viewer": "Sélectionnez un message pour le lire."
|
||||||
},
|
},
|
||||||
|
"preview": {
|
||||||
|
"open": "Prévisualiser",
|
||||||
|
"close": "Fermer l'aperçu",
|
||||||
|
"loading": "Chargement de l'aperçu…",
|
||||||
|
"unavailable": "Aperçu indisponible pour ce type de fichier."
|
||||||
|
},
|
||||||
"folderTree": {
|
"folderTree": {
|
||||||
"expand": "Déplier le dossier",
|
"expand": "Déplier le dossier",
|
||||||
"collapse": "Replier le dossier"
|
"collapse": "Replier le dossier"
|
||||||
@@ -570,8 +577,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 +592,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 ?"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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[]>(() => {
|
||||||
|
|||||||
@@ -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 px-4 py-3 text-sm font-bold"
|
||||||
|
:style="{ backgroundColor: STATUS_CATEGORY_COLOR[cat], color: contrastText(STATUS_CATEGORY_COLOR[cat]) }"
|
||||||
>
|
>
|
||||||
<div class="shrink-0 rounded-t-lg bg-neutral-200 px-4 py-3 text-sm font-bold text-neutral-800">
|
|
||||||
{{ 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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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$'
|
||||||
|
|
||||||
|
|||||||
37
migrations/Version20260521094948.php
Normal file
37
migrations/Version20260521094948.php
Normal 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
84
src/Command/MailRedecodeHeadersCommand.php
Normal file
84
src/Command/MailRedecodeHeadersCommand.php
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Mail\MimeHeaderDecoder;
|
||||||
|
use App\Repository\MailMessageRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:mail:redecode-headers',
|
||||||
|
description: 'Re-décode les sujets et noms d\'expéditeur encodés en MIME (RFC 2047) déjà stockés en base',
|
||||||
|
)]
|
||||||
|
final class MailRedecodeHeadersCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly MailMessageRepository $messageRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addOption(
|
||||||
|
'dry-run',
|
||||||
|
null,
|
||||||
|
InputOption::VALUE_NONE,
|
||||||
|
'Affiche les changements sans écrire en base',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$isDryRun = (bool) $input->getOption('dry-run');
|
||||||
|
|
||||||
|
$messages = $this->messageRepository->findAll();
|
||||||
|
$io->text(sprintf('%d message(s) à examiner...', count($messages)));
|
||||||
|
|
||||||
|
$changed = 0;
|
||||||
|
|
||||||
|
foreach ($messages as $message) {
|
||||||
|
$newSubject = MimeHeaderDecoder::decode($message->getSubject());
|
||||||
|
$newFromName = MimeHeaderDecoder::decode($message->getFromName());
|
||||||
|
|
||||||
|
$hasChange = $newSubject !== $message->getSubject() || $newFromName !== $message->getFromName();
|
||||||
|
|
||||||
|
if (!$hasChange) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($io->isVerbose()) {
|
||||||
|
$io->text(sprintf(' - #%d : "%s" → "%s"', $message->getId(), (string) $message->getSubject(), (string) $newSubject));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$isDryRun) {
|
||||||
|
$message->setSubject($newSubject);
|
||||||
|
$message->setFromName($newFromName);
|
||||||
|
}
|
||||||
|
|
||||||
|
++$changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$isDryRun) {
|
||||||
|
$this->entityManager->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->success(sprintf(
|
||||||
|
'%s%d en-tête(s) re-décodé(s).',
|
||||||
|
$isDryRun ? '[dry-run] ' : '',
|
||||||
|
$changed,
|
||||||
|
));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -351,7 +351,9 @@ final class ImapMailProvider implements MailProviderInterface
|
|||||||
{
|
{
|
||||||
$from = $message->getFrom()->first();
|
$from = $message->getFrom()->first();
|
||||||
$fromAddress = null !== $from ? (string) $from->mail : '';
|
$fromAddress = null !== $from ? (string) $from->mail : '';
|
||||||
$fromName = null !== $from && null !== $from->personal ? (string) $from->personal : null;
|
$fromName = null !== $from && null !== $from->personal
|
||||||
|
? MimeHeaderDecoder::decode((string) $from->personal)
|
||||||
|
: null;
|
||||||
|
|
||||||
$toAddresses = [];
|
$toAddresses = [];
|
||||||
foreach ($message->getTo() as $addr) {
|
foreach ($message->getTo() as $addr) {
|
||||||
@@ -388,7 +390,7 @@ final class ImapMailProvider implements MailProviderInterface
|
|||||||
return new MailMessageHeaderDto(
|
return new MailMessageHeaderDto(
|
||||||
uid: (int) $message->getUid(),
|
uid: (int) $message->getUid(),
|
||||||
messageId: (string) $message->getMessageId(),
|
messageId: (string) $message->getMessageId(),
|
||||||
subject: '' !== (string) $message->getSubject() ? (string) $message->getSubject() : null,
|
subject: '' !== (string) $message->getSubject() ? MimeHeaderDecoder::decode((string) $message->getSubject()) : null,
|
||||||
fromAddress: $fromAddress,
|
fromAddress: $fromAddress,
|
||||||
fromName: $fromName,
|
fromName: $fromName,
|
||||||
toAddresses: $toAddresses,
|
toAddresses: $toAddresses,
|
||||||
|
|||||||
47
src/Mail/MimeHeaderDecoder.php
Normal file
47
src/Mail/MimeHeaderDecoder.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use const ICONV_MIME_DECODE_CONTINUE_ON_ERROR;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Décode les en-têtes mail encodés en « encoded-words » MIME (RFC 2047),
|
||||||
|
* ex: "=?UTF-8?Q?Fwd=3A_Votre_inscription?=" → "Fwd: Votre inscription".
|
||||||
|
*
|
||||||
|
* Certains serveurs IMAP (OVH) renvoient les sujets / noms d'expéditeur
|
||||||
|
* encodés bruts ; webklex ne les décode pas systématiquement. Cet utilitaire
|
||||||
|
* normalise la sortie en UTF-8 lisible. Idempotent : un texte déjà décodé
|
||||||
|
* (sans séquence "=?") est retourné inchangé.
|
||||||
|
*/
|
||||||
|
final class MimeHeaderDecoder
|
||||||
|
{
|
||||||
|
public static function decode(?string $value): ?string
|
||||||
|
{
|
||||||
|
if (null === $value || '' === $value) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pas d'encoded-word → rien à faire (chemin rapide + idempotence).
|
||||||
|
if (!str_contains($value, '=?')) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = @iconv_mime_decode($value, ICONV_MIME_DECODE_CONTINUE_ON_ERROR, 'UTF-8');
|
||||||
|
|
||||||
|
if (false === $decoded || '' === trim($decoded)) {
|
||||||
|
// Fallback : mb_decode_mimeheader gère certains cas refusés par iconv.
|
||||||
|
$previous = mb_internal_encoding();
|
||||||
|
mb_internal_encoding('UTF-8');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$decoded = mb_decode_mimeheader($value);
|
||||||
|
} finally {
|
||||||
|
mb_internal_encoding($previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false === $decoded || '' === $decoded ? $value : $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
50
tests/Unit/Mail/MimeHeaderDecoderTest.php
Normal file
50
tests/Unit/Mail/MimeHeaderDecoderTest.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Mail;
|
||||||
|
|
||||||
|
use App\Mail\MimeHeaderDecoder;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
class MimeHeaderDecoderTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testDecodesQEncodedSubject(): void
|
||||||
|
{
|
||||||
|
self::assertSame(
|
||||||
|
'Fwd: Votre inscription',
|
||||||
|
MimeHeaderDecoder::decode('=?UTF-8?Q?Fwd=3A_Votre_inscription?='),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDecodesBEncodedSubjectWithAccents(): void
|
||||||
|
{
|
||||||
|
// "Réunion été" encodé en Base64 UTF-8
|
||||||
|
self::assertSame(
|
||||||
|
'Réunion été',
|
||||||
|
MimeHeaderDecoder::decode('=?UTF-8?B?UsOpdW5pb24gw6l0w6k=?='),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsIdempotentOnPlainText(): void
|
||||||
|
{
|
||||||
|
self::assertSame('Christian ROY', MimeHeaderDecoder::decode('Christian ROY'));
|
||||||
|
self::assertSame('TR: Bail commercial', MimeHeaderDecoder::decode('TR: Bail commercial'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPreservesNullAndEmpty(): void
|
||||||
|
{
|
||||||
|
self::assertNull(MimeHeaderDecoder::decode(null));
|
||||||
|
self::assertSame('', MimeHeaderDecoder::decode(''));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFallsBackToOriginalWhenUndecodable(): void
|
||||||
|
{
|
||||||
|
// Charset inconnu : on ne perd pas la valeur d'origine.
|
||||||
|
$value = '=?unknown-charset?Q?test?=';
|
||||||
|
self::assertNotSame('', MimeHeaderDecoder::decode($value));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user