Compare commits
163 Commits
b50cfb5049
..
v0.4.5
| Author | SHA1 | Date | |
|---|---|---|---|
| a9f05fd819 | |||
| 925be5d181 | |||
| 5da165f739 | |||
| 2bffff9b83 | |||
| d7af8ee138 | |||
| eb2adc9fdc | |||
| 4775cbf184 | |||
| 8be96bce0c | |||
| fb97b8d4e3 | |||
| 913d3b7d93 | |||
| 90bf46f598 | |||
| e5c5371c74 | |||
| 40a1d737f3 | |||
| 595b8f5be3 | |||
| a40d11503a | |||
| 760649170e | |||
| b0f05da84a | |||
| c75dfa0371 | |||
| c6fa5a534e | |||
| 17b5fa2340 | |||
| 79d3414824 | |||
| f313e74c9e | |||
| 7a682b4662 | |||
| d6f430ca35 | |||
| 4d7ff9be26 | |||
| 7c0d3372a9 | |||
| d36429f058 | |||
| 28b673eec8 | |||
| bad292a316 | |||
| 273234626f | |||
| 96c7d902e7 | |||
| f62c790449 | |||
| 13cec9a46a | |||
| d676fdcb0c | |||
| bfcf712123 | |||
| 622fcf72c1 | |||
| 67e73a52d7 | |||
| aa175063dc | |||
| 9aa14d38a9 | |||
| 95a98012ad | |||
| 535753b189 | |||
| e710f57c49 | |||
| 73f0adc761 | |||
| 2e0f5b4e30 | |||
| 33e4e79f8e | |||
| bfa155d060 | |||
| e7224765b1 | |||
| fe07398059 | |||
| a440ce267f | |||
| 8986f3cb0e | |||
| 6d420c86e8 | |||
| cc46dd915d | |||
| f7f7a07162 | |||
| 117175d4b1 | |||
| c7d12f6acd | |||
| f584ed96fa | |||
| 5ce7693343 | |||
| 7fb525595e | |||
| b1d6303afe | |||
| 1c3ba9c33c | |||
| 412c412cbc | |||
| 62e0bf5f11 | |||
| 696b40ca80 | |||
| cbbc491d69 | |||
| 26fab44dab | |||
| 0028b489e4 | |||
| 1fb7460f8e | |||
| c47434b502 | |||
| f245863b78 | |||
| b546f528df | |||
| b5b4288cc0 | |||
| 3a2d8d5bde | |||
| 23191bdab6 | |||
| 5f92cbbf4f | |||
| f80680e874 | |||
| 697197864f | |||
| 0da26ff418 | |||
| cd9c16a990 | |||
| 0c597bc653 | |||
| 0c80159d7e | |||
| 3cac87aa24 | |||
| 07b7d054d5 | |||
| 361cc8cfab | |||
| 930e1a1e37 | |||
| 55301c9c63 | |||
| 5fb7fbe66c | |||
| c1560468e6 | |||
| f86698e7cd | |||
| 1fd2c05db3 | |||
| 9f179e400d | |||
| 6a37349cf7 | |||
| 52b78d6bbc | |||
| e6d765f7bb | |||
| 5d42009348 | |||
| 8e4ddf00a8 | |||
| 18bc96082f | |||
| 6a084489ea | |||
| 80a41db34f | |||
| cf94635121 | |||
| eec61c089c | |||
| a9f87be8e5 | |||
| 25f2fc4b16 | |||
| a21914312a | |||
| f6a947ec15 | |||
| 03f3c85fd8 | |||
| 8a68e0d397 | |||
| 43e6d1aed2 | |||
| a3e3fd6da6 | |||
| b8b03048b6 | |||
| ba86a71e12 | |||
| 6a942def3f | |||
| d4fdb84a17 | |||
| 5585fa7ef6 | |||
| b301ebbad0 | |||
| feaa9f1875 | |||
| b25be8fd6a | |||
| 3e6b0e877a | |||
| 9f3fc05a52 | |||
| 4c3721b6ac | |||
| 06d733f88e | |||
| 258c6e9c17 | |||
| feffe63019 | |||
| 34ba554fba | |||
| b2cc6e96e1 | |||
| 2a68d2f9c6 | |||
| 2898b22440 | |||
| f1fd80d9ac | |||
| 24e3e8e989 | |||
| 47f2ab9cd4 | |||
| 36729f8f61 | |||
| 30b090852d | |||
| f0c9568521 | |||
| 7c37eb58cb | |||
| 7a5b8dabff | |||
| fef563be06 | |||
| e14c707dfd | |||
| fa7bb27ef5 | |||
| 21e9d2cab4 | |||
| 00ffcb1cf2 | |||
| daba09472f | |||
| f3208a481f | |||
| a46542fcdd | |||
| 1ae2d9ac2c | |||
| e41caa9cfe | |||
| 916f4ae101 | |||
| 45d389c67f | |||
| 7f12332cf6 | |||
| fe30f03b9f | |||
| fc472d5dad | |||
| a0a2f27eac | |||
| bd7adec2f0 | |||
| 9b6386c4ae | |||
| 9da1ae7ca1 | |||
| bc8bed3339 | |||
| 3fee678bd2 | |||
| be720178c2 | |||
| eec0294f3e | |||
| 59a1c7956c | |||
| e86949a1d7 | |||
| 7ca62bfc46 | |||
| b60e4ae670 | |||
| ace52f8fc5 | |||
| 1ae9535516 |
+5
-4
@@ -2,10 +2,11 @@
|
|||||||
.gitea
|
.gitea
|
||||||
.env.local
|
.env.local
|
||||||
.env.test
|
.env.test
|
||||||
docker/
|
infra/dev/
|
||||||
deploy/docker/docker-compose.prod.yml
|
infra/prod/docker-compose.yml
|
||||||
deploy/docker/deploy.sh
|
infra/prod/deploy.sh
|
||||||
deploy/docker/.env.example
|
infra/prod/deploy-release.sh
|
||||||
|
infra/prod/.env.example
|
||||||
frontend/node_modules
|
frontend/node_modules
|
||||||
frontend/.nuxt
|
frontend/.nuxt
|
||||||
frontend/.output
|
frontend/.output
|
||||||
|
|||||||
@@ -21,3 +21,15 @@ JWT_COOKIE_TTL=86400
|
|||||||
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
|
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
|
||||||
|
|
||||||
ENCRYPTION_KEY=change_me_in_env_local
|
ENCRYPTION_KEY=change_me_in_env_local
|
||||||
|
###> symfony/lock ###
|
||||||
|
# Choose one of the stores below
|
||||||
|
# postgresql+advisory://db_user:db_password@localhost/db_name
|
||||||
|
LOCK_DSN=flock
|
||||||
|
###< symfony/lock ###
|
||||||
|
|
||||||
|
###> symfony/messenger ###
|
||||||
|
# Choose one of the transports below
|
||||||
|
# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
|
||||||
|
# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
|
||||||
|
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
|
||||||
|
###< symfony/messenger ###
|
||||||
|
|||||||
+4
-4
@@ -60,7 +60,7 @@ JWT_COOKIE_TTL=86400
|
|||||||
# Base de donnees (Doctrine / PostgreSQL)
|
# Base de donnees (Doctrine / PostgreSQL)
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
||||||
# Les variables POSTGRES_* sont definies dans docker/.env.docker
|
# Les variables POSTGRES_* sont definies dans infra/dev/.env.docker
|
||||||
# et injectees automatiquement par Docker Compose.
|
# et injectees automatiquement par Docker Compose.
|
||||||
# DATABASE_URL est construite a partir de ces variables.
|
# DATABASE_URL est construite a partir de ces variables.
|
||||||
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
|
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
|
||||||
@@ -74,10 +74,10 @@ DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_P
|
|||||||
ENCRYPTION_KEY=change_me_in_env_local
|
ENCRYPTION_KEY=change_me_in_env_local
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# Docker (docker/.env.docker)
|
# Docker (infra/dev/.env.docker)
|
||||||
#
|
#
|
||||||
# Ces variables sont lues par Docker Compose. Voir docker/.env.docker
|
# Ces variables sont lues par Docker Compose. Voir infra/dev/.env.docker
|
||||||
# pour les valeurs par defaut. Creez docker/.env.docker.local pour
|
# pour les valeurs par defaut. Creez infra/dev/.env.docker.local pour
|
||||||
# surcharger localement.
|
# surcharger localement.
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
- name: Build Docker image
|
- name: Build Docker image
|
||||||
run: |
|
run: |
|
||||||
docker build \
|
docker build \
|
||||||
-f deploy/docker/Dockerfile.prod \
|
-f infra/prod/Dockerfile \
|
||||||
-t gitea.malio.fr/malio-dev/lesstime:${{ gitea.ref_name }} \
|
-t gitea.malio.fr/malio-dev/lesstime:${{ gitea.ref_name }} \
|
||||||
-t gitea.malio.fr/malio-dev/lesstime:latest \
|
-t gitea.malio.fr/malio-dev/lesstime:latest \
|
||||||
.
|
.
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
name: Build Release Artefact
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v*"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Setup PHP
|
|
||||||
uses: shivammathur/setup-php@v2
|
|
||||||
with:
|
|
||||||
php-version: "8.4"
|
|
||||||
extensions: mbstring, intl, pdo_pgsql, xml, curl, zip, gd
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "lts/*"
|
|
||||||
|
|
||||||
- name: Install backend deps (prod)
|
|
||||||
env:
|
|
||||||
APP_ENV: prod
|
|
||||||
APP_DEBUG: "0"
|
|
||||||
run: composer install --no-dev --optimize-autoloader --no-interaction --no-scripts
|
|
||||||
|
|
||||||
- name: Build frontend (static)
|
|
||||||
run: |
|
|
||||||
cd frontend
|
|
||||||
npm ci
|
|
||||||
CI=1 NUXT_TELEMETRY_DISABLED=1 NUXT_PUBLIC_API_BASE=/api NUXT_PUBLIC_APP_BASE=/ npm run generate
|
|
||||||
test -f .output/public/index.html
|
|
||||||
|
|
||||||
- name: Build artefact
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
mkdir -p release
|
|
||||||
tar -czf "release/lesstime-${GITHUB_REF_NAME}.tar.gz" \
|
|
||||||
.env \
|
|
||||||
bin \
|
|
||||||
config \
|
|
||||||
migrations \
|
|
||||||
public \
|
|
||||||
src \
|
|
||||||
vendor \
|
|
||||||
composer.json \
|
|
||||||
composer.lock \
|
|
||||||
symfony.lock \
|
|
||||||
frontend/.output
|
|
||||||
|
|
||||||
- name: Create Release
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
files: release/lesstime-${{ github.ref_name }}.tar.gz
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
|
||||||
+7
-1
@@ -28,5 +28,11 @@
|
|||||||
###< ide ###
|
###< ide ###
|
||||||
|
|
||||||
###> docker local ###
|
###> docker local ###
|
||||||
docker/.env.docker.local
|
infra/dev/.env.docker.local
|
||||||
###< docker local ###
|
###< docker local ###
|
||||||
|
|
||||||
|
###> local db dumps ###
|
||||||
|
*.sql.gz
|
||||||
|
*.sql.gz:Zone.Identifier
|
||||||
|
REVIEW.md
|
||||||
|
###< local db dumps ###
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
Application de gestion de projet. Monorepo Symfony 8 (API Platform 4) + Nuxt 4.
|
Application de gestion de projet. Monorepo Symfony 8 (API Platform 4) + Nuxt 4.
|
||||||
|
|
||||||
|
> **WIP — Intégration Mail (branche `feat/mail-integration`)** : client mail OVH IMAP. Avant de toucher au mail, lire `docs/mail-integration.md` (section « Statut & reprise » = bugs déjà corrigés, points en suspens, commandes). Code : `src/Mail/`, `src/Service/MailSyncService.php`, `src/Controller/Mail/`, `frontend/{services,stores,components}/mail*`.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- **Backend** : PHP 8.4, Symfony 8.0, API Platform 4, Doctrine ORM, PostgreSQL 16
|
- **Backend** : PHP 8.4, Symfony 8.0, API Platform 4, Doctrine ORM, PostgreSQL 16
|
||||||
@@ -99,10 +101,14 @@ 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
|
||||||
|
|
||||||
|
### Composants UI
|
||||||
|
|
||||||
|
La librairie `@malio/layer-ui` fournit les composants de formulaire et d'action. La documentation complète des props, events et exemples d'utilisation se trouve dans `frontend/node_modules/@malio/layer-ui/COMPONENTS.md`. Toujours s'y référer avant d'utiliser un composant Malio.
|
||||||
|
|
||||||
### MCP Server
|
### MCP Server
|
||||||
|
|
||||||
- 25 tools MCP exposant projets, tâches, métadonnées, time tracking, et récurrences
|
- 25 tools MCP exposant projets, tâches, métadonnées, time tracking, et récurrences
|
||||||
@@ -125,7 +131,7 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
|||||||
- Container PHP : `php-lesstime-fpm`
|
- Container PHP : `php-lesstime-fpm`
|
||||||
- Container Nginx : `nginx-lesstime`
|
- Container Nginx : `nginx-lesstime`
|
||||||
- Container DB : PostgreSQL sur port **5435** (interne et externe)
|
- Container DB : PostgreSQL sur port **5435** (interne et externe)
|
||||||
- Config Docker : `docker/.env.docker` (override local : `docker/.env.docker.local`)
|
- Config Docker : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`)
|
||||||
- Après modif nginx : `docker restart nginx-lesstime`
|
- Après modif nginx : `docker restart nginx-lesstime`
|
||||||
|
|
||||||
## Fixtures
|
## Fixtures
|
||||||
@@ -136,3 +142,12 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
|||||||
- API token admin (dev) : `dev-mcp-token-for-testing-only-do-not-use-in-production`
|
- API token admin (dev) : `dev-mcp-token-for-testing-only-do-not-use-in-production`
|
||||||
- ZimbraConfiguration : serverUrl `https://mail.ovh.com`, username `lesstime@ovh.fr`, enabled false
|
- ZimbraConfiguration : serverUrl `https://mail.ovh.com`, username `lesstime@ovh.fr`, enabled false
|
||||||
- TaskRecurrence (hebdomadaire lun/mer/ven) attachée à la tâche "Réunion de suivi hebdomadaire" (SIRH)
|
- TaskRecurrence (hebdomadaire lun/mer/ven) attachée à la tâche "Réunion de suivi hebdomadaire" (SIRH)
|
||||||
|
|
||||||
|
## Delegation Codex
|
||||||
|
|
||||||
|
Pour les taches mecaniques (tests, boilerplate, renommages, refacto repetitif), delegue a Codex via le plugin `codex`. Garde Claude pour la reflexion, l'architecture et la verification.
|
||||||
|
|
||||||
|
- **Codex** = junior dev rapide et pas cher (executions mecaniques)
|
||||||
|
- **Claude** = senior dev qui verifie et reflechit (design, review, decisions)
|
||||||
|
|
||||||
|
C'est le meilleur ratio qualite/credits.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ Application de gestion de projet avec suivi du temps et portail client.
|
|||||||
- Profil utilisateur avec avatar (crop circulaire)
|
- Profil utilisateur avec avatar (crop circulaire)
|
||||||
- Notifications temps réel
|
- Notifications temps réel
|
||||||
- Intégration Gitea (issues, repos)
|
- Intégration Gitea (issues, repos)
|
||||||
|
- Intégration Mail IMAP (boîte partagée OVH, voir `docs/mail-integration.md`)
|
||||||
- Serveur MCP pour assistants IA
|
- Serveur MCP pour assistants IA
|
||||||
- Multi-langue (i18n)
|
- Multi-langue (i18n)
|
||||||
|
|
||||||
@@ -44,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 |
|
||||||
@@ -55,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
|
||||||
@@ -73,6 +97,7 @@ make shell-root # Shell root dans le container PHP
|
|||||||
make dev-nuxt # Dev server Nuxt (hot reload, port 3002)
|
make dev-nuxt # Dev server Nuxt (hot reload, port 3002)
|
||||||
make cache-clear # Vider le cache Symfony
|
make cache-clear # Vider le cache Symfony
|
||||||
make logs-dev # Tail logs Symfony
|
make logs-dev # Tail logs Symfony
|
||||||
|
make mail-sync # Synchroniser la boîte mail IMAP (voir docs/mail-cron-setup.md)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Base de données
|
### Base de données
|
||||||
@@ -156,7 +181,7 @@ docker/ # Dockerfiles et config Nginx
|
|||||||
| `nginx-lesstime` | 8082 | Nginx reverse proxy |
|
| `nginx-lesstime` | 8082 | Nginx reverse proxy |
|
||||||
| PostgreSQL | 5435 | Base de données |
|
| PostgreSQL | 5435 | Base de données |
|
||||||
|
|
||||||
Configuration : `docker/.env.docker` (override local : `docker/.env.docker.local`)
|
Configuration : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`)
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
@@ -216,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
|
||||||
|
|
||||||
|
|||||||
+8
-2
@@ -21,12 +21,15 @@
|
|||||||
"sabre/vobject": "^4.5",
|
"sabre/vobject": "^4.5",
|
||||||
"symfony/asset": "8.0.*",
|
"symfony/asset": "8.0.*",
|
||||||
"symfony/console": "8.0.*",
|
"symfony/console": "8.0.*",
|
||||||
|
"symfony/doctrine-messenger": "^8.0",
|
||||||
"symfony/dotenv": "8.0.*",
|
"symfony/dotenv": "8.0.*",
|
||||||
"symfony/expression-language": "8.0.*",
|
"symfony/expression-language": "8.0.*",
|
||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
"symfony/framework-bundle": "8.0.*",
|
"symfony/framework-bundle": "8.0.*",
|
||||||
"symfony/http-client": "8.0.*",
|
"symfony/http-client": "8.0.*",
|
||||||
|
"symfony/lock": "8.0.*",
|
||||||
"symfony/mcp-bundle": "^0.6.0",
|
"symfony/mcp-bundle": "^0.6.0",
|
||||||
|
"symfony/messenger": "^8.0",
|
||||||
"symfony/mime": "8.0.*",
|
"symfony/mime": "8.0.*",
|
||||||
"symfony/monolog-bundle": "^4.0",
|
"symfony/monolog-bundle": "^4.0",
|
||||||
"symfony/property-access": "8.0.*",
|
"symfony/property-access": "8.0.*",
|
||||||
@@ -36,7 +39,8 @@
|
|||||||
"symfony/security-bundle": "8.0.*",
|
"symfony/security-bundle": "8.0.*",
|
||||||
"symfony/serializer": "8.0.*",
|
"symfony/serializer": "8.0.*",
|
||||||
"symfony/validator": "8.0.*",
|
"symfony/validator": "8.0.*",
|
||||||
"symfony/yaml": "8.0.*"
|
"symfony/yaml": "8.0.*",
|
||||||
|
"webklex/php-imap": "^6.2"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"allow-plugins": {
|
"allow-plugins": {
|
||||||
@@ -93,6 +97,8 @@
|
|||||||
"require-dev": {
|
"require-dev": {
|
||||||
"doctrine/doctrine-fixtures-bundle": "^4.3",
|
"doctrine/doctrine-fixtures-bundle": "^4.3",
|
||||||
"friendsofphp/php-cs-fixer": "^3.94",
|
"friendsofphp/php-cs-fixer": "^3.94",
|
||||||
"phpunit/phpunit": "^13.0"
|
"phpunit/phpunit": "^13.0",
|
||||||
|
"symfony/browser-kit": "^8.0",
|
||||||
|
"symfony/css-selector": "^8.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1342
-1
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
|||||||
|
framework:
|
||||||
|
lock: '%env(LOCK_DSN)%'
|
||||||
@@ -21,3 +21,6 @@ mcp:
|
|||||||
store: file
|
store: file
|
||||||
directory: '%kernel.project_dir%/var/mcp-sessions'
|
directory: '%kernel.project_dir%/var/mcp-sessions'
|
||||||
ttl: 3600
|
ttl: 3600
|
||||||
|
discovery:
|
||||||
|
scan_dirs: ['src']
|
||||||
|
exclude_dirs: ['DataFixtures']
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
framework:
|
||||||
|
messenger:
|
||||||
|
failure_transport: failed
|
||||||
|
|
||||||
|
transports:
|
||||||
|
sync: 'sync://'
|
||||||
|
|
||||||
|
async:
|
||||||
|
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
|
||||||
|
options:
|
||||||
|
queue_name: default
|
||||||
|
retry_strategy:
|
||||||
|
max_retries: 3
|
||||||
|
delay: 1000
|
||||||
|
multiplier: 2
|
||||||
|
max_delay: 0
|
||||||
|
|
||||||
|
failed: 'doctrine://default?queue_name=failed&auto_setup=0'
|
||||||
|
|
||||||
|
routing:
|
||||||
|
# Sync à la demande (bouton « rafraîchir ») : exécutée pendant la requête HTTP
|
||||||
|
# pour que le re-fetch du front voie immédiatement les nouveaux mails, sans worker
|
||||||
|
# messenger:consume à maintenir. La sync de fond reste assurée par le cron OS
|
||||||
|
# (app:mail:sync, synchrone, indépendant du bus). Repasser à `async` + worker si
|
||||||
|
# la boîte grossit au point que la sync à la demande approche le timeout PHP.
|
||||||
|
'App\Message\MailSyncRequested': sync
|
||||||
|
|
||||||
|
when@test:
|
||||||
|
framework:
|
||||||
|
messenger:
|
||||||
|
transports:
|
||||||
|
async: 'in-memory://'
|
||||||
|
failed: 'in-memory://'
|
||||||
@@ -64,6 +64,8 @@ security:
|
|||||||
- { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] }
|
- { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] }
|
||||||
- { path: ^/_mcp, roles: PUBLIC_ACCESS, methods: [ GET ] }
|
- { path: ^/_mcp, roles: PUBLIC_ACCESS, methods: [ GET ] }
|
||||||
- { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY }
|
- { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY }
|
||||||
|
# Mail : requiert authentification (les checks ROLE_USER/ROLE_CLIENT sont dans MailAccessChecker)
|
||||||
|
- { path: ^/api/mail, roles: IS_AUTHENTICATED_FULLY }
|
||||||
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
|
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
framework:
|
||||||
|
default_locale: en
|
||||||
|
translator:
|
||||||
|
default_path: '%kernel.project_dir%/translations'
|
||||||
|
providers:
|
||||||
@@ -301,7 +301,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* },
|
* },
|
||||||
* },
|
* },
|
||||||
* translator?: bool|array{ // Translator configuration
|
* translator?: bool|array{ // Translator configuration
|
||||||
* enabled?: bool|Param, // Default: false
|
* enabled?: bool|Param, // Default: true
|
||||||
* fallbacks?: list<scalar|Param|null>,
|
* fallbacks?: list<scalar|Param|null>,
|
||||||
* logging?: bool|Param, // Default: false
|
* logging?: bool|Param, // Default: false
|
||||||
* formatter?: scalar|Param|null, // Default: "translator.formatter.default"
|
* formatter?: scalar|Param|null, // Default: "translator.formatter.default"
|
||||||
@@ -413,7 +413,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* enabled?: bool|Param, // Default: true
|
* enabled?: bool|Param, // Default: true
|
||||||
* },
|
* },
|
||||||
* lock?: bool|string|array{ // Lock configuration
|
* lock?: bool|string|array{ // Lock configuration
|
||||||
* enabled?: bool|Param, // Default: false
|
* enabled?: bool|Param, // Default: true
|
||||||
* resources?: array<string, string|list<scalar|Param|null>>,
|
* resources?: array<string, string|list<scalar|Param|null>>,
|
||||||
* },
|
* },
|
||||||
* semaphore?: bool|string|array{ // Semaphore configuration
|
* semaphore?: bool|string|array{ // Semaphore configuration
|
||||||
@@ -421,7 +421,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* resources?: array<string, scalar|Param|null>,
|
* resources?: array<string, scalar|Param|null>,
|
||||||
* },
|
* },
|
||||||
* messenger?: bool|array{ // Messenger configuration
|
* messenger?: bool|array{ // Messenger configuration
|
||||||
* enabled?: bool|Param, // Default: false
|
* enabled?: bool|Param, // Default: true
|
||||||
* routing?: array<string, string|array{ // Default: []
|
* routing?: array<string, string|array{ // Default: []
|
||||||
* senders?: list<scalar|Param|null>,
|
* senders?: list<scalar|Param|null>,
|
||||||
* }>,
|
* }>,
|
||||||
@@ -1360,7 +1360,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* include_type?: bool|Param, // Always include @var in updates (including delete ones). // Default: false
|
* include_type?: bool|Param, // Always include @var in updates (including delete ones). // Default: false
|
||||||
* },
|
* },
|
||||||
* messenger?: bool|array{
|
* messenger?: bool|array{
|
||||||
* enabled?: bool|Param, // Default: false
|
* enabled?: bool|Param, // Default: true
|
||||||
* },
|
* },
|
||||||
* elasticsearch?: bool|array{
|
* elasticsearch?: bool|array{
|
||||||
* enabled?: bool|Param, // Default: false
|
* enabled?: bool|Param, // Default: false
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.3.17'
|
app.version: '0.4.5'
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
listen [::]:80;
|
|
||||||
server_name project.malio-dev.fr;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://127.0.0.1:8080;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
client_max_body_size 55m;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
listen [::]:80;
|
|
||||||
server_name project.malio-dev.fr;
|
|
||||||
|
|
||||||
root /var/www/lesstime/frontend/.output/public;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
client_max_body_size 55m;
|
|
||||||
|
|
||||||
location ^~ /api/ {
|
|
||||||
root /var/www/lesstime/public;
|
|
||||||
try_files $uri /index.php?$query_string;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ^~ /bundles/ {
|
|
||||||
root /var/www/lesstime/public;
|
|
||||||
try_files $uri =404;
|
|
||||||
}
|
|
||||||
|
|
||||||
location = /api/login_check {
|
|
||||||
include fastcgi_params;
|
|
||||||
fastcgi_param SCRIPT_FILENAME /var/www/lesstime/public/index.php;
|
|
||||||
fastcgi_param DOCUMENT_ROOT /var/www/lesstime/public;
|
|
||||||
fastcgi_param SCRIPT_NAME /index.php;
|
|
||||||
fastcgi_param PATH_INFO /login_check;
|
|
||||||
fastcgi_param REQUEST_URI /login_check;
|
|
||||||
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ^~ /_mcp {
|
|
||||||
root /var/www/lesstime/public;
|
|
||||||
try_files $uri /index.php?$query_string;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ ^/index\.php(/|$) {
|
|
||||||
include fastcgi_params;
|
|
||||||
fastcgi_param SCRIPT_FILENAME /var/www/lesstime/public/index.php;
|
|
||||||
fastcgi_param DOCUMENT_ROOT /var/www/lesstime/public;
|
|
||||||
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ \.php$ {
|
|
||||||
return 404;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -109,23 +109,33 @@ export LESSTIME_IMAGE_TAG="$TAG"
|
|||||||
|
|
||||||
echo "==> Deploying lesstime:${TAG}..."
|
echo "==> Deploying lesstime:${TAG}..."
|
||||||
|
|
||||||
|
echo "==> Enabling maintenance mode..."
|
||||||
|
touch maintenance.on
|
||||||
|
|
||||||
echo "==> Pulling image..."
|
echo "==> Pulling image..."
|
||||||
docker compose pull
|
sudo docker compose pull
|
||||||
|
|
||||||
echo "==> Starting container..."
|
echo "==> Starting container..."
|
||||||
docker compose up -d
|
sudo docker compose up -d
|
||||||
|
|
||||||
echo "==> Waiting for container to be ready..."
|
echo "==> Waiting for container to be ready..."
|
||||||
sleep 3
|
sleep 3
|
||||||
|
|
||||||
|
echo "==> Extracting maintenance page..."
|
||||||
|
mkdir -p public
|
||||||
|
sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
|
||||||
|
|
||||||
echo "==> Running migrations..."
|
echo "==> Running migrations..."
|
||||||
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
||||||
|
|
||||||
echo "==> Clearing cache..."
|
echo "==> Clearing cache..."
|
||||||
docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
||||||
docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
||||||
|
|
||||||
VERSION=$(docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
|
echo "==> Disabling maintenance mode..."
|
||||||
|
rm -f maintenance.on
|
||||||
|
|
||||||
|
VERSION=$(sudo docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
|
||||||
echo "==> Deployed v${VERSION}"
|
echo "==> Deployed v${VERSION}"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -192,16 +202,33 @@ Creer `/etc/nginx/sites-available/lesstime.conf` :
|
|||||||
```nginx
|
```nginx
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
server_name project.malio-dev.fr;
|
server_name project.malio-dev.fr;
|
||||||
|
|
||||||
client_max_body_size 55m;
|
root /var/www/lesstime/public;
|
||||||
|
|
||||||
|
# Maintenance mode
|
||||||
|
if (-f /var/www/lesstime/maintenance.on) {
|
||||||
|
return 503;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_page 503 @maintenance;
|
||||||
|
|
||||||
|
location @maintenance {
|
||||||
|
rewrite ^(.*)$ /maintenance.html break;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /maintenance.html {
|
||||||
|
internal;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:8080;
|
proxy_pass http://127.0.0.1:8081;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
client_max_body_size 55m;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -250,6 +277,8 @@ rm /tmp/lesstime.sql
|
|||||||
├── config/jwt/
|
├── config/jwt/
|
||||||
│ ├── private.pem
|
│ ├── private.pem
|
||||||
│ └── public.pem
|
│ └── public.pem
|
||||||
|
├── public/
|
||||||
|
│ └── maintenance.html # extrait automatiquement par deploy.sh
|
||||||
└── uploads/
|
└── uploads/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# Configuration du mode maintenance (nginx hote)
|
||||||
|
|
||||||
|
Guide pour activer le support du mode maintenance pilote par Central.
|
||||||
|
Ces etapes sont a faire **une seule fois** par application sur le serveur de production.
|
||||||
|
|
||||||
|
Le principe : le nginx de l'hote (reverse proxy) verifie si un fichier `maintenance.on` existe dans le dossier de deploy. Si oui, il sert une page `maintenance.html` au lieu de proxifier vers le container Docker.
|
||||||
|
|
||||||
|
Central pilote la creation/suppression de ce fichier via ses volumes Docker.
|
||||||
|
|
||||||
|
## Ce qui a ete fait pour Lesstime
|
||||||
|
|
||||||
|
### 1. Deployer pour extraire la page maintenance
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/lesstime
|
||||||
|
sudo ./deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Le `deploy.sh` extrait automatiquement `maintenance.html` du container vers `public/` :
|
||||||
|
```
|
||||||
|
mkdir -p public
|
||||||
|
sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Mettre a jour la conf nginx de l'hote
|
||||||
|
|
||||||
|
Remplacer le contenu de `/etc/nginx/sites-available/lesstime.conf` :
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name project.malio-dev.fr;
|
||||||
|
|
||||||
|
root /var/www/lesstime/public;
|
||||||
|
|
||||||
|
# Maintenance mode
|
||||||
|
if (-f /var/www/lesstime/maintenance.on) {
|
||||||
|
return 503;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_page 503 @maintenance;
|
||||||
|
|
||||||
|
location @maintenance {
|
||||||
|
rewrite ^(.*)$ /maintenance.html break;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /maintenance.html {
|
||||||
|
internal;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8081;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
client_max_body_size 55m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Recharger nginx
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Verifier
|
||||||
|
|
||||||
|
- Depuis Central, activer la maintenance sur Lesstime
|
||||||
|
- Ouvrir `http://project.malio-dev.fr` → doit afficher la page "Maintenance en cours"
|
||||||
|
- Desactiver la maintenance depuis Central → le site revient
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A faire pour Inventory
|
||||||
|
|
||||||
|
Meme procedure :
|
||||||
|
|
||||||
|
### 1. Deployer pour extraire la page maintenance
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/inventory
|
||||||
|
sudo ./deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
> Si le `deploy.sh` ne contient pas encore l'extraction, mettre a jour le fichier depuis le repo (`infra/prod/deploy.sh`) ou executer manuellement :
|
||||||
|
> ```bash
|
||||||
|
> mkdir -p public
|
||||||
|
> sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
|
||||||
|
> ```
|
||||||
|
|
||||||
|
### 2. Mettre a jour la conf nginx de l'hote
|
||||||
|
|
||||||
|
Remplacer le contenu de `/etc/nginx/sites-available/inventory.conf` :
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name inventory.malio-dev.fr;
|
||||||
|
|
||||||
|
root /var/www/inventory/public;
|
||||||
|
|
||||||
|
# Maintenance mode
|
||||||
|
if (-f /var/www/inventory/maintenance.on) {
|
||||||
|
return 503;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_page 503 @maintenance;
|
||||||
|
|
||||||
|
location @maintenance {
|
||||||
|
rewrite ^(.*)$ /maintenance.html break;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /maintenance.html {
|
||||||
|
internal;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8082;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Recharger nginx
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fonctionnement
|
||||||
|
|
||||||
|
```
|
||||||
|
Central (container)
|
||||||
|
└── touch /var/www/maintenance/lesstime/maintenance.on
|
||||||
|
│ (volume Docker : /var/www/lesstime → /var/www/maintenance/lesstime)
|
||||||
|
▼
|
||||||
|
/var/www/lesstime/maintenance.on (hote)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
nginx hote : if (-f /var/www/lesstime/maintenance.on) → 503
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
maintenance.html servie depuis /var/www/lesstime/public/
|
||||||
|
```
|
||||||
+4
-4
@@ -2,7 +2,7 @@ services:
|
|||||||
php:
|
php:
|
||||||
container_name: php-${DOCKER_APP_NAME}-fpm
|
container_name: php-${DOCKER_APP_NAME}-fpm
|
||||||
build:
|
build:
|
||||||
context: ./docker/php
|
context: ./infra/dev
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
DOCKER_PHP_VERSION: ${DOCKER_PHP_VERSION}
|
DOCKER_PHP_VERSION: ${DOCKER_PHP_VERSION}
|
||||||
@@ -21,8 +21,8 @@ services:
|
|||||||
- ~/.cache:/var/www/.cache # Pour la cache de composer
|
- ~/.cache:/var/www/.cache # Pour la cache de composer
|
||||||
- ~/.config:/var/www/.config # Pour la config de yarn
|
- ~/.config:/var/www/.config # Pour la config de yarn
|
||||||
- ~/.composer:/var/www/.composer # Pour la config de composer
|
- ~/.composer:/var/www/.composer # Pour la config de composer
|
||||||
- ./docker/php/config/php.ini:/usr/local/etc/php/php.ini
|
- ./infra/dev/php.ini:/usr/local/etc/php/php.ini
|
||||||
- ./docker/php/config/docker-php-ext-xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
|
- ./infra/dev/xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
|
||||||
- ./LOG:/var/www/html/LOG
|
- ./LOG:/var/www/html/LOG
|
||||||
- uploads_data:/var/www/html/var/uploads
|
- uploads_data:/var/www/html/var/uploads
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
@@ -41,7 +41,7 @@ services:
|
|||||||
- "8082:80"
|
- "8082:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/var/www/html:ro
|
- ./:/var/www/html:ro
|
||||||
- ./docker/nginx/conf.d:/etc/nginx/conf.d:ro
|
- ./infra/dev/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Règle Claude : Time Tracking automatique via Lesstime
|
||||||
|
|
||||||
|
> Ajouter ce contenu dans le CLAUDE.md de chaque projet ou dans `~/.claude/CLAUDE.md` pour l'appliquer globalement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Time Tracking obligatoire
|
||||||
|
|
||||||
|
Claude DOIT créer une time entry dans Lesstime au démarrage de chaque tâche de développement, ou sur demande explicite de l'utilisateur ("lance le chrono", "start timer", "track le temps").
|
||||||
|
|
||||||
|
### Déclencheurs
|
||||||
|
|
||||||
|
1. **Début d'une tâche de dev** : feature, bugfix, refactoring, infra, review
|
||||||
|
2. **Demande explicite** : "lance le chrono", "start timer", "track le temps"
|
||||||
|
3. **Depuis un ticket Lesstime** : lier directement au taskId du ticket référencé
|
||||||
|
|
||||||
|
### Méthode
|
||||||
|
|
||||||
|
Créer la time entry via **curl** sur l'API REST Lesstime :
|
||||||
|
|
||||||
|
1. **Login** : `POST http://project.malio-dev.fr/api/login_check`
|
||||||
|
- Body : `{"username":"admin","password":"admin"}`
|
||||||
|
- Réponse : 204 avec cookie `Set-Cookie: BEARER=<jwt>`
|
||||||
|
|
||||||
|
2. **Créer le timer** : `POST http://project.malio-dev.fr/api/time_entries`
|
||||||
|
- Headers : `Cookie: BEARER=<jwt>`, `Content-Type: application/ld+json`, `Accept: application/ld+json`
|
||||||
|
- Body :
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user": "/api/users/5",
|
||||||
|
"startedAt": "<ISO8601 avec timezone>",
|
||||||
|
"title": "<description courte de la tâche>",
|
||||||
|
"project": "/api/projects/<projectId>",
|
||||||
|
"tags": ["/api/task_tags/<tagId>"],
|
||||||
|
"task": "/api/tasks/<taskId>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Stopper le timer** : `PATCH http://project.malio-dev.fr/api/time_entries/<id>`
|
||||||
|
- Headers : `Cookie: BEARER=<jwt>`, `Content-Type: application/merge-patch+json`, `Accept: application/ld+json`
|
||||||
|
- Body : `{"stoppedAt": "<ISO8601>"}`
|
||||||
|
|
||||||
|
### Paramètres obligatoires
|
||||||
|
|
||||||
|
- **user** : TOUJOURS `/api/users/5` (Matthieu)
|
||||||
|
- **startedAt** : ISO 8601 avec timezone (ex: `2026-04-01T14:30:00+02:00`)
|
||||||
|
- **title** : description courte de la tâche en cours
|
||||||
|
- **project** : selon le projet (voir mapping ci-dessous)
|
||||||
|
|
||||||
|
### Tags (choisir selon le type de travail)
|
||||||
|
|
||||||
|
| Tag | ID | IRI |
|
||||||
|
|-----|----|-----|
|
||||||
|
| Backend | 3 | `/api/task_tags/3` |
|
||||||
|
| Frontend | 2 | `/api/task_tags/2` |
|
||||||
|
| IA | 7 | `/api/task_tags/7` |
|
||||||
|
| Infra | 5 | `/api/task_tags/5` |
|
||||||
|
| UI/UX | 4 | `/api/task_tags/4` |
|
||||||
|
| Maintenance | 6 | `/api/task_tags/6` |
|
||||||
|
| RDV | 1 | `/api/task_tags/1` |
|
||||||
|
| Réunion | 8 | `/api/task_tags/8` |
|
||||||
|
| Formation | 10 | `/api/task_tags/10` |
|
||||||
|
| Gestion projet | 9 | `/api/task_tags/9` |
|
||||||
|
|
||||||
|
### Mapping projets
|
||||||
|
|
||||||
|
| Projet | ID | IRI |
|
||||||
|
|--------|----|-----|
|
||||||
|
| Lesstime | 5 | `/api/projects/5` |
|
||||||
|
| Inventory | 7 | `/api/projects/7` |
|
||||||
|
| SIRH | 12 | `/api/projects/12` |
|
||||||
|
| Infrastructure | 13 | `/api/projects/13` |
|
||||||
|
| Malio UI | 11 | `/api/projects/11` |
|
||||||
|
| ERP Liot | 6 | `/api/projects/6` |
|
||||||
|
| Ferme | 8 | `/api/projects/8` |
|
||||||
|
| ADMIN | 16 | `/api/projects/16` |
|
||||||
|
| Maintenance-LIOT | 17 | `/api/projects/17` |
|
||||||
|
| Qualiopi | 14 | `/api/projects/14` |
|
||||||
|
| Vaultwarden | 18 | `/api/projects/18` |
|
||||||
|
|
||||||
|
### Règles
|
||||||
|
|
||||||
|
- **Un seul timer actif à la fois** (contrainte DB) — stopper l'actif avant d'en créer un nouveau
|
||||||
|
- **Toujours stopper le timer** en fin de tâche ou sur demande
|
||||||
|
- **Informer l'utilisateur** quand un timer est lancé/stoppé (numéro, titre, projet, tags)
|
||||||
|
- **Lier au ticket Lesstime** si un ticket est référencé (champ `task`)
|
||||||
|
- **Choisir les tags intelligemment** selon le type de travail effectué
|
||||||
+2
-2
@@ -61,7 +61,7 @@ ENCRYPTION_KEY=<random-hex-32>
|
|||||||
## 4. Installer le script de deploy
|
## 4. Installer le script de deploy
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo cp script/deploy-release.sh /usr/local/bin/deploy-lesstime
|
sudo cp infra/prod/deploy-release.sh /usr/local/bin/deploy-lesstime
|
||||||
sudo chmod +x /usr/local/bin/deploy-lesstime
|
sudo chmod +x /usr/local/bin/deploy-lesstime
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ sudo -u www-data php bin/console lexik:jwt:generate-keypair --skip-if-exists --e
|
|||||||
## 7. Configurer Nginx
|
## 7. Configurer Nginx
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo cp deploy/nginx/lesstime.conf /etc/nginx/sites-available/lesstime
|
sudo cp infra/prod/nginx-baremetal.conf /etc/nginx/sites-available/lesstime
|
||||||
sudo ln -sf /etc/nginx/sites-available/lesstime /etc/nginx/sites-enabled/
|
sudo ln -sf /etc/nginx/sites-available/lesstime /etc/nginx/sites-enabled/
|
||||||
sudo nginx -t && sudo systemctl reload nginx
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# Mail Integration — Configuration cron OS
|
||||||
|
|
||||||
|
## Vue d'ensemble
|
||||||
|
|
||||||
|
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**.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
> **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
|
||||||
|
|
||||||
|
- `MailConfiguration.enabled = true` (configurable depuis l'admin — onglet « Mail »)
|
||||||
|
- `ENCRYPTION_KEY` (clé hex 32 bytes) défini dans l'environnement :
|
||||||
|
- **dev** : `infra/dev/.env.docker.local`
|
||||||
|
- **prod** : `/var/www/lesstime/.env`
|
||||||
|
- Container démarré :
|
||||||
|
- **dev** : `make start` (container `php-lesstime-fpm`)
|
||||||
|
- **prod** : déployé via `sudo ./deploy.sh` (container `lesstime-app`)
|
||||||
|
|
||||||
|
## Variables d'environnement nécessaires
|
||||||
|
|
||||||
|
| Variable | Description | Exemple |
|
||||||
|
|---|---|---|
|
||||||
|
| `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) |
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dev
|
||||||
|
|
||||||
|
### Lancer une sync à la main
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make mail-sync # sync complète (toutes les boîtes)
|
||||||
|
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)
|
||||||
|
```
|
||||||
|
|
||||||
|
Ou directement dans le container :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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 --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs (dev)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make logs-dev # tail -f var/log/dev.log
|
||||||
|
```
|
||||||
|
|
||||||
|
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é
|
||||||
|
|
||||||
|
- Le password IMAP est **toujours stocké chiffré** (libsodium secretbox)
|
||||||
|
- 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`)
|
||||||
|
- 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`
|
||||||
|
- Les corps de mails sont sanitisés via DOMPurify avant affichage (`frontend/utils/sanitizeMailHtml.ts`)
|
||||||
|
- Les pixels de tracking distants sont remplacés par un placeholder
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# Intégration Mail — Vue d'ensemble
|
||||||
|
|
||||||
|
> ## 🟢 Statut & reprise (handoff — MAJ 2026-05-20)
|
||||||
|
>
|
||||||
|
> **Branche** : `feat/mail-integration` · **MR Gitea** : https://gitea.malio.fr/MALIO-DEV/Lesstime/pulls/5 (base `develop`)
|
||||||
|
> Construit en 7 phases (plans dans `docs/superpowers/plans/2026-05-19-mail-phase*.md`).
|
||||||
|
>
|
||||||
|
> ### Ce qui marche (testé contre une vraie boîte OVH `contact@malio.fr`)
|
||||||
|
> - Connexion IMAP + test connexion (admin → `/admin` onglet Mail)
|
||||||
|
> - Synchro complète multi-dossiers : **456 messages / 57 dossiers** ramenés, ne crashe plus
|
||||||
|
> - Lecture dossiers/messages dans `/mail`, arbre repliable (chevrons, sous-dossiers masqués par défaut)
|
||||||
|
> - Lecture d'un mail, sanitization DOMPurify
|
||||||
|
> - Création/lien tâche depuis un mail
|
||||||
|
>
|
||||||
|
> ### Bugs déjà corrigés ce soir (NE PAS ré-investiguer)
|
||||||
|
> Tous dans `ImapMailProvider` / `MailSyncService` — les tests mockaient le provider, donc le fetch réel n'avait jamais été exercé avant le test live :
|
||||||
|
> 1. Requête sans critère → `BAD parse error: zero-length content` → `whereAll()`
|
||||||
|
> 2. `getDate()`/`getSubject()` renvoient des `Attribute` webklex v6 → casts explicites
|
||||||
|
> 3. Séquence par défaut `ST_MSGN` → `peek()` faisait un STORE rejeté par OVH (`flag could not be removed`) → forcé `ST_UID` partout
|
||||||
|
> 4. Snippet via `getTextBody()` = fetch du corps de chaque mail (sync 179s + peek) → `setFetchBody(false)`, snippet désactivé au listing
|
||||||
|
> 5. Test connexion exigeait `enabled=true` → découplé via `getClient(requireEnabled:false)` + `testConnection()`
|
||||||
|
> 6. Contrainte UNIQUE globale sur `message_id` → fausse pour IMAP (même Message-ID dans plusieurs dossiers) → fermait l'EntityManager → cascade. **Migration `Version20260520061736`** : index simple. Garde anti-cascade dans `MailSyncService` (reset `ManagerRegistry`).
|
||||||
|
> 7. 139 connexions IMAP (une/dossier) → throttling OVH → réutilisation d'1 connexion (`closeConnection()` sur l'interface) + reconnexion ciblée après dossier en erreur.
|
||||||
|
> - Contrat front/back réaligné dans `frontend/services/mail.ts` (route `/mail/folders/{path}/messages`, mapping `messages→items`, `fromAddress→fromEmail`, détail plat→imbriqué).
|
||||||
|
>
|
||||||
|
> ### Points en suspens / à savoir
|
||||||
|
> - **Mise à jour auto** = cron OS lançant `make mail-sync` toutes les 10 min (cf `docs/mail-cron-setup.md`). **Pas configuré en dev** — lancer à la main.
|
||||||
|
> - **Bouton "Actualiser"** : dispatch async Messenger (`MailSyncRequested → async`). Sans worker `messenger:consume async` qui tourne, les demandes s'empilent sans s'exécuter. En prod : supervisor. En dev : lancer un worker.
|
||||||
|
> - **~7 dossiers/139** à encodage spécial (ex: `INBOX/RH/.../SÉBASTIEN` en UTF7-modifié) ou réponses vides sont skippés proprement et réessayés au cycle suivant. Edge case webklex non bloquant.
|
||||||
|
> - **Dépendance** : `webklex/php-imap ^6.2` tire des paquets Laravel (`illuminate/*` via `carbon ^3`) dans ce projet Symfony — fonctionnel mais à valider en review.
|
||||||
|
> - 6 PHPUnit Notices (mocks sans expectations) non bloquantes.
|
||||||
|
>
|
||||||
|
> ### Commandes utiles
|
||||||
|
> ```bash
|
||||||
|
> 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 messenger:consume async -vv # worker (fait marcher le bouton)
|
||||||
|
> 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.
|
||||||
|
|
||||||
|
## Fonctionnalités
|
||||||
|
|
||||||
|
- Lecture de la boîte mail partagée (IMAP) depuis Lesstime
|
||||||
|
- Navigation par dossiers (arbre récursif avec compteurs non-lus)
|
||||||
|
- Liste paginée des messages (infinite scroll, cursor-based)
|
||||||
|
- Lecture des corps de mail sanitisés (DOMPurify — protection XSS + pixels tracking)
|
||||||
|
- Création d'une tâche Lesstime depuis un mail (sujet → titre, texte → description)
|
||||||
|
- Lien mail ↔ tâche (bidirectionnel)
|
||||||
|
- Onglet "Mails" dans le TaskDrawer pour retrouver les mails liés à une tâche
|
||||||
|
- Synchronisation IMAP automatique via cron OS (toutes les 10 min)
|
||||||
|
- Déclenchement manuel de sync depuis l'UI (bouton Refresh)
|
||||||
|
- Badge non-lus en temps réel dans la sidebar (polling 30s)
|
||||||
|
|
||||||
|
## Endpoints API
|
||||||
|
|
||||||
|
| Méthode | URL | Rôle | Description |
|
||||||
|
|---------|-----|------|-------------|
|
||||||
|
| GET | `/api/mail/configuration` | ROLE_ADMIN | Lire la config singleton |
|
||||||
|
| PATCH | `/api/mail/configuration` | ROLE_ADMIN | Mettre à jour la config |
|
||||||
|
| POST | `/api/mail/configuration/test` | ROLE_ADMIN | Tester la connexion IMAP |
|
||||||
|
| GET | `/api/mail/folders` | ROLE_USER | Arbre des dossiers + unread |
|
||||||
|
| GET | `/api/mail/messages` | ROLE_USER | Liste paginée (param: folder, cursor, limit) |
|
||||||
|
| GET | `/api/mail/messages/{id}` | ROLE_USER | Détail + body (cached 5 min) |
|
||||||
|
| POST | `/api/mail/messages/{id}/read` | ROLE_USER | Marquer lu/non-lu |
|
||||||
|
| POST | `/api/mail/messages/{id}/flag` | ROLE_USER | Marquer étoilé/non-étoilé |
|
||||||
|
| POST | `/api/mail/messages/{id}/create-task` | ROLE_USER | Créer tâche depuis mail |
|
||||||
|
| POST | `/api/mail/messages/{id}/link-task` | ROLE_USER | Lier mail à tâche existante |
|
||||||
|
| DELETE | `/api/mail/messages/{id}/link-task/{taskId}` | ROLE_USER | Supprimer le lien |
|
||||||
|
| GET | `/api/tasks/{id}/mails` | ROLE_USER | Mails liés à une tâche |
|
||||||
|
| GET | `/api/mail/attachments/{id}` | ROLE_USER | Télécharger une pièce jointe |
|
||||||
|
| POST | `/api/mail/sync` | ROLE_USER | Déclencher sync async (Messenger) |
|
||||||
|
|
||||||
|
Tous les endpoints `/api/mail/*` refusent explicitement `ROLE_CLIENT`.
|
||||||
|
|
||||||
|
## Sécurité
|
||||||
|
|
||||||
|
- ROLE_CLIENT exclusif : accès refusé à tous les endpoints mail et à la page `/mail`
|
||||||
|
- Le sidebar "Messagerie" est masqué pour les ROLE_CLIENT
|
||||||
|
- Password IMAP chiffré via libsodium secretbox (env `ENCRYPTION_KEY`)
|
||||||
|
- Corps de mail sanitisés via DOMPurify (`sanitizeMailHtml.ts`) — script/iframe/object/embed/on*/javascript: bloqués
|
||||||
|
- Pixels tracking distants (img src http) remplacés par placeholder
|
||||||
|
- Aucun body, password ou contenu de pièce jointe dans les logs
|
||||||
|
|
||||||
|
## Dépendances
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- `webklex/php-imap` : client IMAP PHP
|
||||||
|
- `symfony/lock` : Symfony Lock pour éviter les syncs parallèles
|
||||||
|
- `symfony/messenger` : dispatch asynchrone `MailSyncRequested`
|
||||||
|
- `libsodium` (ext PHP) : chiffrement du password IMAP
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- `dompurify` + `@types/dompurify` : sanitization HTML des corps de mail
|
||||||
|
|
||||||
|
## Fichiers clés
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- `src/Entity/MailConfiguration.php` — entité singleton (credentials, enabled)
|
||||||
|
- `src/Entity/MailFolder.php` — dossier IMAP synced
|
||||||
|
- `src/Entity/MailMessage.php` — message IMAP synced (headers, flags)
|
||||||
|
- `src/Entity/TaskMailLink.php` — lien tâche ↔ mail
|
||||||
|
- `src/Mail/ImapMailProvider.php` — implémentation IMAP (webklex)
|
||||||
|
- `src/Service/MailSyncService.php` — algorithme de sync (UID FETCH, resync flags)
|
||||||
|
- `src/Controller/Mail/` — controllers custom (test, folders, messages, sync)
|
||||||
|
- `src/State/Mail/` — providers/processors API Platform (configuration)
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- `frontend/pages/mail.vue` — page principale 3 colonnes
|
||||||
|
- `frontend/components/mail/` — MailFolderTree, MailMessageList, MailMessageViewer, MailRefreshButton
|
||||||
|
- `frontend/components/admin/AdminMailTab.vue` — onglet config admin
|
||||||
|
- `frontend/stores/mail.ts` — store Pinia (folders, messages, polling)
|
||||||
|
- `frontend/services/mail.ts` — service API (toutes les méthodes)
|
||||||
|
- `frontend/services/dto/mail.ts` — types TypeScript
|
||||||
|
- `frontend/utils/sanitizeMailHtml.ts` — DOMPurify wrapper
|
||||||
|
|
||||||
|
## Synchronisation cron
|
||||||
|
|
||||||
|
Voir `docs/mail-cron-setup.md` pour la configuration détaillée.
|
||||||
|
|
||||||
|
Résumé :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Cron OS (toutes les 10 min)
|
||||||
|
*/10 * * * * cd /path/to/Lesstime && make mail-sync >> /var/log/lesstime-mail-sync.log 2>&1
|
||||||
|
|
||||||
|
# Commandes Makefile
|
||||||
|
make mail-sync # Sync complète
|
||||||
|
make mail-sync FOLDER=INBOX # Sync d'un dossier
|
||||||
|
make mail-sync DRYRUN=1 # Simulation sans écriture
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration admin
|
||||||
|
|
||||||
|
1. Aller sur `/admin` → onglet "Mail"
|
||||||
|
2. Renseigner les credentials IMAP/SMTP (OVH : `ssl0.ovh.net`, port 993/465, SSL)
|
||||||
|
3. Cliquer "Tester la connexion"
|
||||||
|
4. Activer la synchronisation → Enregistrer
|
||||||
|
5. Configurer le cron OS
|
||||||
|
|
||||||
|
## Variables d'environnement
|
||||||
|
|
||||||
|
| Variable | Description | Obligatoire |
|
||||||
|
|----------|-------------|-------------|
|
||||||
|
| `ENCRYPTION_KEY` | Clé hex 32 bytes libsodium pour chiffrer le password IMAP | Oui |
|
||||||
|
| `LOCK_DSN` | DSN Symfony Lock (défaut: `flock`) | Non |
|
||||||
|
| `MESSENGER_TRANSPORT_DSN` | Transport Messenger pour sync async | Recommandé (prod) |
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
# Mail Integration — Master Plan
|
||||||
|
|
||||||
|
> **Master plan** : ce document décrit le découpage en phases. Chaque phase aura son propre plan détaillé (rédigé par un subagent rédacteur) puis sera implémentée par un subagent codeur, en cycle.
|
||||||
|
|
||||||
|
**Spec source** : `docs/superpowers/specs/2026-05-19-mail-integration-design.md`
|
||||||
|
|
||||||
|
**Goal** : Ajouter à Lesstime un client mail intégré pour une boîte partagée OVH (IMAP/SMTP), avec lecture inbox/dossiers et création/lien tâche depuis un mail.
|
||||||
|
|
||||||
|
**Stratégie** : 7 phases séquentielles, dépendances claires, chaque phase = working software testable. Cycle par phase : rédacteur → codeur → review humaine → phase suivante.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cartographie des phases
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1 (Backend foundations) ──┐
|
||||||
|
├─→ Phase 2 (IMAP provider + sync) ──┐
|
||||||
|
│ ├─→ Phase 3 (API backend) ──┐
|
||||||
|
│ │ │
|
||||||
|
└─→─────────────────────────────────────────────────────────────────┤
|
||||||
|
│
|
||||||
|
Phase 4 (Frontend services + store) ←──────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
├─→ Phase 5 (UI principale 3 colonnes)
|
||||||
|
│
|
||||||
|
├─→ Phase 6 (Intégration tâches : modals, onglet TaskDrawer)
|
||||||
|
│
|
||||||
|
└─→ Phase 7 (Admin config + sidebar + polish)
|
||||||
|
```
|
||||||
|
|
||||||
|
Chaque phase produit du logiciel fonctionnel (testable, mergeable) sans casser les précédentes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Backend Foundations
|
||||||
|
|
||||||
|
**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase1-foundations.md`
|
||||||
|
|
||||||
|
**Scope** :
|
||||||
|
- Entité `MailConfiguration` (singleton, fields complets de la spec, `encryptedPassword` via `TokenEncryptor`)
|
||||||
|
- Entité `MailFolder`
|
||||||
|
- Entité `MailMessage`
|
||||||
|
- Entité `TaskMailLink` (avec unique constraint)
|
||||||
|
- Repositories : `MailConfigurationRepository::findSingleton()`, `MailFolderRepository`, `MailMessageRepository`, `TaskMailLinkRepository`
|
||||||
|
- Migration Doctrine unique créant les 4 tables (raw SQL)
|
||||||
|
- DTOs sous `src/Mail/Dto/` : `MailFolderDto`, `MailMessageHeaderDto`, `MailMessageDetailDto`, `MailAttachmentDto`
|
||||||
|
- Interface `App\Mail\MailProviderInterface` (signatures uniquement, pas d'impl)
|
||||||
|
- Exception `App\Mail\Exception\MailProviderException`
|
||||||
|
- Tests unitaires repositories (au moins le pattern singleton)
|
||||||
|
|
||||||
|
**Critère d'acceptation** :
|
||||||
|
- `make migration-migrate` passe sans erreur
|
||||||
|
- `php bin/console doctrine:schema:validate` OK
|
||||||
|
- `make test` vert (au moins les tests créés)
|
||||||
|
- Fixture `MailConfiguration` désactivée (OVH defaults) ajoutée
|
||||||
|
|
||||||
|
**Dépendances** : aucune (point d'entrée).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — IMAP Provider + Sync
|
||||||
|
|
||||||
|
**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase2-imap-sync.md`
|
||||||
|
|
||||||
|
**Scope** :
|
||||||
|
- Ajout dépendance Composer `webklex/php-imap` (vérifier compat PHP 8.4)
|
||||||
|
- Implémentation `App\Mail\ImapMailProvider implements MailProviderInterface`
|
||||||
|
- Lecture config via `MailConfigurationRepository::findSingleton()`
|
||||||
|
- Déchiffrement password via `TokenEncryptor`
|
||||||
|
- `listFolders`, `listMessages`, `fetchMessage`, `markRead`, `markFlagged`, `moveMessage`, `fetchAttachment`
|
||||||
|
- Wrapping erreurs en `MailProviderException`
|
||||||
|
- `App\Service\MailSyncService`
|
||||||
|
- `syncAll(): MailSyncReport`
|
||||||
|
- `syncFolder(string $folderPath): MailSyncReport`
|
||||||
|
- `syncFolderStructure(): void`
|
||||||
|
- Algorithme exact de la spec (UID FETCH lastUid+1:*, resync flags N=200 derniers, detect suppressions avec garde 50%)
|
||||||
|
- DTO `MailSyncReport` (count créés / mis à jour / supprimés / errors)
|
||||||
|
- Symfony Lock (`mail.sync`, TTL 10 min)
|
||||||
|
- Commande console `app:mail:sync` (avec option `--folder=...`)
|
||||||
|
- Documentation cron OS + cible Makefile `make mail-sync`
|
||||||
|
- Tests : ImapMailProvider mocké via fixture serveur ou interface, MailSyncService avec provider mocké
|
||||||
|
|
||||||
|
**Critère d'acceptation** :
|
||||||
|
- `php bin/console app:mail:sync --dry-run` fonctionne contre une fake config
|
||||||
|
- Tests `make test` verts
|
||||||
|
- `make mail-sync` documentée dans Makefile
|
||||||
|
|
||||||
|
**Dépendances** : Phase 1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — API Backend
|
||||||
|
|
||||||
|
**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase3-api.md`
|
||||||
|
|
||||||
|
**Scope** :
|
||||||
|
- API Platform ressources :
|
||||||
|
- `GET /api/mail/configuration` (ROLE_ADMIN) — singleton provider
|
||||||
|
- `PATCH /api/mail/configuration` (ROLE_ADMIN) — processor (jamais retourner password en clair, accepter nouveau password à chiffrer)
|
||||||
|
- Custom controllers (priority: 1) :
|
||||||
|
- `POST /api/mail/configuration/test` (ROLE_ADMIN) — test connexion
|
||||||
|
- `GET /api/mail/folders` (ROLE_USER, refus ROLE_CLIENT explicite) — arbre + unreadCount depuis BDD
|
||||||
|
- `GET /api/mail/folders/{path}/messages?page&limit` — pagination cursor `sentAt DESC, id DESC`
|
||||||
|
- `GET /api/mail/messages/{id}` — fetch live IMAP + cache Symfony `mail_body_{messageId}` TTL 5 min
|
||||||
|
- `POST /api/mail/messages/{id}/read` (body `{ read: bool }`)
|
||||||
|
- `POST /api/mail/messages/{id}/flag`
|
||||||
|
- `POST /api/mail/messages/{id}/create-task` (body `{ projectId, taskGroupId?, priority? }`)
|
||||||
|
- `POST /api/mail/messages/{id}/link-task` (body `{ taskId }`)
|
||||||
|
- `DELETE /api/mail/messages/{id}/link-task/{taskId}`
|
||||||
|
- `GET /api/tasks/{id}/mails`
|
||||||
|
- `GET /api/mail/attachments/{id}` — stream, `Content-Disposition: attachment`, jamais inline
|
||||||
|
- `POST /api/mail/sync` — async via Messenger
|
||||||
|
- Message + Handler Symfony Messenger `MailSyncRequested`
|
||||||
|
- Sécurité : `#[IsGranted('IS_AUTHENTICATED_FULLY')]` + check `ROLE_USER && !ROLE_CLIENT` explicite
|
||||||
|
- Tests fonctionnels endpoints (auth, format réponses, ROLE_CLIENT refusé)
|
||||||
|
|
||||||
|
**Critère d'acceptation** :
|
||||||
|
- Tous endpoints répondent corrects status/format
|
||||||
|
- Tests `make test` verts
|
||||||
|
- ROLE_CLIENT refusé sur 100% des endpoints mail
|
||||||
|
- Password jamais leak dans les réponses
|
||||||
|
|
||||||
|
**Dépendances** : Phase 1, Phase 2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Frontend Services + Store
|
||||||
|
|
||||||
|
**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase4-frontend-services.md`
|
||||||
|
|
||||||
|
**Scope** :
|
||||||
|
- Install npm `dompurify` + types
|
||||||
|
- `frontend/services/dto/mail.ts` : tous les types TS
|
||||||
|
- `frontend/services/mail.ts` : méthodes API (suivre pattern `tasks.ts`)
|
||||||
|
- `listFolders`, `listMessages`, `getMessage`, `markRead`, `markFlagged`
|
||||||
|
- `createTaskFromMail`, `linkTask`, `unlinkTask`, `listMailsForTask`
|
||||||
|
- `triggerSync`
|
||||||
|
- `getConfiguration`, `updateConfiguration`, `testConfiguration`
|
||||||
|
- `downloadAttachment` (retourne Blob)
|
||||||
|
- Store Pinia `frontend/stores/useMailStore.ts`
|
||||||
|
- State : `folders`, `selectedFolderPath`, `messages[]`, `selectedMessageId`, `selectedMessageDetail`, `loading`, `syncing`, `globalUnreadCount`
|
||||||
|
- Actions correspondantes
|
||||||
|
- Polling `pollUnreadCount()` toutes les 30s (start/stop)
|
||||||
|
- Sanitization helper `frontend/utils/sanitizeMailHtml.ts` (DOMPurify avec config bloquante : script/iframe/object/embed/on*/javascript:, strip ou placeholder pour `<img src="http(s)://...">` distants)
|
||||||
|
|
||||||
|
**Critère d'acceptation** :
|
||||||
|
- `cd frontend && npx tsc --noEmit` OK
|
||||||
|
- Test manuel d'un appel `mail.listFolders()` depuis devtools renvoie 401 si pas authentifié, 200 sinon
|
||||||
|
|
||||||
|
**Dépendances** : Phase 3 (les endpoints doivent exister).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5 — UI principale (page /mail)
|
||||||
|
|
||||||
|
**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase5-ui-main.md`
|
||||||
|
|
||||||
|
**Scope** :
|
||||||
|
- Page `frontend/pages/mail.vue` — layout 3 colonnes (dossiers / liste / lecteur), responsive
|
||||||
|
- Composants `frontend/components/mail/` :
|
||||||
|
- `MailFolderTree.vue` — arbre récursif avec badges unread, sélection
|
||||||
|
- `MailMessageList.vue` — liste paginée (infinite scroll), indicateurs lu/étoilé/PJ, formatage relatif des dates
|
||||||
|
- `MailMessageViewer.vue` — header (de/à/cc/date) + body sanitizé via DOMPurify + liste PJ téléchargeables + actions (Créer tâche / Lier / Marquer lu/non-lu / Étoiler)
|
||||||
|
- `MailRefreshButton.vue` — bouton sync manuel, désactivé pendant `syncing`
|
||||||
|
- i18n clés `mail.*` dans `frontend/i18n/locales/fr.json` (et `en.json` si présent) : titres, vides, actions, erreurs
|
||||||
|
- Mapping noms dossiers système (`INBOX`, `Sent`, `Drafts`, `Archive`, `Trash`, `Junk`) → labels traduits
|
||||||
|
- Gestion query param `?messageId=X` pour deep-link vers un mail (selection auto à l'ouverture)
|
||||||
|
- Refus visuel pour ROLE_CLIENT (le middleware backend bloque déjà, mais ajouter check côté router/middleware Nuxt)
|
||||||
|
|
||||||
|
**Critère d'acceptation** :
|
||||||
|
- Page accessible à `/mail` pour ROLE_USER/ROLE_ADMIN
|
||||||
|
- ROLE_CLIENT redirigé vers `/portal`
|
||||||
|
- Pas d'XSS via body mail (test manuel avec un mail contenant `<script>alert(1)</script>`)
|
||||||
|
- Pixels tracking distants remplacés par placeholder
|
||||||
|
|
||||||
|
**Dépendances** : Phase 4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6 — Intégration Tâches
|
||||||
|
|
||||||
|
**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase6-task-integration.md`
|
||||||
|
|
||||||
|
**Scope** :
|
||||||
|
- `frontend/components/mail/MailCreateTaskModal.vue` — wrapper du `TaskDrawer` existant pré-rempli :
|
||||||
|
- Titre = subject
|
||||||
|
- Description = body plain text
|
||||||
|
- Picker projet + groupe + priorité
|
||||||
|
- À la création : appelle `POST /api/mail/messages/{id}/create-task`, ferme modal, redirige ou affiche succès
|
||||||
|
- `frontend/components/mail/MailLinkTaskModal.vue` — autocomplete sur tâches existantes (filter par projet, statut non-archivé)
|
||||||
|
- Onglet **"Mails"** sur `TaskDrawer.vue` :
|
||||||
|
- Nouvelle section affichée à côté Documents / Time tracking / etc.
|
||||||
|
- Liste `MailMessage` liés à la tâche (via `GET /api/tasks/{id}/mails`)
|
||||||
|
- Item cliquable → `router.push('/mail?messageId=' + id)`
|
||||||
|
- Bouton "Lier un mail" → ouvre un picker mail (TBD selon ergonomie : modal recherche ou redirige vers /mail)
|
||||||
|
- Tests manuels : créer tâche depuis mail, lier mail à tâche existante, voir mail depuis onglet tâche
|
||||||
|
|
||||||
|
**Critère d'acceptation** :
|
||||||
|
- Workflow complet : mail → "Créer tâche" → tâche créée et liée → visible dans onglet "Mails" du TaskDrawer
|
||||||
|
- Workflow : tâche existante → "Lier mail" → mail apparaît dans onglet
|
||||||
|
|
||||||
|
**Dépendances** : Phase 5.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7 — Admin Config + Sidebar + Polish
|
||||||
|
|
||||||
|
**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase7-admin-polish.md`
|
||||||
|
|
||||||
|
**Scope** :
|
||||||
|
- `frontend/components/admin/AdminMailTab.vue` (calqué sur `AdminZimbraTab.vue`) :
|
||||||
|
- Form : protocol (imap pour MVP), imapHost/Port/Encryption, smtpHost/Port/Encryption, username, password (write-only, `hasPassword: true` côté GET), sentFolderPath, enabled toggle
|
||||||
|
- Bouton "Tester la connexion" → `POST /api/mail/configuration/test`
|
||||||
|
- Indicateur OVH defaults pré-remplis (`ssl0.ovh.net:993/465`)
|
||||||
|
- Ajout onglet `AdminMailTab` dans la page admin (selon pattern existant)
|
||||||
|
- Lien sidebar dans le layout default :
|
||||||
|
- Icône `material-symbols:mail-outline`
|
||||||
|
- Label traduit
|
||||||
|
- Badge unread (count `useMailStore.globalUnreadCount`)
|
||||||
|
- Visible uniquement pour `ROLE_USER && !ROLE_CLIENT`
|
||||||
|
- Lifecycle polling 30s : start dans `app.vue` ou layout default, stop au logout
|
||||||
|
- Documentation finale :
|
||||||
|
- README ou `docs/` : section "Mail integration" (cron OS, variables config, sécurité)
|
||||||
|
- Makefile : `make mail-sync` documentée
|
||||||
|
- Vérification finale tracking pixels (relire `sanitizeMailHtml.ts` + tester)
|
||||||
|
- QA passe : workflow end-to-end depuis vraie boîte OVH (si dispo) ou IMAP test (greenmail/dovecot local)
|
||||||
|
|
||||||
|
**Critère d'acceptation** :
|
||||||
|
- Admin peut configurer la boîte, tester, activer
|
||||||
|
- Sidebar affiche badge unread temps réel (30s polling)
|
||||||
|
- Doc d'install à jour
|
||||||
|
- Aucun warning console front, aucun ERROR PHP dans `make logs-dev`
|
||||||
|
|
||||||
|
**Dépendances** : Phase 5 (sidebar utilise le store), Phase 3 (admin API).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conventions communes à toutes les phases
|
||||||
|
|
||||||
|
- **TDD** : test rouge → code → test vert → commit
|
||||||
|
- **Strict types** PHP (`declare(strict_types=1)`) en tête de chaque fichier
|
||||||
|
- **PHP CS Fixer** : `make php-cs-fixer-allow-risky` avant chaque commit
|
||||||
|
- **Commits** : format `<type>(mail) : <message>` (espace avant `:`)
|
||||||
|
- **Branche** : `feat/mail-integration` (créée au début de Phase 1)
|
||||||
|
- **Pas de jamais logger** : bodies, password, attachments
|
||||||
|
- **Review humaine entre chaque phase** : le user valide avant lancement phase suivante
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cycle d'exécution
|
||||||
|
|
||||||
|
Pour chaque phase N :
|
||||||
|
|
||||||
|
1. **Spawn subagent rédacteur** (`feature-dev:code-architect`)
|
||||||
|
- Input : ce master plan + spec + scope phase N
|
||||||
|
- Output : `docs/superpowers/plans/2026-05-19-mail-phaseN-*.md` au format `writing-plans` (tasks bite-sized, fichiers exacts, code complet, commandes test)
|
||||||
|
|
||||||
|
2. **Spawn subagent codeur** (`ruflo-core:coder`)
|
||||||
|
- Input : plan détaillé phase N
|
||||||
|
- Output : code + tests + commits (TDD strict)
|
||||||
|
|
||||||
|
3. **Review humaine** : user valide ou demande corrections
|
||||||
|
|
||||||
|
4. **Phase suivante** uniquement si OK
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,526 @@
|
|||||||
|
# Mail Integration — Phase 7 : Admin Config + Sidebar + Polish
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Finaliser l'intégration mail avec l'UI admin de configuration, le lien sidebar avec badge unread temps réel (polling 30s), et la documentation utilisateur/opérationnelle finale.
|
||||||
|
|
||||||
|
**Architecture:** Onglet `AdminMailTab.vue` calqué sur `AdminZimbraTab.vue` (form IMAP/SMTP/credentials, bouton test connexion). Lien sidebar dans `layouts/default.vue` (visible ROLE_USER+ROLE_ADMIN seulement, masqué ROLE_CLIENT pur). Polling start au login / stop au logout via layout. Documentation finale dans `docs/` + section README mail.
|
||||||
|
|
||||||
|
**Tech Stack:** Nuxt 4, Vue 3 Composition API, @malio/layer-ui, Pinia (useMailStore).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fichiers créés / modifiés
|
||||||
|
|
||||||
|
| Fichier | Action |
|
||||||
|
|---------|--------|
|
||||||
|
| `frontend/components/admin/AdminMailTab.vue` | **Créer** |
|
||||||
|
| `frontend/pages/admin.vue` | **Modifier** (ajout onglet mail) |
|
||||||
|
| `frontend/layouts/default.vue` | **Modifier** (lien sidebar + polling lifecycle) |
|
||||||
|
| `frontend/i18n/locales/fr.json` | **Modifier** (clés mail.admin.* + mail.sidebar.*) |
|
||||||
|
| `frontend/i18n/locales/en.json` | **Modifier si présent** |
|
||||||
|
| `docs/mail-cron-setup.md` | **Modifier** (enrichir checklist prod + sécurité) |
|
||||||
|
| `docs/mail-integration.md` | **Créer** (doc complète intégration) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1 : Composant `AdminMailTab.vue`
|
||||||
|
|
||||||
|
**Fichier cible :** `frontend/components/admin/AdminMailTab.vue`
|
||||||
|
|
||||||
|
**Modèle de référence :** `frontend/components/admin/AdminZimbraTab.vue` — reproduire exactement le même pattern (reactive form, hasPassword, isSaving/isTesting, loadSettings onMounted, handleSave/handleTest).
|
||||||
|
|
||||||
|
**Service à utiliser :** `useMailService()` depuis `~/services/mail` — méthodes `getConfiguration`, `updateConfiguration`, `testConfiguration`.
|
||||||
|
|
||||||
|
**DTOs :** `MailConfigurationDto`, `MailConfigurationUpdateDto`, `MailTestConnectionResultDto` depuis `~/services/dto/mail`.
|
||||||
|
|
||||||
|
### Étapes
|
||||||
|
|
||||||
|
- [ ] Créer `frontend/components/admin/AdminMailTab.vue`
|
||||||
|
- [ ] Déclarer le reactive form avec tous les champs de `MailConfigurationDto` (sauf `hasPassword`, qui est en lecture seule) :
|
||||||
|
```
|
||||||
|
protocol: '' (lecture seule "imap" en MVP — champ disabled)
|
||||||
|
imapHost: ''
|
||||||
|
imapPort: 993 (default OVH)
|
||||||
|
imapEncryption: 'ssl' (default OVH)
|
||||||
|
smtpHost: ''
|
||||||
|
smtpPort: 465 (default OVH)
|
||||||
|
smtpEncryption: 'ssl' (default OVH)
|
||||||
|
username: ''
|
||||||
|
password: '' (write-only — jamais pré-rempli)
|
||||||
|
sentFolderPath: '' (ex: "Sent Messages" ou "INBOX.Sent")
|
||||||
|
enabled: false
|
||||||
|
```
|
||||||
|
- [ ] `hasPassword` : `ref<boolean>(false)` — alimenté par `getConfiguration().hasPassword`
|
||||||
|
- [ ] `isSaving` : `ref<boolean>(false)`, `isTesting` : `ref<boolean>(false)`
|
||||||
|
- [ ] `testResult` : `ref<boolean | null>(null)` — réinitialisé à null au handleSave
|
||||||
|
- [ ] `loadSettings()` :
|
||||||
|
```ts
|
||||||
|
async function loadSettings(): Promise<void> {
|
||||||
|
const config = await getConfiguration()
|
||||||
|
form.protocol = config.protocol ?? 'imap'
|
||||||
|
form.imapHost = config.imapHost ?? ''
|
||||||
|
form.imapPort = config.imapPort ?? 993
|
||||||
|
form.imapEncryption = config.imapEncryption ?? 'ssl'
|
||||||
|
form.smtpHost = config.smtpHost ?? ''
|
||||||
|
form.smtpPort = config.smtpPort ?? 465
|
||||||
|
form.smtpEncryption = config.smtpEncryption ?? 'ssl'
|
||||||
|
form.username = config.username ?? ''
|
||||||
|
form.sentFolderPath = config.sentFolderPath ?? ''
|
||||||
|
form.enabled = config.enabled
|
||||||
|
hasPassword.value = config.hasPassword
|
||||||
|
// password jamais pré-rempli
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- [ ] `handleSave()` : construit un `MailConfigurationUpdateDto` — inclure `password` uniquement si `form.password` est non-vide, sinon omettre le champ. Après save réussi : `hasPassword.value = result.hasPassword`, vider `form.password`, `testResult.value = null`
|
||||||
|
- [ ] `handleTest()` : appelle `testConfiguration()`, `testResult.value = result.ok`. Le champ `result.error` est affiché en sous-texte si `testResult.value === false`
|
||||||
|
- [ ] Template — sections IMAP et SMTP avec labels traduits :
|
||||||
|
- Titre `h2` : `$t('mail.admin.title')`
|
||||||
|
- Section IMAP (`fieldset` ou `div` avec titre `$t('mail.admin.imapSection')`) :
|
||||||
|
- `MalioInputText` pour `imapHost` + helper text `$t('mail.admin.ovhDefaultsHelp')` sous le champ (texte gris : `ssl0.ovh.net`)
|
||||||
|
- `input[type=number]` natif pour `imapPort` (MalioInputText n'accepte pas les number — voir convention CLAUDE.md)
|
||||||
|
- `select` natif pour `imapEncryption` (options : `ssl`, `tls`, `none`)
|
||||||
|
- Section SMTP (`$t('mail.admin.smtpSection')`) :
|
||||||
|
- `MalioInputText` pour `smtpHost`
|
||||||
|
- `input[type=number]` natif pour `smtpPort`
|
||||||
|
- `select` natif pour `smtpEncryption` (options : `ssl`, `tls`, `none`)
|
||||||
|
- Credentials :
|
||||||
|
- `MalioInputText` pour `username`
|
||||||
|
- `MalioInputPassword` pour `password` + indicateur `hasPassword` (même pattern que `AdminZimbraTab.vue` : `<p v-if="hasPassword && !form.password">{{ $t('mail.admin.passwordSet') }}</p>`)
|
||||||
|
- `MalioInputText` pour `sentFolderPath` (placeholder: `Sent Messages`)
|
||||||
|
- `label` + checkbox natif pour `enabled` : `$t('mail.admin.enabled')`
|
||||||
|
- Boutons côte à côte :
|
||||||
|
- `MalioButton` submit `$t('mail.admin.save')` `:disabled="isSaving"` → `handleSave`
|
||||||
|
- `MalioButton` variant tertiary `$t('mail.admin.test')` `:disabled="isTesting"` → `handleTest`
|
||||||
|
- Résultat test : `<p v-if="testResult !== null">` coloré vert/rouge selon valeur — si false ET `testError`, afficher `testError` sous le résultat
|
||||||
|
- [ ] `onMounted(() => { loadSettings() })`
|
||||||
|
- [ ] Vérifier indentation 4 espaces, pas d'imports inutilisés, TypeScript strict
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2 : Intégration `AdminMailTab` dans `pages/admin.vue`
|
||||||
|
|
||||||
|
**Fichier cible :** `frontend/pages/admin.vue`
|
||||||
|
|
||||||
|
Le pattern actuel utilise un tableau `tabs as const` + `activeTab` ref + v-if par composant. Il suffit d'ajouter l'entrée mail à la fin.
|
||||||
|
|
||||||
|
### Étapes
|
||||||
|
|
||||||
|
- [ ] Ouvrir `frontend/pages/admin.vue`
|
||||||
|
- [ ] Dans le tableau `tabs`, ajouter à la fin :
|
||||||
|
```ts
|
||||||
|
{ key: 'mail', label: 'Mail' },
|
||||||
|
```
|
||||||
|
Remarque : les labels dans `tabs` sont des string litéraux inline (cf. autres onglets comme `'Zimbra'`), pas de i18n ici.
|
||||||
|
- [ ] Le type `TabKey` est inféré automatiquement via `typeof tabs[number]['key']` — pas de changement nécessaire
|
||||||
|
- [ ] Dans le template, après `<AdminZimbraTab v-if="activeTab === 'zimbra'" />`, ajouter :
|
||||||
|
```html
|
||||||
|
<AdminMailTab v-if="activeTab === 'mail'" />
|
||||||
|
```
|
||||||
|
- [ ] Vérifier que Nuxt auto-importe `AdminMailTab` (fichier dans `components/admin/` → auto-import OK)
|
||||||
|
- [ ] Test manuel : naviguer vers `/admin`, cliquer l'onglet "Mail", vérifier que le form se charge sans erreur 403 si connecté ROLE_ADMIN
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3 : Lien sidebar dans `layouts/default.vue`
|
||||||
|
|
||||||
|
**Fichier cible :** `frontend/layouts/default.vue`
|
||||||
|
|
||||||
|
Le composant `SidebarLink` accepte `to`, `icon`, `label`, `collapsed`. Il n'a pas de prop `badge` native — vérifier dans `@malio/layer-ui/COMPONENTS.md` si une prop badge existe. Si non, wrapper manuel avec un `<div class="relative">` + badge absolu.
|
||||||
|
|
||||||
|
### Étapes
|
||||||
|
|
||||||
|
- [ ] Lire `frontend/node_modules/@malio/layer-ui/COMPONENTS.md` pour vérifier les props de `SidebarLink` (présence prop `badge` ou `badgeCount`)
|
||||||
|
- [ ] **Cas A — SidebarLink a une prop badge :**
|
||||||
|
Utiliser directement :
|
||||||
|
```html
|
||||||
|
<SidebarLink
|
||||||
|
v-if="isMailVisible"
|
||||||
|
to="/mail"
|
||||||
|
icon="material-symbols:mail-outline"
|
||||||
|
label="$t('mail.sidebar.title')"
|
||||||
|
:collapsed="sidebarIsCollapsed"
|
||||||
|
:badge="mailStore.globalUnreadCount > 0 ? mailStore.globalUnreadCount : undefined"
|
||||||
|
aria-label="$t('mail.sidebar.ariaLabel')"
|
||||||
|
@click="ui.closeMobileSidebar()"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
- [ ] **Cas B — SidebarLink n'a pas de prop badge (plus probable) :**
|
||||||
|
Wrapper avec badge manuel :
|
||||||
|
```html
|
||||||
|
<div v-if="isMailVisible" class="relative">
|
||||||
|
<SidebarLink
|
||||||
|
to="/mail"
|
||||||
|
icon="material-symbols:mail-outline"
|
||||||
|
:label="$t('mail.sidebar.title')"
|
||||||
|
:collapsed="sidebarIsCollapsed"
|
||||||
|
@click="ui.closeMobileSidebar()"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-if="mailStore.globalUnreadCount > 0"
|
||||||
|
class="absolute right-2 top-1/2 -translate-y-1/2 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
|
||||||
|
:aria-label="`${mailStore.globalUnreadCount} messages non lus`"
|
||||||
|
>
|
||||||
|
{{ mailStore.globalUnreadCount > 99 ? '99+' : mailStore.globalUnreadCount }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
- [ ] Dans `<script setup>`, ajouter :
|
||||||
|
```ts
|
||||||
|
const mailStore = useMailStore()
|
||||||
|
```
|
||||||
|
- [ ] Définir le computed `isMailVisible` :
|
||||||
|
```ts
|
||||||
|
const isMailVisible = computed(() => {
|
||||||
|
const roles: string[] = auth.user?.roles ?? []
|
||||||
|
// Visible si ROLE_USER (ou ROLE_ADMIN) mais pas ROLE_CLIENT exclusif
|
||||||
|
const isClient = roles.includes('ROLE_CLIENT') && !roles.includes('ROLE_ADMIN') && !roles.includes('ROLE_USER')
|
||||||
|
return !isClient && (roles.includes('ROLE_USER') || roles.includes('ROLE_ADMIN'))
|
||||||
|
})
|
||||||
|
```
|
||||||
|
- [ ] Placer le lien sidebar **après** `SidebarLink to="/my-tasks"` et **avant** `SidebarLink to="/projects"` (ordre logique : dashboard → mes tâches → mail → projets → suivi de temps → admin)
|
||||||
|
- [ ] Vérifier responsive : en mode collapsed (`sidebarIsCollapsed = true`), le badge doit rester visible et accessible
|
||||||
|
- [ ] Test manuel : utilisateur ROLE_CLIENT seul → lien absent. Utilisateur ROLE_USER → lien visible. Badge rouge si `globalUnreadCount > 0`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4 : Lifecycle polling start/stop
|
||||||
|
|
||||||
|
**Fichier cible :** `frontend/layouts/default.vue`
|
||||||
|
|
||||||
|
Le store `useMailStore` expose `startPolling()` (idempotent — guard `if (pollTimer) return`) et `stopPolling()`. Le polling doit démarrer au montage du layout (si l'utilisateur est autorisé) et s'arrêter au logout.
|
||||||
|
|
||||||
|
### Étapes
|
||||||
|
|
||||||
|
- [ ] Dans `onMounted` de `layouts/default.vue` (qui contient déjà `timerStore.fetchActive()`), ajouter après :
|
||||||
|
```ts
|
||||||
|
if (isMailVisible.value) {
|
||||||
|
mailStore.startPolling()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- [ ] Vérifier que `isMailVisible` est disponible dans le même scope (oui, c'est un computed défini dans `<script setup>`)
|
||||||
|
- [ ] Pour le stop au logout : dans `useAuthStore`, le logout vide l'user. Watcher sur `auth.user` dans le layout :
|
||||||
|
```ts
|
||||||
|
watch(() => auth.user, (user) => {
|
||||||
|
if (!user) {
|
||||||
|
mailStore.stopPolling()
|
||||||
|
} else if (isMailVisible.value) {
|
||||||
|
mailStore.startPolling()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
- [ ] Vérifier l'idempotence : `startPolling()` dans le store a déjà `if (pollTimer) return` — naviguer entre les pages ne crée pas plusieurs timers
|
||||||
|
- [ ] `onUnmounted` dans le layout n'est pas nécessaire car le layout persiste toute la session ; le watch sur `auth.user` suffit
|
||||||
|
- [ ] Test manuel : ouvrir devtools → Network → vérifier un seul appel `GET /api/mail/folders` toutes les 30s, pas de rafale
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5 : i18n additionnels Phase 7
|
||||||
|
|
||||||
|
**Fichiers cibles :** `frontend/i18n/locales/fr.json` (et `en.json` si présent)
|
||||||
|
|
||||||
|
### Clés à ajouter (section `mail` — fusionner avec les clés existantes des phases précédentes)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mail": {
|
||||||
|
"sidebar": {
|
||||||
|
"title": "Messagerie",
|
||||||
|
"ariaLabel": "Accès à la messagerie, {count} messages non lus"
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"title": "Configuration messagerie",
|
||||||
|
"protocol": "Protocole",
|
||||||
|
"imapSection": "Réception (IMAP)",
|
||||||
|
"smtpSection": "Envoi (SMTP)",
|
||||||
|
"host": "Serveur",
|
||||||
|
"port": "Port",
|
||||||
|
"encryption": "Chiffrement",
|
||||||
|
"username": "Adresse e-mail",
|
||||||
|
"password": "Mot de passe",
|
||||||
|
"passwordSet": "Mot de passe déjà configuré — laisser vide pour conserver",
|
||||||
|
"sentFolderPath": "Dossier des envois",
|
||||||
|
"enabled": "Activer la synchronisation mail",
|
||||||
|
"test": "Tester la connexion",
|
||||||
|
"testSuccess": "Connexion IMAP réussie",
|
||||||
|
"testFailed": "Échec de connexion",
|
||||||
|
"save": "Enregistrer",
|
||||||
|
"saveSuccess": "Configuration enregistrée",
|
||||||
|
"ovhDefaultsHelp": "OVH : ssl0.ovh.net (port 993 IMAP / 465 SMTP)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Étapes
|
||||||
|
|
||||||
|
- [ ] Ouvrir `frontend/i18n/locales/fr.json`
|
||||||
|
- [ ] Localiser la section `mail` existante (créée en Phase 4/5)
|
||||||
|
- [ ] Fusionner les clés `mail.sidebar.*` et `mail.admin.*` sans écraser les clés existantes
|
||||||
|
- [ ] Si `en.json` existe : ajouter les équivalents anglais (traduction directe — pas d'approximation)
|
||||||
|
- [ ] Vérifier la cohérence JSON (virgules, pas de clés dupliquées)
|
||||||
|
- [ ] `make dev-nuxt` → console browser → 0 warning `[vue-i18n] Missing locale message`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6 : Documentation finale
|
||||||
|
|
||||||
|
### 6a — Enrichir `docs/mail-cron-setup.md`
|
||||||
|
|
||||||
|
**Fichier cible :** `docs/mail-cron-setup.md`
|
||||||
|
|
||||||
|
Ce fichier existe déjà (créé Phase 2). Ajouter les sections manquantes :
|
||||||
|
|
||||||
|
- [ ] Ajouter section **"Checklist setup production"** après la section "Variables d'environnement" :
|
||||||
|
```markdown
|
||||||
|
## Checklist setup production
|
||||||
|
|
||||||
|
1. [ ] Définir `ENCRYPTION_KEY` dans les variables d'environnement production
|
||||||
|
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`)
|
||||||
|
```
|
||||||
|
- [ ] Ajouter section **"Sécurité"** (si absente ou incomplète) :
|
||||||
|
```markdown
|
||||||
|
## Rappels sécurité
|
||||||
|
|
||||||
|
- 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 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 (voir `frontend/utils/sanitizeMailHtml.ts`)
|
||||||
|
- Les pixels tracking distants sont remplacés par un placeholder
|
||||||
|
- Aucun body mail, password ou contenu de pièce jointe n'est loggé
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6b — Créer `docs/mail-integration.md`
|
||||||
|
|
||||||
|
**Fichier cible :** `docs/mail-integration.md`
|
||||||
|
|
||||||
|
- [ ] Créer le fichier avec les sections suivantes :
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Intégration Mail — Vue d'ensemble
|
||||||
|
|
||||||
|
## Fonctionnalités
|
||||||
|
|
||||||
|
- Lecture de la boîte mail partagée (IMAP) depuis Lesstime
|
||||||
|
- Navigation par dossiers (arbre récursif avec compteurs non-lus)
|
||||||
|
- Liste paginée des messages (infinite scroll, cursor-based)
|
||||||
|
- Lecture des corps de mail sanitisés (DOMPurify — protection XSS + pixels tracking)
|
||||||
|
- Création d'une tâche Lesstime depuis un mail (sujet → titre, texte → description)
|
||||||
|
- Lien mail ↔ tâche (bidirectionnel)
|
||||||
|
- Onglet "Mails" dans le TaskDrawer pour retrouver les mails liés à une tâche
|
||||||
|
- Synchronisation IMAP automatique via cron OS (toutes les 10 min)
|
||||||
|
- Déclenchement manuel de sync depuis l'UI (bouton Refresh)
|
||||||
|
- Badge non-lus en temps réel dans la sidebar (polling 30s)
|
||||||
|
|
||||||
|
## Endpoints API
|
||||||
|
|
||||||
|
| Méthode | URL | Rôle | Description |
|
||||||
|
|---------|-----|------|-------------|
|
||||||
|
| GET | `/api/mail/configuration` | ROLE_ADMIN | Lire la config singleton |
|
||||||
|
| PATCH | `/api/mail/configuration` | ROLE_ADMIN | Mettre à jour la config |
|
||||||
|
| POST | `/api/mail/configuration/test` | ROLE_ADMIN | Tester la connexion IMAP |
|
||||||
|
| GET | `/api/mail/folders` | ROLE_USER | Arbre des dossiers + unread |
|
||||||
|
| GET | `/api/mail/messages` | ROLE_USER | Liste paginée (param: folder, cursor, limit) |
|
||||||
|
| GET | `/api/mail/messages/{id}` | ROLE_USER | Détail + body (cached 5 min) |
|
||||||
|
| POST | `/api/mail/messages/{id}/read` | ROLE_USER | Marquer lu/non-lu |
|
||||||
|
| POST | `/api/mail/messages/{id}/flag` | ROLE_USER | Marquer étoilé/non-étoilé |
|
||||||
|
| POST | `/api/mail/messages/{id}/create-task` | ROLE_USER | Créer tâche depuis mail |
|
||||||
|
| POST | `/api/mail/messages/{id}/link-task` | ROLE_USER | Lier mail à tâche existante |
|
||||||
|
| DELETE | `/api/mail/messages/{id}/link-task/{taskId}` | ROLE_USER | Supprimer le lien |
|
||||||
|
| GET | `/api/tasks/{id}/mails` | ROLE_USER | Mails liés à une tâche |
|
||||||
|
| GET | `/api/mail/attachments/{id}` | ROLE_USER | Télécharger une pièce jointe |
|
||||||
|
| POST | `/api/mail/sync` | ROLE_USER | Déclencher sync async (Messenger) |
|
||||||
|
|
||||||
|
Tous les endpoints `/api/mail/*` refusent explicitement `ROLE_CLIENT`.
|
||||||
|
|
||||||
|
## Sécurité
|
||||||
|
|
||||||
|
- ROLE_CLIENT exclusif : accès refusé à tous les endpoints mail et à la page `/mail`
|
||||||
|
- Le sidebar "Messagerie" est masqué pour les ROLE_CLIENT
|
||||||
|
- Password IMAP chiffré via libsodium secretbox (env `ENCRYPTION_KEY`)
|
||||||
|
- Corps de mail sanitisés via DOMPurify (`sanitizeMailHtml.ts`) — script/iframe/object/embed/on*/javascript: bloqués
|
||||||
|
- Pixels tracking distants (img src http) remplacés par placeholder
|
||||||
|
- Aucun body, password ou contenu de pièce jointe dans les logs
|
||||||
|
|
||||||
|
## Dépendances
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- `webklex/php-imap` : client IMAP PHP
|
||||||
|
- `symfony/lock` : Symfony Lock pour éviter les syncs parallèles
|
||||||
|
- `symfony/messenger` : dispatch asynchrone `MailSyncRequested`
|
||||||
|
- `libsodium` (ext PHP) : chiffrement du password IMAP
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- `dompurify` + `@types/dompurify` : sanitization HTML des corps de mail
|
||||||
|
|
||||||
|
## Fichiers clés
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- `src/Entity/MailConfiguration.php` — entité singleton (credentials, enabled)
|
||||||
|
- `src/Entity/MailFolder.php` — dossier IMAP synced
|
||||||
|
- `src/Entity/MailMessage.php` — message IMAP synced (headers, flags)
|
||||||
|
- `src/Entity/TaskMailLink.php` — lien tâche ↔ mail
|
||||||
|
- `src/Mail/ImapMailProvider.php` — implémentation IMAP (webklex)
|
||||||
|
- `src/Service/MailSyncService.php` — algorithme de sync (UID FETCH, resync flags)
|
||||||
|
- `src/Controller/Mail/` — controllers custom (test, folders, messages, sync)
|
||||||
|
- `src/State/Mail/` — providers/processors API Platform (configuration)
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- `frontend/pages/mail.vue` — page principale 3 colonnes
|
||||||
|
- `frontend/components/mail/` — MailFolderTree, MailMessageList, MailMessageViewer, MailRefreshButton
|
||||||
|
- `frontend/components/admin/AdminMailTab.vue` — onglet config admin
|
||||||
|
- `frontend/stores/mail.ts` — store Pinia (folders, messages, polling)
|
||||||
|
- `frontend/services/mail.ts` — service API (toutes les méthodes)
|
||||||
|
- `frontend/services/dto/mail.ts` — types TypeScript
|
||||||
|
- `frontend/utils/sanitizeMailHtml.ts` — DOMPurify wrapper
|
||||||
|
|
||||||
|
## Synchronisation cron
|
||||||
|
|
||||||
|
Voir `docs/mail-cron-setup.md` pour la configuration détaillée.
|
||||||
|
|
||||||
|
Résumé :
|
||||||
|
```bash
|
||||||
|
# Cron OS (toutes les 10 min)
|
||||||
|
*/10 * * * * cd /path/to/Lesstime && make mail-sync >> /var/log/lesstime-mail-sync.log 2>&1
|
||||||
|
|
||||||
|
# Commandes Makefile
|
||||||
|
make mail-sync # Sync complète
|
||||||
|
make mail-sync FOLDER=INBOX # Sync d'un dossier
|
||||||
|
make mail-sync DRYRUN=1 # Simulation sans écriture
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration admin
|
||||||
|
|
||||||
|
1. Aller sur `/admin` → onglet "Mail"
|
||||||
|
2. Renseigner les credentials IMAP/SMTP (OVH : `ssl0.ovh.net`, port 993/465, SSL)
|
||||||
|
3. Cliquer "Tester la connexion"
|
||||||
|
4. Activer la synchronisation → Enregistrer
|
||||||
|
5. Configurer le cron OS
|
||||||
|
|
||||||
|
## Variables d'environnement
|
||||||
|
|
||||||
|
| Variable | Description | Obligatoire |
|
||||||
|
|----------|-------------|-------------|
|
||||||
|
| `ENCRYPTION_KEY` | Clé hex 32 bytes libsodium pour chiffrer le password IMAP | Oui |
|
||||||
|
| `LOCK_DSN` | DSN Symfony Lock (défaut: `flock`) | Non |
|
||||||
|
| `MESSENGER_TRANSPORT_DSN` | Transport Messenger pour sync async | Recommandé (prod) |
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6c — Vérifier `make mail-sync` dans le README
|
||||||
|
|
||||||
|
- [ ] Ouvrir `README.md` à la racine de Lesstime
|
||||||
|
- [ ] Vérifier si une section mail ou une mention de `make mail-sync` existe déjà
|
||||||
|
- [ ] Si absente : ajouter dans la section des commandes Makefile une ligne documentant `make mail-sync` avec la description courte (cf. le commentaire déjà présent dans le makefile)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7 : Vérifications sécurité finales
|
||||||
|
|
||||||
|
### Étapes
|
||||||
|
|
||||||
|
- [ ] Ouvrir `frontend/utils/sanitizeMailHtml.ts` — vérifier la config DOMPurify :
|
||||||
|
- `FORBID_TAGS` doit inclure : `script`, `iframe`, `object`, `embed`, `form`, `input`
|
||||||
|
- `FORBID_ATTR` doit inclure tous les handlers `on*` + `javascript:` dans `href`/`src`
|
||||||
|
- Les `<img src="http(s)://...">` distants sont remplacés par un placeholder (pas juste supprimés)
|
||||||
|
- Si manquant, noter la correction mais ne pas modifier (la correction est documentée ici pour le codeur)
|
||||||
|
- [ ] Test injection XSS manuel (dans la console browser, sur la page `/mail`) :
|
||||||
|
```js
|
||||||
|
import('/utils/sanitizeMailHtml').then(m => {
|
||||||
|
console.log(m.sanitizeMailHtml('<script>alert(1)</script><img src=x onerror=alert(2)><iframe src="javascript:alert(3)"></iframe>'))
|
||||||
|
})
|
||||||
|
```
|
||||||
|
Résultat attendu : chaîne sans `<script>`, sans `onerror`, sans `<iframe>`
|
||||||
|
- [ ] Grep logs — confirmer aucun body/password/attachment dans les logs :
|
||||||
|
```bash
|
||||||
|
grep -rn "bodyHtml\|bodyText\|password\|attachment.*content" src/Mail/ src/Service/MailSyncService.php src/Controller/Mail/ --include="*.php"
|
||||||
|
```
|
||||||
|
Vérifier que les occurrences trouvées sont uniquement des définitions de propriétés, jamais passées à un logger
|
||||||
|
- [ ] Vérifier que `GET /api/mail/configuration` ne retourne jamais de champ `password` dans la réponse JSON (tester avec `curl -s http://localhost:8082/api/mail/configuration -H "Cookie: BEARER=..."` ou équivalent)
|
||||||
|
- [ ] Vérifier que `POST /api/mail/folders` avec un cookie ROLE_CLIENT retourne bien 403
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8 : QA passe end-to-end
|
||||||
|
|
||||||
|
### Étapes
|
||||||
|
|
||||||
|
- [ ] `make test` → 0 failure, 0 error
|
||||||
|
- [ ] `make php-cs-fixer-allow-risky` → idempotent (0 fichier modifié)
|
||||||
|
- [ ] `cd frontend && npx tsc --noEmit` → 0 erreur TypeScript
|
||||||
|
- [ ] `make dev-nuxt` → démarrage OK, 0 erreur console browser au load de `/mail`
|
||||||
|
- [ ] **Workflow admin :**
|
||||||
|
- Se connecter en admin
|
||||||
|
- Aller sur `/admin` → onglet "Mail"
|
||||||
|
- Renseigner `imapHost = ssl0.ovh.net`, `imapPort = 993`, `imapEncryption = ssl`, `username = test@example.com`, `password = test`
|
||||||
|
- Cliquer "Tester la connexion" → résultat affiché (succès ou échec selon config réelle)
|
||||||
|
- Enregistrer → toast "Configuration enregistrée"
|
||||||
|
- Rechargement de la page → les champs sont pré-remplis, indicateur "Mot de passe déjà configuré" visible
|
||||||
|
- [ ] **Workflow sidebar :**
|
||||||
|
- Se connecter en ROLE_USER
|
||||||
|
- Vérifier que le lien "Messagerie" est visible dans la sidebar
|
||||||
|
- Vérifier le badge si `globalUnreadCount > 0`
|
||||||
|
- Se connecter en ROLE_CLIENT → vérifier l'absence du lien sidebar
|
||||||
|
- [ ] **Workflow polling :**
|
||||||
|
- Ouvrir les DevTools → Network → filtrer sur `mail/folders`
|
||||||
|
- Rester sur une page 90s → exactement 3 appels (1 immédiat + 2 toutes les 30s)
|
||||||
|
- Naviguer entre `/mail` et `/my-tasks` → pas de rafale, pas de duplication du polling
|
||||||
|
- [ ] **Workflow complet mail → tâche (régression Phase 6) :**
|
||||||
|
- Ouvrir un mail dans `/mail`
|
||||||
|
- Cliquer "Créer tâche" → modal → sélectionner projet → créer
|
||||||
|
- Tâche apparaît dans `/my-tasks` avec le mail lié
|
||||||
|
- Depuis le TaskDrawer de la tâche → onglet "Mails" → mail visible → cliquer → redirection `/mail?messageId=X`
|
||||||
|
- [ ] **Simulation sync :**
|
||||||
|
- `make mail-sync DRYRUN=1` → commande retourne 0, pas d'erreur Symfony
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9 : Cleanup final
|
||||||
|
|
||||||
|
### Étapes
|
||||||
|
|
||||||
|
- [ ] Grep debug dans tous les fichiers mail frontend :
|
||||||
|
```bash
|
||||||
|
grep -rn "console\.log\|console\.warn\|console\.error\|debugger" frontend/components/mail/ frontend/components/admin/AdminMailTab.vue frontend/stores/mail.ts frontend/services/mail.ts frontend/utils/sanitizeMailHtml.ts
|
||||||
|
```
|
||||||
|
Supprimer toute occurrence (sauf `console.error` intentionnel avec commentaire explicatif)
|
||||||
|
- [ ] Grep TODO/FIXME/HACK :
|
||||||
|
```bash
|
||||||
|
grep -rn "TODO\|FIXME\|HACK\|XXX" frontend/components/mail/ frontend/components/admin/AdminMailTab.vue frontend/stores/mail.ts frontend/services/mail.ts
|
||||||
|
```
|
||||||
|
Résoudre ou supprimer chaque occurrence
|
||||||
|
- [ ] Vérifier qu'aucun import inutilisé ne traîne dans `AdminMailTab.vue` et les fichiers modifiés dans `layouts/default.vue`
|
||||||
|
- [ ] `cd frontend && npx tsc --noEmit` → toujours 0 erreur après cleanup
|
||||||
|
- [ ] Si des modifications ont été faites depuis le dernier commit Phase 6, créer un commit final :
|
||||||
|
```
|
||||||
|
feat(mail) : Phase 7 — admin config tab, sidebar badge, polling lifecycle
|
||||||
|
docs(mail) : documentation intégration mail complète
|
||||||
|
```
|
||||||
|
(deux commits séparés si les changements sont distincts)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critères d'acceptation (Phase 7 complète)
|
||||||
|
|
||||||
|
- [ ] Admin peut accéder à `/admin` → onglet "Mail" → configurer IMAP/SMTP → tester → activer
|
||||||
|
- [ ] Le sidebar affiche un badge unread actualisé toutes les 30s pour ROLE_USER/ROLE_ADMIN
|
||||||
|
- [ ] Le sidebar "Messagerie" est invisible pour ROLE_CLIENT exclusif
|
||||||
|
- [ ] `make test` vert
|
||||||
|
- [ ] `npx tsc --noEmit` 0 erreur
|
||||||
|
- [ ] 0 warning console browser au chargement
|
||||||
|
- [ ] 0 ERROR PHP dans `make logs-dev` pendant le workflow normal
|
||||||
|
- [ ] `docs/mail-integration.md` complet et accessible
|
||||||
|
- [ ] `docs/mail-cron-setup.md` enrichi avec checklist prod et rappels sécurité
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dépendances
|
||||||
|
|
||||||
|
- **Phase 5** (store `useMailStore` avec `startPolling`/`stopPolling` + page `/mail`) — DONE
|
||||||
|
- **Phase 6** (intégration tâches) — DONE
|
||||||
|
- **Phase 3** (endpoints `/api/mail/configuration` GET/PATCH/test, ROLE_CLIENT refusé) — DONE
|
||||||
|
- **Phase 4** (services `getConfiguration`, `updateConfiguration`, `testConfiguration`, DTOs) — DONE
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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`.
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
# Workflows de statuts par projet (Kanban custom)
|
||||||
|
|
||||||
|
**Date** : 2026-05-19
|
||||||
|
**Branche** : `feat/project-workflows`
|
||||||
|
**Statut** : design validé (2026-05-19, par Matthieu), en attente de plan d'implémentation
|
||||||
|
|
||||||
|
## Reprise sur un autre poste
|
||||||
|
|
||||||
|
> **Pour le prochain Claude qui ouvre cette branche :**
|
||||||
|
>
|
||||||
|
> 1. Branche `feat/project-workflows` checkout-ée, basée sur `develop` (commit `5585fa7` à l'origine).
|
||||||
|
> 2. **Ce qui est fait** : design validé avec Matthieu et committé (ce fichier).
|
||||||
|
> 3. **Aucun code applicatif n'a encore été écrit.**
|
||||||
|
> 4. **Prochaine étape** : invoquer la skill `superpowers:writing-plans` pour transformer ce design en plan d'implémentation détaillé (découpage en tickets ordonnés, dépendances, estimations).
|
||||||
|
> 5. **Validations Matthieu (2026-05-19)** :
|
||||||
|
> - Hors scope (§8) → MCP `switch-project-workflow` **rapatrié dans la V1** (cf. §6).
|
||||||
|
> - Fallback `in_progress` pour statuts non-mappables → **abandonné**. Seuls les 5 statuts standards existent ; la migration M2 échoue explicitement si elle rencontre autre chose.
|
||||||
|
> - Suppression d'`AdminStatusTab` → **OK**.
|
||||||
|
> - Ordre des étapes de livraison (§10) → **OK**.
|
||||||
|
> 6. **Time tracking** : créer un nouveau timer Lesstime au reprise (projet=5 Lesstime, tags=[3 Backend, 9 Gestion projet]).
|
||||||
|
> 7. **Fichiers déjà modifiés sur develop (orphelins, pas liés à cette feature)** à ne PAS toucher : `.mcp.json`, `config/reference.php`, `frontend/package-lock.json`, `frontend/pages/profile.vue`.
|
||||||
|
|
||||||
|
## 1. Contexte et besoin
|
||||||
|
|
||||||
|
Aujourd'hui les `TaskStatus` sont globaux : tous les projets partagent le même jeu de 5 statuts (À faire / En cours / Bloqué / En attente de validation / Terminé). Pour les gros projets de dev, on veut pouvoir définir un kanban plus riche (ex : Backlog / To Do / In Dev / Code Review / QA / Blocked / Ready to deploy / Done) sans imposer ce détail aux projets simples.
|
||||||
|
|
||||||
|
**Objectif** : permettre à chaque projet d'avoir son propre jeu de colonnes kanban, via des **templates de workflows réutilisables** définis en admin et assignés à un projet, sans casser les projets existants ni les vues transverses (`my-tasks`, time-tracking, dashboards, MCP).
|
||||||
|
|
||||||
|
## 2. Modèle de données
|
||||||
|
|
||||||
|
### Nouvelle entité : `Workflow`
|
||||||
|
|
||||||
|
```
|
||||||
|
Workflow
|
||||||
|
- id int, PK
|
||||||
|
- name string(255), unique
|
||||||
|
- isDefault bool (un seul = true ; assigné aux projets sans workflow explicite ; unicité garantie par un listener Doctrine PrePersist/PreUpdate)
|
||||||
|
- position int (pour l'ordre dans l'admin)
|
||||||
|
- statuses OneToMany → TaskStatus (inverse côté Workflow)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modifications : `TaskStatus`
|
||||||
|
|
||||||
|
```
|
||||||
|
TaskStatus
|
||||||
|
+ workflow_id int, FK → Workflow, NOT NULL, onDelete=CASCADE
|
||||||
|
+ category string, enum PHP : 'todo' | 'in_progress' | 'blocked' | 'review' | 'done', NOT NULL
|
||||||
|
~ position devient relatif au workflow (idéalement contrainte unique (workflow_id, position))
|
||||||
|
- isFinal conservé tel quel — distinct de category='done' (permet un statut "Annulé" final ≠ done)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modifications : `Project`
|
||||||
|
|
||||||
|
```
|
||||||
|
Project
|
||||||
|
+ workflow_id int, FK → Workflow, NOT NULL, onDelete=RESTRICT
|
||||||
|
```
|
||||||
|
|
||||||
|
### Choix de design
|
||||||
|
|
||||||
|
- **Pas de partage de statuts entre workflows** : chaque workflow a SES PROPRES rows `TaskStatus`. "À faire" du workflow Standard ≠ "À faire" de Dev Kanban (IDs et couleurs distincts). Évite les bugs de couplage, simplifie le mapping lors du switch.
|
||||||
|
- **`category` obligatoire** : pivot pour les vues transverses + mapping auto lors du switch. 5 valeurs : `todo`, `in_progress`, `blocked`, `review`, `done`.
|
||||||
|
- **Plusieurs statuts peuvent partager la même catégorie** dans un workflow (ex : 3 statuts en `review` dans Dev Kanban). La catégorie n'est pas une contrainte, juste un bucket de regroupement.
|
||||||
|
- **`onDelete=RESTRICT` sur `Project.workflow_id`** : un workflow ne peut pas être supprimé s'il a au moins un projet attaché. Protection à 3 niveaux (DB / API / UI).
|
||||||
|
- **Suppression de TaskStatus** : reste protégée comme aujourd'hui via le flow `ConfirmDeleteStatusModal` (réassignation des tâches à un autre statut ou null).
|
||||||
|
|
||||||
|
## 3. Migrations BDD
|
||||||
|
|
||||||
|
Trois migrations Doctrine successives :
|
||||||
|
|
||||||
|
**M1 — `create_workflow_table`**
|
||||||
|
- Crée la table `workflow` (id, name, is_default, position)
|
||||||
|
- Insère le workflow par défaut `Standard` (is_default=true, position=0)
|
||||||
|
|
||||||
|
**M2 — `add_workflow_to_task_status`**
|
||||||
|
- Ajoute `task_status.workflow_id` nullable + `task_status.category` nullable
|
||||||
|
- `UPDATE task_status SET workflow_id = <id Standard>` pour toutes les lignes existantes
|
||||||
|
- Backfill catégories (uniquement les 5 statuts standards existants — confirmé avec Matthieu 2026-05-19) :
|
||||||
|
- "À faire" → `todo`
|
||||||
|
- "En cours" → `in_progress`
|
||||||
|
- "Bloqué" → `blocked`
|
||||||
|
- "En attente de validation" → `review`
|
||||||
|
- "Terminé" → `done`
|
||||||
|
- La migration **échoue** (exception) si elle rencontre un label non listé → garde-fou explicite contre toute prod qui aurait dérivé.
|
||||||
|
- Passe les 2 colonnes en `NOT NULL`
|
||||||
|
|
||||||
|
**M3 — `add_workflow_to_project`**
|
||||||
|
- Ajoute `project.workflow_id` nullable
|
||||||
|
- `UPDATE project SET workflow_id = <id Standard>` pour tous les projets existants
|
||||||
|
- Passe en `NOT NULL` avec FK `ON DELETE RESTRICT`
|
||||||
|
|
||||||
|
## 4. Backend (Symfony / API Platform)
|
||||||
|
|
||||||
|
### Entités
|
||||||
|
|
||||||
|
- `App\Entity\Workflow` — nouvelle entité, ApiResource avec `ROLE_ADMIN` pour Post/Patch/Delete
|
||||||
|
- `App\Enum\StatusCategory` — enum PHP avec les 5 valeurs canoniques
|
||||||
|
- `App\Entity\TaskStatus` — ajout des propriétés `workflow` (ManyToOne) et `category` (StatusCategory)
|
||||||
|
- `App\Entity\Project` — ajout de la propriété `workflow` (ManyToOne, requise)
|
||||||
|
|
||||||
|
### Sérialisation
|
||||||
|
|
||||||
|
- Groupe `workflow:read` pour l'API admin
|
||||||
|
- `task_status:read` ajoute `workflow` et `category`
|
||||||
|
- `project:read` embarque le workflow (ou son IRI) — décision à arbitrer dans le plan d'impl (vraisemblablement embarqué pour limiter les round-trips)
|
||||||
|
|
||||||
|
### Endpoint dédié au switch
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/projects/{id}/switch-workflow
|
||||||
|
Body: {
|
||||||
|
workflowId: int,
|
||||||
|
mapping: { "<sourceStatusId>": <targetStatusId> | null, ... }
|
||||||
|
}
|
||||||
|
Security: ROLE_ADMIN
|
||||||
|
```
|
||||||
|
|
||||||
|
**Processor** : `App\State\SwitchProjectWorkflowProcessor`
|
||||||
|
1. Valide qu'il y a une entrée de mapping pour chaque `statusId` actuellement référencé par les tâches du projet (sinon 422 avec liste des sources manquantes)
|
||||||
|
2. Valide que chaque target appartient bien au workflow cible (ou est `null`)
|
||||||
|
3. Transaction unique :
|
||||||
|
- Pour chaque entrée du mapping : `UPDATE task SET status_id = <target> WHERE project_id = X AND status_id = <source>`
|
||||||
|
- `UPDATE project SET workflow_id = <new>`
|
||||||
|
4. Retourne `{ project, migratedTaskCount }`
|
||||||
|
|
||||||
|
### Validation cross-entity
|
||||||
|
|
||||||
|
- Sur `Task` (Post/Patch) : si `status` fourni, valider que `status.workflow === task.project.workflow`. Sinon 422 `"Status does not belong to this project's workflow"`.
|
||||||
|
|
||||||
|
### Suppression d'un Workflow
|
||||||
|
|
||||||
|
- `WorkflowProcessor` (custom Delete) : compte les projets liés ; si > 0, renvoie 409 Conflict avec `{ linkedProjectIds: [...], message: "Workflow used by N project(s)" }`
|
||||||
|
|
||||||
|
## 5. Frontend (Nuxt / Vue)
|
||||||
|
|
||||||
|
### Nouveaux fichiers
|
||||||
|
|
||||||
|
- `frontend/services/workflows.ts` — service API CRUD
|
||||||
|
- `frontend/services/dto/workflow.ts` — type TS
|
||||||
|
- `frontend/components/admin/AdminWorkflowTab.vue` — nouvel onglet dans `/admin`
|
||||||
|
- `frontend/components/admin/WorkflowDrawer.vue` — drawer création/édition workflow (nom + liste éditable des statuts avec leur catégorie)
|
||||||
|
- `frontend/components/project/ProjectWorkflowSwitchModal.vue` — modal de migration
|
||||||
|
|
||||||
|
### Modifications
|
||||||
|
|
||||||
|
- `frontend/components/admin/AdminStatusTab.vue` :
|
||||||
|
- **Supprimé.** Toute la gestion des statuts passe par l'onglet Workflows (un workflow = nom + sa liste de statuts éditable inline). Évite la confusion "où je crée un statut ?".
|
||||||
|
- `frontend/components/project/ProjectDrawer.vue` :
|
||||||
|
- Affiche le workflow actuel
|
||||||
|
- Bouton "Changer de workflow" qui ouvre `ProjectWorkflowSwitchModal`
|
||||||
|
- `frontend/pages/projects/[id]/index.vue` :
|
||||||
|
- Charge `project.workflow.statuses` au lieu de `statusService.getAll()`
|
||||||
|
- Le kanban a les colonnes du workflow du projet
|
||||||
|
- `frontend/pages/projects/[id]/archives.vue` :
|
||||||
|
- Filtre statut limité au workflow du projet
|
||||||
|
- `frontend/pages/my-tasks.vue` :
|
||||||
|
- **Kanban groupé par catégorie** : 5 colonnes (Todo / In Progress / Blocked / Review / Done)
|
||||||
|
- Chaque card affiche le statut spécifique en badge
|
||||||
|
- Vue liste : pas de changement
|
||||||
|
- `frontend/components/task/TaskModal.vue` :
|
||||||
|
- Reçoit `:statuses` filtrés par workflow du projet via les pages parentes (déjà la pattern actuelle)
|
||||||
|
- `frontend/components/task/TaskBulkActions.vue` :
|
||||||
|
- Dropdown statut filtré au workflow du projet de la tâche sélectionnée
|
||||||
|
- Si tâches multi-projets : bouton "Changer le statut" désactivé avec tooltip explicatif
|
||||||
|
|
||||||
|
### `ProjectWorkflowSwitchModal.vue` — détails UX
|
||||||
|
|
||||||
|
- Étape 1 : `MalioSelect` des workflows disponibles (sauf le workflow actuel)
|
||||||
|
- Étape 2 (après sélection) : tableau de mapping
|
||||||
|
- Une ligne par statut source effectivement utilisé par les tâches du projet (count > 0) + une ligne "Backlog" si des tâches `status=null`
|
||||||
|
- Colonnes : Source (label + badge catégorie) → Cible (`MalioSelect` des statuts du workflow cible, pré-rempli intelligemment) → Nb de tâches concernées
|
||||||
|
- Pré-remplissage : pour chaque source, on cherche dans le workflow cible le statut de **même catégorie** avec la plus petite `position`. Si aucune correspondance par catégorie, l'utilisateur doit choisir manuellement.
|
||||||
|
- Option "Mapper vers le backlog" sur chaque ligne (= cible `null`)
|
||||||
|
- Footer :
|
||||||
|
- Bouton "Confirmer la migration" désactivé tant qu'au moins un mapping est manquant
|
||||||
|
- Toast au succès : "N tâches migrées, projet sur workflow '<nom>'"
|
||||||
|
|
||||||
|
## 6. MCP
|
||||||
|
|
||||||
|
| Tool | Changement |
|
||||||
|
|---|---|
|
||||||
|
| `list-statuses` | Ajout d'un param optionnel `projectId?: int`. Si fourni → renvoie les statuts du workflow du projet. Sinon → renvoie tous les statuts avec `workflowId` et `category` ajoutés. Description mise à jour pour mentionner les workflows. |
|
||||||
|
| `list-workflows` (nouveau) | Liste tous les workflows avec leurs statuts groupés (`{ id, name, isDefault, statuses: [...] }`). |
|
||||||
|
| `create-task` / `update-task` | La validation backend (côté entité Task) rejette automatiquement un `status` n'appartenant pas au workflow du projet. Documenter dans la description du tool. |
|
||||||
|
| `switch-project-workflow` (nouveau, ROLE_ADMIN) | Wrappe l'endpoint `POST /api/projects/{id}/switch-workflow`. Params : `projectId`, `workflowId`, `mapping: { [sourceStatusId]: targetStatusId \| null }`. Renvoie `{ migratedTaskCount }`. Mêmes validations que l'endpoint HTTP. |
|
||||||
|
|
||||||
|
## 7. Permissions
|
||||||
|
|
||||||
|
| Action | Rôle requis |
|
||||||
|
|---|---|
|
||||||
|
| Lire les workflows et leurs statuts | `ROLE_USER` |
|
||||||
|
| Créer / éditer / supprimer un workflow | `ROLE_ADMIN` |
|
||||||
|
| Créer / éditer / supprimer un statut | `ROLE_ADMIN` |
|
||||||
|
| Changer le workflow d'un projet (switch) | `ROLE_ADMIN` |
|
||||||
|
|
||||||
|
## 8. Hors scope (YAGNI explicites)
|
||||||
|
|
||||||
|
- **Workflows en read-only intégrés** (ex : "Scrum officiel" non éditable) — pas besoin pour l'instant
|
||||||
|
- **Transitions autorisées** entre statuts (ex : impossible de passer de "Backlog" directement à "Done") — pas demandé, ajouterait beaucoup de complexité
|
||||||
|
- **Versioning des workflows** (historique des modifs) — pas demandé
|
||||||
|
- **Workflow par groupe de tâches** (TaskGroup avec son propre workflow dans un projet) — pas demandé
|
||||||
|
|
||||||
|
## 9. Risques et limites
|
||||||
|
|
||||||
|
- **Migration M2 (backfill catégories)** : la migration échoue si elle rencontre un label de statut autre que les 5 standards. Si la prod a dérivé entre temps, ajouter le mapping manuellement à la migration avant déploiement.
|
||||||
|
- **`my-tasks` kanban groupé** : avec des projets multi-workflows, l'utilisateur voit une card "In Dev" et une card "En cours" dans la même colonne `in_progress`. Le badge statut sur la card doit rester lisible (taille suffisante, couleur du statut).
|
||||||
|
- **Filtre statut dans `my-tasks` (vue liste)** : aujourd'hui pas de filtre statut côté `my-tasks` (cf. code), donc rien à adapter. Si on en ajoute un plus tard, il faudra qu'il propose les catégories plutôt que les statuts spécifiques.
|
||||||
|
- **Sélection multi-projets dans `TaskBulkActions`** : le bouton "Changer de statut" se désactive ; à valider que le reste du bulk reste utilisable (assignee, priorité, effort, group — eux restent globaux ou per-project comme aujourd'hui).
|
||||||
|
|
||||||
|
## 10. Étapes de livraison suggérées
|
||||||
|
|
||||||
|
1. Migrations BDD + entité `Workflow` + enum `StatusCategory` + adaptations entités `TaskStatus` et `Project`
|
||||||
|
2. Validation cross-entity sur `Task` + sérialisation des nouvelles propriétés
|
||||||
|
3. Endpoint `POST /api/projects/{id}/switch-workflow` + processor
|
||||||
|
4. Service frontend `workflows` + types DTO
|
||||||
|
5. UI admin : `AdminWorkflowTab` + `WorkflowDrawer`
|
||||||
|
6. Adaptation `projects/[id]/index.vue` (kanban filtré par workflow)
|
||||||
|
7. Adaptation `my-tasks.vue` (kanban groupé par catégorie)
|
||||||
|
8. `ProjectWorkflowSwitchModal` + intégration dans `ProjectDrawer`
|
||||||
|
9. Adaptation `TaskBulkActions` et autres écrans transverses
|
||||||
|
10. MCP : modification `list-statuses` + nouveaux `list-workflows` et `switch-project-workflow` + mise à jour des descriptions
|
||||||
|
11. Tests : PHPUnit pour le processor + validation cross-entity ; tests fonctionnels du switch (HTTP + MCP)
|
||||||
|
|
||||||
|
Le découpage exact (tickets, ordre, dépendances) sera fait dans le plan d'implémentation.
|
||||||
@@ -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.
|
||||||
@@ -10,21 +10,17 @@
|
|||||||
input-class="w-full"
|
input-class="w-full"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MalioInputText
|
<MalioInputPassword
|
||||||
v-model="form.tokenId"
|
v-model="form.tokenId"
|
||||||
:label="$t('bookstack.settings.tokenId')"
|
:label="$t('bookstack.settings.tokenId')"
|
||||||
:placeholder="$t('bookstack.settings.tokenIdPlaceholder')"
|
|
||||||
input-class="w-full"
|
input-class="w-full"
|
||||||
type="password"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<MalioInputText
|
<MalioInputPassword
|
||||||
v-model="form.tokenSecret"
|
v-model="form.tokenSecret"
|
||||||
:label="$t('bookstack.settings.tokenSecret')"
|
:label="$t('bookstack.settings.tokenSecret')"
|
||||||
:placeholder="$t('bookstack.settings.tokenSecretPlaceholder')"
|
|
||||||
input-class="w-full"
|
input-class="w-full"
|
||||||
type="password"
|
|
||||||
/>
|
/>
|
||||||
<p v-if="hasToken && !form.tokenId && !form.tokenSecret" class="mt-1 text-xs text-green-600">
|
<p v-if="hasToken && !form.tokenId && !form.tokenSecret" class="mt-1 text-xs text-green-600">
|
||||||
{{ $t('bookstack.settings.tokenConfigured') }}
|
{{ $t('bookstack.settings.tokenConfigured') }}
|
||||||
|
|||||||
@@ -11,12 +11,10 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<MalioInputText
|
<MalioInputPassword
|
||||||
v-model="form.token"
|
v-model="form.token"
|
||||||
:label="$t('gitea.settings.token')"
|
:label="$t('gitea.settings.token')"
|
||||||
:placeholder="$t('gitea.settings.tokenPlaceholder')"
|
|
||||||
input-class="w-full"
|
input-class="w-full"
|
||||||
type="password"
|
|
||||||
/>
|
/>
|
||||||
<p v-if="hasToken && !form.token" class="mt-1 text-xs text-green-600">
|
<p v-if="hasToken && !form.token" class="mt-1 text-xs text-green-600">
|
||||||
{{ $t('gitea.settings.tokenConfigured') }}
|
{{ $t('gitea.settings.tokenConfigured') }}
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-bold text-neutral-900">{{ $t('mail.admin.title') }}</h2>
|
||||||
|
|
||||||
|
<form class="mt-6 max-w-lg space-y-6" @submit.prevent="handleSave">
|
||||||
|
<!-- Section IMAP (réception) -->
|
||||||
|
<fieldset class="space-y-4">
|
||||||
|
<legend class="text-sm font-bold text-neutral-700">{{ $t('mail.admin.imapSection') }}</legend>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.imapHost"
|
||||||
|
:label="$t('mail.admin.host')"
|
||||||
|
input-class="w-full"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-neutral-500">{{ $t('mail.admin.ovhDefaultsHelp') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-neutral-700">{{ $t('mail.admin.port') }}</label>
|
||||||
|
<input
|
||||||
|
v-model.number="form.imapPort"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
class="mt-1 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-neutral-700">{{ $t('mail.admin.encryption') }}</label>
|
||||||
|
<select
|
||||||
|
v-model="form.imapEncryption"
|
||||||
|
class="mt-1 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||||
|
>
|
||||||
|
<option value="ssl">SSL</option>
|
||||||
|
<option value="tls">TLS</option>
|
||||||
|
<option value="none">Aucun</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- Section SMTP (envoi) -->
|
||||||
|
<fieldset class="space-y-4">
|
||||||
|
<legend class="text-sm font-bold text-neutral-700">{{ $t('mail.admin.smtpSection') }}</legend>
|
||||||
|
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.smtpHost"
|
||||||
|
:label="$t('mail.admin.host')"
|
||||||
|
input-class="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-neutral-700">{{ $t('mail.admin.port') }}</label>
|
||||||
|
<input
|
||||||
|
v-model.number="form.smtpPort"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
class="mt-1 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-semibold text-neutral-700">{{ $t('mail.admin.encryption') }}</label>
|
||||||
|
<select
|
||||||
|
v-model="form.smtpEncryption"
|
||||||
|
class="mt-1 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||||
|
>
|
||||||
|
<option value="ssl">SSL</option>
|
||||||
|
<option value="tls">TLS</option>
|
||||||
|
<option value="none">Aucun</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- Credentials -->
|
||||||
|
<fieldset class="space-y-4">
|
||||||
|
<legend class="text-sm font-bold text-neutral-700">{{ $t('mail.admin.username') }}</legend>
|
||||||
|
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.username"
|
||||||
|
:label="$t('mail.admin.username')"
|
||||||
|
input-class="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<MalioInputPassword
|
||||||
|
v-model="form.password"
|
||||||
|
:label="$t('mail.admin.password')"
|
||||||
|
input-class="w-full"
|
||||||
|
/>
|
||||||
|
<p v-if="hasPassword && !form.password" class="mt-1 text-xs text-green-600">
|
||||||
|
{{ $t('mail.admin.passwordSet') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.sentFolderPath"
|
||||||
|
:label="$t('mail.admin.sentFolderPath')"
|
||||||
|
placeholder="Sent Messages"
|
||||||
|
input-class="w-full"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<label class="flex cursor-pointer items-center gap-2">
|
||||||
|
<input v-model="form.enabled" type="checkbox" class="rounded border-neutral-300" />
|
||||||
|
<span class="text-sm">{{ $t('mail.admin.enabled') }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<MalioButton
|
||||||
|
:label="$t('mail.admin.save')"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
:disabled="isSaving"
|
||||||
|
@click="handleSave"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="tertiary"
|
||||||
|
:label="$t('mail.admin.test')"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
:disabled="isTesting"
|
||||||
|
@click="handleTest"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="testResult !== null">
|
||||||
|
<p
|
||||||
|
class="text-sm font-medium"
|
||||||
|
:class="testResult ? 'text-green-600' : 'text-red-600'"
|
||||||
|
>
|
||||||
|
{{ testResult ? $t('mail.admin.testSuccess') : $t('mail.admin.testFailed') }}
|
||||||
|
</p>
|
||||||
|
<p v-if="testResult === false && testError" class="mt-1 text-xs text-neutral-500">
|
||||||
|
{{ testError }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useMailService } from '~/services/mail'
|
||||||
|
|
||||||
|
const { getConfiguration, updateConfiguration, testConfiguration } = useMailService()
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
protocol: 'imap',
|
||||||
|
imapHost: '',
|
||||||
|
imapPort: 993,
|
||||||
|
imapEncryption: 'ssl',
|
||||||
|
smtpHost: '',
|
||||||
|
smtpPort: 465,
|
||||||
|
smtpEncryption: 'ssl',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
sentFolderPath: '',
|
||||||
|
enabled: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasPassword = ref<boolean>(false)
|
||||||
|
const isSaving = ref<boolean>(false)
|
||||||
|
const isTesting = ref<boolean>(false)
|
||||||
|
const testResult = ref<boolean | null>(null)
|
||||||
|
const testError = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function loadSettings(): Promise<void> {
|
||||||
|
const config = await getConfiguration()
|
||||||
|
form.protocol = config.protocol ?? 'imap'
|
||||||
|
form.imapHost = config.imapHost ?? ''
|
||||||
|
form.imapPort = config.imapPort ?? 993
|
||||||
|
form.imapEncryption = config.imapEncryption ?? 'ssl'
|
||||||
|
form.smtpHost = config.smtpHost ?? ''
|
||||||
|
form.smtpPort = config.smtpPort ?? 465
|
||||||
|
form.smtpEncryption = config.smtpEncryption ?? 'ssl'
|
||||||
|
form.username = config.username ?? ''
|
||||||
|
form.sentFolderPath = config.sentFolderPath ?? ''
|
||||||
|
form.enabled = config.enabled
|
||||||
|
hasPassword.value = config.hasPassword
|
||||||
|
// password jamais pré-rempli
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave(): Promise<void> {
|
||||||
|
isSaving.value = true
|
||||||
|
testResult.value = null
|
||||||
|
testError.value = null
|
||||||
|
try {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
protocol: form.protocol,
|
||||||
|
imapHost: form.imapHost.trim() || null,
|
||||||
|
imapPort: form.imapPort,
|
||||||
|
imapEncryption: form.imapEncryption,
|
||||||
|
smtpHost: form.smtpHost.trim() || null,
|
||||||
|
smtpPort: form.smtpPort,
|
||||||
|
smtpEncryption: form.smtpEncryption,
|
||||||
|
username: form.username.trim() || null,
|
||||||
|
sentFolderPath: form.sentFolderPath.trim() || null,
|
||||||
|
enabled: form.enabled,
|
||||||
|
}
|
||||||
|
if (form.password) {
|
||||||
|
payload.password = form.password
|
||||||
|
}
|
||||||
|
const result = await updateConfiguration(payload)
|
||||||
|
hasPassword.value = result.hasPassword
|
||||||
|
form.password = ''
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTest(): Promise<void> {
|
||||||
|
isTesting.value = true
|
||||||
|
testResult.value = null
|
||||||
|
testError.value = null
|
||||||
|
try {
|
||||||
|
const result = await testConfiguration()
|
||||||
|
testResult.value = result.ok
|
||||||
|
if (!result.ok && result.error) {
|
||||||
|
testError.value = result.error
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
testResult.value = false
|
||||||
|
} finally {
|
||||||
|
isTesting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadSettings()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h2 class="text-lg font-bold text-neutral-900">Statuts</h2>
|
|
||||||
<MalioButton
|
|
||||||
icon-name="mdi:plus"
|
|
||||||
icon-position="left"
|
|
||||||
button-class="w-auto px-4"
|
|
||||||
label="Ajouter un statut"
|
|
||||||
@click="openCreate"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DataTable
|
|
||||||
:columns="columns"
|
|
||||||
:items="items"
|
|
||||||
:loading="isLoading"
|
|
||||||
empty-message="Aucun statut trouvé."
|
|
||||||
deletable
|
|
||||||
@row-click="openEdit"
|
|
||||||
@delete="requestDelete"
|
|
||||||
>
|
|
||||||
<template #cell-color="{ item }">
|
|
||||||
<span
|
|
||||||
class="inline-block h-6 w-6 rounded-full"
|
|
||||||
:style="{ backgroundColor: item.color }"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</DataTable>
|
|
||||||
|
|
||||||
<TaskStatusDrawer
|
|
||||||
v-model="drawerOpen"
|
|
||||||
:item="selectedItem"
|
|
||||||
@saved="onSaved"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConfirmDeleteStatusModal
|
|
||||||
v-model="confirmModalOpen"
|
|
||||||
:status-label="statusToDelete?.label ?? ''"
|
|
||||||
:task-count="affectedTaskCount"
|
|
||||||
:available-statuses="reassignTargets"
|
|
||||||
@confirm="onConfirmDelete"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { TaskStatus } from '~/services/dto/task-status'
|
|
||||||
import type { Task } from '~/services/dto/task'
|
|
||||||
import { useTaskStatusService } from '~/services/task-statuses'
|
|
||||||
import { useTaskService } from '~/services/tasks'
|
|
||||||
|
|
||||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
|
||||||
|
|
||||||
const columns: DataTableColumn[] = [
|
|
||||||
{ key: 'label', label: 'Libellé', primary: true },
|
|
||||||
{ key: 'color', label: 'Couleur' },
|
|
||||||
{ key: 'position', label: 'Position', class: 'text-neutral-700' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const statusService = useTaskStatusService()
|
|
||||||
const taskService = useTaskService()
|
|
||||||
|
|
||||||
const items = ref<TaskStatus[]>([])
|
|
||||||
const tasks = ref<Task[]>([])
|
|
||||||
const isLoading = ref(true)
|
|
||||||
const drawerOpen = ref(false)
|
|
||||||
const selectedItem = ref<TaskStatus | null>(null)
|
|
||||||
const confirmModalOpen = ref(false)
|
|
||||||
const statusToDelete = ref<TaskStatus | null>(null)
|
|
||||||
|
|
||||||
const affectedTaskCount = computed(() => {
|
|
||||||
if (!statusToDelete.value) return 0
|
|
||||||
return tasks.value.filter(t => t.status?.id === statusToDelete.value!.id).length
|
|
||||||
})
|
|
||||||
|
|
||||||
const reassignTargets = computed(() => {
|
|
||||||
if (!statusToDelete.value) return items.value
|
|
||||||
return items.value.filter(s => s.id !== statusToDelete.value!.id)
|
|
||||||
})
|
|
||||||
|
|
||||||
async function loadItems() {
|
|
||||||
isLoading.value = true
|
|
||||||
try {
|
|
||||||
const [statuses, allTasks] = await Promise.all([
|
|
||||||
statusService.getAll(),
|
|
||||||
taskService.getAll(),
|
|
||||||
])
|
|
||||||
items.value = statuses
|
|
||||||
tasks.value = allTasks
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreate() {
|
|
||||||
selectedItem.value = null
|
|
||||||
drawerOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEdit(item: TaskStatus) {
|
|
||||||
selectedItem.value = item
|
|
||||||
drawerOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requestDelete(item: TaskStatus) {
|
|
||||||
statusToDelete.value = item
|
|
||||||
const count = tasks.value.filter(t => t.status?.id === item.id).length
|
|
||||||
if (count === 0) {
|
|
||||||
await statusService.remove(item.id)
|
|
||||||
await loadItems()
|
|
||||||
} else {
|
|
||||||
confirmModalOpen.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onConfirmDelete(targetStatusId: number | null) {
|
|
||||||
if (!statusToDelete.value) return
|
|
||||||
|
|
||||||
const affectedTasks = tasks.value.filter(t => t.status?.id === statusToDelete.value!.id)
|
|
||||||
const statusIri = targetStatusId ? `/api/task_statuses/${targetStatusId}` : null
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
affectedTasks.map(t => taskService.update(t.id, { status: statusIri }))
|
|
||||||
)
|
|
||||||
|
|
||||||
await statusService.remove(statusToDelete.value.id)
|
|
||||||
confirmModalOpen.value = false
|
|
||||||
statusToDelete.value = null
|
|
||||||
await loadItems()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSaved() {
|
|
||||||
await loadItems()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadItems()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-bold text-neutral-900">{{ $t('workflows.title') }}</h2>
|
||||||
|
<MalioButton
|
||||||
|
icon-name="mdi:plus"
|
||||||
|
icon-position="left"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
:label="$t('workflows.addWorkflow')"
|
||||||
|
@click="openCreate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:columns="columns"
|
||||||
|
:items="items"
|
||||||
|
:loading="isLoading"
|
||||||
|
empty-message="Aucun workflow trouvé."
|
||||||
|
deletable
|
||||||
|
@row-click="openEdit"
|
||||||
|
@delete="requestDelete"
|
||||||
|
>
|
||||||
|
<template #cell-isDefault="{ item }">
|
||||||
|
<span
|
||||||
|
v-if="item.isDefault"
|
||||||
|
class="rounded bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-700"
|
||||||
|
>
|
||||||
|
{{ $t('workflows.isDefault') }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-statusCount="{ item }">
|
||||||
|
{{ item.statuses.length }}
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<WorkflowDrawer
|
||||||
|
v-model="drawerOpen"
|
||||||
|
:item="selectedItem"
|
||||||
|
@saved="onSaved"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Workflow } from '~/services/dto/workflow'
|
||||||
|
import { useWorkflowService } from '~/services/workflows'
|
||||||
|
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const columns: DataTableColumn[] = [
|
||||||
|
{ key: 'name', label: t('workflows.name'), primary: true },
|
||||||
|
{ key: 'isDefault', label: t('workflows.isDefault') },
|
||||||
|
{ key: 'statusCount', label: t('workflows.statuses') },
|
||||||
|
{ key: 'position', label: 'Position' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const workflowService = useWorkflowService()
|
||||||
|
|
||||||
|
const items = ref<Workflow[]>([])
|
||||||
|
const isLoading = ref(true)
|
||||||
|
const drawerOpen = ref(false)
|
||||||
|
const selectedItem = ref<Workflow | null>(null)
|
||||||
|
|
||||||
|
async function loadItems() {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
items.value = await workflowService.getAll()
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
selectedItem.value = null
|
||||||
|
drawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(item: Workflow) {
|
||||||
|
selectedItem.value = item
|
||||||
|
drawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestDelete(item: Workflow) {
|
||||||
|
try {
|
||||||
|
await workflowService.remove(item.id)
|
||||||
|
await loadItems()
|
||||||
|
} catch {
|
||||||
|
// Toast d'erreur déjà émis par useApi
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSaved() {
|
||||||
|
await loadItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadItems()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -22,11 +22,10 @@
|
|||||||
input-class="w-full"
|
input-class="w-full"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<MalioInputText
|
<MalioInputPassword
|
||||||
v-model="form.password"
|
v-model="form.password"
|
||||||
:label="$t('zimbra.settings.password')"
|
:label="$t('zimbra.settings.password')"
|
||||||
input-class="w-full"
|
input-class="w-full"
|
||||||
type="password"
|
|
||||||
/>
|
/>
|
||||||
<p v-if="hasPassword && !form.password" class="mt-1 text-xs text-green-600">
|
<p v-if="hasPassword && !form.password" class="mt-1 text-xs text-green-600">
|
||||||
{{ $t('zimbra.settings.passwordConfigured') }}
|
{{ $t('zimbra.settings.passwordConfigured') }}
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
<template>
|
||||||
|
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('workflows.editWorkflow') : $t('workflows.addWorkflow')">
|
||||||
|
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.name"
|
||||||
|
:label="$t('workflows.name')"
|
||||||
|
input-class="w-full"
|
||||||
|
:error="touched.name && !form.name.trim() ? $t('workflows.name') + ' requis' : ''"
|
||||||
|
@blur="touched.name = true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id="isDefault"
|
||||||
|
v-model="form.isDefault"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<label for="isDefault" class="text-sm font-medium text-neutral-700">
|
||||||
|
{{ $t('workflows.isDefault') }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-bold text-neutral-900">{{ $t('workflows.statuses') }}</h3>
|
||||||
|
<MalioButton
|
||||||
|
type="button"
|
||||||
|
icon-name="mdi:plus"
|
||||||
|
icon-position="left"
|
||||||
|
button-class="w-auto px-3 py-1 text-xs"
|
||||||
|
:label="$t('workflows.addStatus')"
|
||||||
|
@click="addStatus"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex flex-col gap-3">
|
||||||
|
<div
|
||||||
|
v-for="(s, idx) in form.statuses"
|
||||||
|
:key="idx"
|
||||||
|
class="rounded border border-neutral-200 p-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-end gap-2">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="s.label"
|
||||||
|
label="Libellé"
|
||||||
|
input-class="w-full"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
v-model="s.category"
|
||||||
|
:options="categoryOptions"
|
||||||
|
label="Catégorie"
|
||||||
|
group-class="w-48 shrink-0"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="h-10 px-2 text-red-600 hover:text-red-800"
|
||||||
|
aria-label="Supprimer"
|
||||||
|
@click="removeStatus(idx)"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:delete" size="20" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex items-center gap-3">
|
||||||
|
<ColorPicker v-model="s.color" />
|
||||||
|
<label class="ml-auto flex items-center gap-1 text-xs text-neutral-700">
|
||||||
|
<input v-model="s.isFinal" type="checkbox" class="h-3 w-3" />
|
||||||
|
{{ $t('archive.statusFinal') }}
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col text-xs text-neutral-700">
|
||||||
|
Position
|
||||||
|
<input
|
||||||
|
v-model.number="s.position"
|
||||||
|
type="number"
|
||||||
|
class="mt-1 h-9 w-16 rounded border border-neutral-300 px-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex justify-end">
|
||||||
|
<MalioButton
|
||||||
|
label="Enregistrer"
|
||||||
|
button-class="w-auto px-6"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@click="handleSubmit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</MalioDrawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Workflow, StatusCategory } from '~/services/dto/workflow'
|
||||||
|
import { STATUS_CATEGORY_COLOR } from '~/services/dto/workflow'
|
||||||
|
import type { TaskStatusWrite } from '~/services/dto/task-status'
|
||||||
|
import { useWorkflowService } from '~/services/workflows'
|
||||||
|
import { useTaskStatusService } from '~/services/task-statuses'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
item: Workflow | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
(e: 'saved'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isOpen = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: v => emit('update:modelValue', v),
|
||||||
|
})
|
||||||
|
|
||||||
|
const isEditing = computed(() => !!props.item)
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
|
||||||
|
type StatusForm = {
|
||||||
|
id?: number
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
position: number
|
||||||
|
isFinal: boolean
|
||||||
|
category: StatusCategory
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = reactive<{
|
||||||
|
name: string
|
||||||
|
isDefault: boolean
|
||||||
|
statuses: StatusForm[]
|
||||||
|
}>({
|
||||||
|
name: '',
|
||||||
|
isDefault: false,
|
||||||
|
statuses: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const touched = reactive({ name: false })
|
||||||
|
|
||||||
|
const categoryOptions: { value: StatusCategory, label: string }[] = [
|
||||||
|
{ value: 'todo', label: t('workflows.categories.todo') },
|
||||||
|
{ value: 'in_progress', label: t('workflows.categories.in_progress') },
|
||||||
|
{ value: 'blocked', label: t('workflows.categories.blocked') },
|
||||||
|
{ value: 'review', label: t('workflows.categories.review') },
|
||||||
|
{ value: 'done', label: t('workflows.categories.done') },
|
||||||
|
]
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (open) => {
|
||||||
|
if (!open) return
|
||||||
|
if (props.item) {
|
||||||
|
form.name = props.item.name
|
||||||
|
form.isDefault = props.item.isDefault
|
||||||
|
form.statuses = props.item.statuses.map(s => ({
|
||||||
|
id: s.id,
|
||||||
|
label: s.label,
|
||||||
|
color: s.color,
|
||||||
|
position: s.position,
|
||||||
|
isFinal: s.isFinal,
|
||||||
|
category: s.category,
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
form.name = ''
|
||||||
|
form.isDefault = false
|
||||||
|
form.statuses = []
|
||||||
|
}
|
||||||
|
touched.name = false
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => form.statuses.map(s => s.category), (cats, prev) => {
|
||||||
|
if (!prev) return
|
||||||
|
cats.forEach((cat, i) => {
|
||||||
|
const s = form.statuses[i]
|
||||||
|
if (s && cat !== prev[i] && s.color === STATUS_CATEGORY_COLOR[prev[i] as StatusCategory]) {
|
||||||
|
s.color = STATUS_CATEGORY_COLOR[cat as StatusCategory]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function addStatus() {
|
||||||
|
form.statuses.push({
|
||||||
|
label: '',
|
||||||
|
color: STATUS_CATEGORY_COLOR.todo,
|
||||||
|
position: form.statuses.length,
|
||||||
|
isFinal: false,
|
||||||
|
category: 'todo',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeStatus(idx: number) {
|
||||||
|
form.statuses.splice(idx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflowService = useWorkflowService()
|
||||||
|
const statusService = useTaskStatusService()
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
touched.name = true
|
||||||
|
if (!form.name.trim()) return
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
if (isEditing.value && props.item) {
|
||||||
|
await workflowService.update(props.item.id, {
|
||||||
|
name: form.name.trim(),
|
||||||
|
isDefault: form.isDefault,
|
||||||
|
position: props.item.position,
|
||||||
|
})
|
||||||
|
await syncStatuses(props.item)
|
||||||
|
} else {
|
||||||
|
const created = await workflowService.create({
|
||||||
|
name: form.name.trim(),
|
||||||
|
isDefault: form.isDefault,
|
||||||
|
position: 0,
|
||||||
|
})
|
||||||
|
for (const s of form.statuses) {
|
||||||
|
const payload: TaskStatusWrite = {
|
||||||
|
label: s.label,
|
||||||
|
color: s.color,
|
||||||
|
position: s.position,
|
||||||
|
isFinal: s.isFinal,
|
||||||
|
category: s.category,
|
||||||
|
workflow: `/api/workflows/${created.id}`,
|
||||||
|
}
|
||||||
|
await statusService.create(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit('saved')
|
||||||
|
isOpen.value = false
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncStatuses(workflow: Workflow) {
|
||||||
|
const existingIds = new Set(workflow.statuses.map(s => s.id))
|
||||||
|
const keptIds = new Set<number>()
|
||||||
|
|
||||||
|
for (const s of form.statuses) {
|
||||||
|
if (s.id) {
|
||||||
|
keptIds.add(s.id)
|
||||||
|
await statusService.update(s.id, {
|
||||||
|
label: s.label,
|
||||||
|
color: s.color,
|
||||||
|
position: s.position,
|
||||||
|
isFinal: s.isFinal,
|
||||||
|
category: s.category,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await statusService.create({
|
||||||
|
label: s.label,
|
||||||
|
color: s.color,
|
||||||
|
position: s.position,
|
||||||
|
isFinal: s.isFinal,
|
||||||
|
category: s.category,
|
||||||
|
workflow: `/api/workflows/${workflow.id}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of existingIds) {
|
||||||
|
if (id && !keptIds.has(id)) {
|
||||||
|
await statusService.remove(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -66,14 +66,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
<MalioInputRichText
|
||||||
{{ $t('clientTicket.description') }}
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
v-model="editForm.description"
|
v-model="editForm.description"
|
||||||
rows="5"
|
:label="$t('clientTicket.description')"
|
||||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
min-height="180px"
|
||||||
style="resize: vertical; min-height: 140px; max-height: 500px"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -129,7 +125,13 @@
|
|||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.description') }}</p>
|
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.description') }}</p>
|
||||||
<p class="mt-1 whitespace-pre-wrap text-sm text-neutral-600">{{ ticket.description }}</p>
|
<MalioInputRichText
|
||||||
|
v-if="ticket.description"
|
||||||
|
:model-value="ticket.description"
|
||||||
|
:editable="false"
|
||||||
|
group-class="mt-1"
|
||||||
|
/>
|
||||||
|
<p v-else class="mt-1 text-sm italic text-neutral-400">—</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- URL (if bug) -->
|
<!-- URL (if bug) -->
|
||||||
|
|||||||
@@ -116,7 +116,12 @@
|
|||||||
|
|
||||||
<!-- Expanded details -->
|
<!-- Expanded details -->
|
||||||
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-3 py-3">
|
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-3 py-3">
|
||||||
<p class="text-sm text-neutral-600 whitespace-pre-wrap">{{ ticket.description }}</p>
|
<MalioInputRichText
|
||||||
|
v-if="ticket.description"
|
||||||
|
:model-value="ticket.description"
|
||||||
|
:editable="false"
|
||||||
|
/>
|
||||||
|
<p v-else class="text-sm italic text-neutral-400">—</p>
|
||||||
<div v-if="ticket.url" class="mt-2">
|
<div v-if="ticket.url" class="mt-2">
|
||||||
<a
|
<a
|
||||||
:href="ticket.url"
|
:href="ticket.url"
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<script setup lang="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 type { UserData } from '~/services/dto/user-data'
|
||||||
|
import { useMailService } from '~/services/mail'
|
||||||
|
import { useProjectService } from '~/services/projects'
|
||||||
|
import { useTaskGroupService } from '~/services/task-groups'
|
||||||
|
import { useUserService } from '~/services/users'
|
||||||
|
import { useAuthStore } from '~/stores/auth'
|
||||||
|
|
||||||
|
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<UserData[]>([])
|
||||||
|
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 })))
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<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>
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { MailFolderDto } from '~/services/dto/mail'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** Arbre de dossiers (getter folderTree du store) */
|
||||||
|
folders: readonly MailFolderDto[]
|
||||||
|
/** Chemin du dossier actuellement sélectionné */
|
||||||
|
selectedPath: string | null
|
||||||
|
/** Niveau de profondeur pour l'indentation (usage récursif interne) */
|
||||||
|
depth?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [path: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { getFolderLabel, getFolderIcon } = useSystemFolderLabel()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const currentDepth = computed(() => props.depth ?? 0)
|
||||||
|
|
||||||
|
// Dossiers dépliés (repliés par défaut → seuls les dossiers racine sont visibles).
|
||||||
|
const expanded = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
|
function isExpanded(path: string): boolean {
|
||||||
|
return expanded.value.has(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExpanded(path: string): void {
|
||||||
|
const next = new Set(expanded.value)
|
||||||
|
if (next.has(path)) {
|
||||||
|
next.delete(path)
|
||||||
|
} else {
|
||||||
|
next.add(path)
|
||||||
|
}
|
||||||
|
expanded.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasChildren(folder: MailFolderDto): boolean {
|
||||||
|
return !!folder.children && folder.children.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelect(path: string): void {
|
||||||
|
emit('select', path)
|
||||||
|
}
|
||||||
|
|
||||||
|
function paddingStyle(): Record<string, string> {
|
||||||
|
const depth = currentDepth.value
|
||||||
|
return { paddingLeft: `${0.5 + depth * 0.75}rem` }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
v-if="folders.length === 0 && currentDepth === 0"
|
||||||
|
class="px-3 py-4 text-sm text-neutral-400 italic"
|
||||||
|
>
|
||||||
|
{{ t('mail.empty.folder') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div v-for="folder in folders" :key="folder.path">
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-1 rounded-md pr-2 py-1.5 text-sm transition-colors"
|
||||||
|
:class="
|
||||||
|
selectedPath === folder.path
|
||||||
|
? 'bg-primary-100 text-primary-700 font-medium'
|
||||||
|
: 'text-neutral-700 hover:bg-neutral-100'
|
||||||
|
"
|
||||||
|
:style="paddingStyle()"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-if="hasChildren(folder)"
|
||||||
|
type="button"
|
||||||
|
class="flex-shrink-0 rounded p-0.5 hover:bg-neutral-200"
|
||||||
|
:aria-label="isExpanded(folder.path) ? t('mail.folderTree.collapse') : t('mail.folderTree.expand')"
|
||||||
|
@click.stop="toggleExpanded(folder.path)"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:name="isExpanded(folder.path) ? 'material-symbols:keyboard-arrow-down' : 'material-symbols:chevron-right'"
|
||||||
|
size="16"
|
||||||
|
class="text-neutral-400"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span v-else class="inline-block w-[22px] flex-shrink-0" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex flex-1 items-center gap-2 text-left min-w-0"
|
||||||
|
@click="handleSelect(folder.path)"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:name="getFolderIcon(folder.path)"
|
||||||
|
size="16"
|
||||||
|
class="flex-shrink-0"
|
||||||
|
:class="selectedPath === folder.path ? 'text-primary-600' : 'text-neutral-400'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span class="flex-1 truncate">
|
||||||
|
{{ getFolderLabel(folder.path, folder.displayName) }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="folder.unreadCount > 0"
|
||||||
|
class="ml-auto flex-shrink-0 rounded-full bg-primary-500 px-1.5 py-0.5 text-xs font-bold text-white"
|
||||||
|
>
|
||||||
|
{{ folder.unreadCount > 99 ? '99+' : folder.unreadCount }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MailFolderTree
|
||||||
|
v-if="hasChildren(folder) && isExpanded(folder.path)"
|
||||||
|
:folders="folder.children"
|
||||||
|
:selected-path="selectedPath"
|
||||||
|
:depth="currentDepth + 1"
|
||||||
|
@select="handleSelect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Task } from '~/services/dto/task'
|
||||||
|
import type { Project } from '~/services/dto/project'
|
||||||
|
import { useMailService } from '~/services/mail'
|
||||||
|
import { useTaskService } from '~/services/tasks'
|
||||||
|
import { useProjectService } from '~/services/projects'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
/** ID BDD du message à lier */
|
||||||
|
messageId: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
/** Émis après liaison réussie — payload = id de la tâche liée */
|
||||||
|
linked: [taskId: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const mailService = useMailService()
|
||||||
|
const taskService = useTaskService()
|
||||||
|
const projectService = useProjectService()
|
||||||
|
|
||||||
|
// ─── État recherche ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const filterProjectId = ref<number | null>(null)
|
||||||
|
const results = ref<Task[]>([])
|
||||||
|
const selectedTask = ref<Task | null>(null)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
|
||||||
|
// ─── Projets pour le filtre ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
const projects = ref<Project[]>([])
|
||||||
|
|
||||||
|
const projectFilterOptions = computed(() =>
|
||||||
|
projects.value.map(p => ({ label: p.name, value: p.id })),
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
projects.value = await projectService.getAll({ archived: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── Debounce recherche ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
watch([searchQuery, filterProjectId], () => {
|
||||||
|
selectedTask.value = null
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
void runSearch()
|
||||||
|
}, 300)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function runSearch(): Promise<void> {
|
||||||
|
const q = searchQuery.value.trim()
|
||||||
|
if (!q && !filterProjectId.value) {
|
||||||
|
results.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
const params: Record<string, string | number | boolean | string[]> = {
|
||||||
|
archived: false,
|
||||||
|
}
|
||||||
|
if (q) params['title'] = q
|
||||||
|
if (filterProjectId.value) params['project'] = `/api/projects/${filterProjectId.value}`
|
||||||
|
results.value = await taskService.getFiltered(params)
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Reset à l'ouverture ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (open) => {
|
||||||
|
if (open) {
|
||||||
|
searchQuery.value = ''
|
||||||
|
filterProjectId.value = null
|
||||||
|
results.value = []
|
||||||
|
selectedTask.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── Actions ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function close(): void {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTask(task: Task): void {
|
||||||
|
selectedTask.value = task
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(): Promise<void> {
|
||||||
|
if (!selectedTask.value) return
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await mailService.linkTask(props.messageId, selectedTask.value.id)
|
||||||
|
emit('linked', selectedTask.value.id)
|
||||||
|
close()
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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.linkTaskModal.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">
|
||||||
|
<!-- Filtre projet -->
|
||||||
|
<MalioSelect
|
||||||
|
v-model="filterProjectId"
|
||||||
|
:options="projectFilterOptions"
|
||||||
|
:label="t('mail.linkTaskModal.projectFilter')"
|
||||||
|
:empty-option-label="t('mail.linkTaskModal.projectAll')"
|
||||||
|
min-width="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Recherche tâche -->
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
||||||
|
{{ t('mail.linkTaskModal.title') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
:placeholder="t('mail.linkTaskModal.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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Résultats -->
|
||||||
|
<div class="max-h-64 overflow-y-auto rounded-md border border-neutral-200">
|
||||||
|
<!-- Chargement -->
|
||||||
|
<div
|
||||||
|
v-if="isLoading"
|
||||||
|
class="flex items-center justify-center py-6 text-sm text-neutral-400"
|
||||||
|
>
|
||||||
|
<Icon name="material-symbols:progress-activity" size="18" class="mr-2 animate-spin" />
|
||||||
|
{{ t('mail.linkTaskModal.loading') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vide -->
|
||||||
|
<div
|
||||||
|
v-else-if="!isLoading && results.length === 0 && (searchQuery.trim() || filterProjectId)"
|
||||||
|
class="py-6 text-center text-sm text-neutral-400 italic"
|
||||||
|
>
|
||||||
|
{{ t('mail.linkTaskModal.empty') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Liste résultats -->
|
||||||
|
<button
|
||||||
|
v-for="task in results"
|
||||||
|
:key="task.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="selectedTask?.id === task.id
|
||||||
|
? 'bg-primary-50 border-l-2 border-primary-500'
|
||||||
|
: 'border-l-2 border-transparent'"
|
||||||
|
@click="selectTask(task)"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name="material-symbols:task-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">
|
||||||
|
{{ task.title }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="task.project"
|
||||||
|
class="truncate text-xs text-neutral-500"
|
||||||
|
>
|
||||||
|
{{ task.project.name }}
|
||||||
|
<span v-if="task.project.code && task.number">
|
||||||
|
— {{ task.project.code }}-{{ task.number }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Icon
|
||||||
|
v-if="selectedTask?.id === task.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.linkTaskModal.submit')"
|
||||||
|
button-class="w-auto px-6"
|
||||||
|
:disabled="!selectedTask || 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>
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { MailMessageHeaderDto } from '~/services/dto/mail'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
messages: readonly MailMessageHeaderDto[]
|
||||||
|
selectedId: number | null
|
||||||
|
loading: boolean
|
||||||
|
hasMore: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [id: number]
|
||||||
|
loadMore: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const sentinelRef = ref<HTMLDivElement | null>(null)
|
||||||
|
let observer: IntersectionObserver | null = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!sentinelRef.value) return
|
||||||
|
observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const entry = entries[0]
|
||||||
|
if (entry?.isIntersecting && props.hasMore && !props.loading) {
|
||||||
|
emit('loadMore')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 },
|
||||||
|
)
|
||||||
|
observer.observe(sentinelRef.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
observer?.disconnect()
|
||||||
|
observer = null
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formate une date ISO en date relative (il y a X minutes/heures/jours).
|
||||||
|
* Utilise Intl.RelativeTimeFormat avec la locale fr.
|
||||||
|
*/
|
||||||
|
function formatRelative(isoDate: string | null): string {
|
||||||
|
if (!isoDate) return ''
|
||||||
|
const date = new Date(isoDate)
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = date.getTime() - now.getTime()
|
||||||
|
const diffSeconds = Math.round(diffMs / 1000)
|
||||||
|
const diffMinutes = Math.round(diffSeconds / 60)
|
||||||
|
const diffHours = Math.round(diffMinutes / 60)
|
||||||
|
const diffDays = Math.round(diffHours / 24)
|
||||||
|
|
||||||
|
const rtf = new Intl.RelativeTimeFormat('fr', { numeric: 'auto' })
|
||||||
|
|
||||||
|
if (Math.abs(diffMinutes) < 1) return rtf.format(diffSeconds, 'second')
|
||||||
|
if (Math.abs(diffHours) < 1) return rtf.format(diffMinutes, 'minute')
|
||||||
|
if (Math.abs(diffDays) < 1) return rtf.format(diffHours, 'hour')
|
||||||
|
if (Math.abs(diffDays) < 30) return rtf.format(diffDays, 'day')
|
||||||
|
|
||||||
|
return date.toLocaleDateString('fr', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSenderLabel(msg: MailMessageHeaderDto): string {
|
||||||
|
return msg.fromName ?? msg.fromEmail ?? ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex h-full flex-col overflow-hidden">
|
||||||
|
<div
|
||||||
|
v-if="!loading && messages.length === 0"
|
||||||
|
class="flex flex-1 items-center justify-center text-sm text-neutral-400 italic px-4 text-center"
|
||||||
|
>
|
||||||
|
{{ t('mail.empty.list') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="flex-1 overflow-y-auto divide-y divide-neutral-100">
|
||||||
|
<button
|
||||||
|
v-for="msg in messages"
|
||||||
|
:key="msg.id"
|
||||||
|
type="button"
|
||||||
|
class="flex w-full gap-3 px-3 py-3 text-left transition-colors hover:bg-neutral-50 focus:outline-none"
|
||||||
|
:class="[
|
||||||
|
selectedId === msg.id ? 'bg-primary-50 border-l-2 border-primary-500' : '',
|
||||||
|
!msg.isRead ? 'bg-white' : 'bg-neutral-50/50',
|
||||||
|
]"
|
||||||
|
@click="emit('select', msg.id)"
|
||||||
|
>
|
||||||
|
<div class="mt-1.5 flex-shrink-0">
|
||||||
|
<span
|
||||||
|
class="block h-2 w-2 rounded-full"
|
||||||
|
:class="msg.isRead ? 'bg-transparent' : 'bg-primary-500'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span
|
||||||
|
class="truncate text-sm"
|
||||||
|
:class="msg.isRead ? 'text-neutral-600 font-normal' : 'text-neutral-900 font-semibold'"
|
||||||
|
>
|
||||||
|
{{ getSenderLabel(msg) }}
|
||||||
|
</span>
|
||||||
|
<span class="flex-shrink-0 text-xs text-neutral-400">
|
||||||
|
{{ formatRelative(msg.sentAt ?? msg.receivedAt) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
class="truncate text-sm"
|
||||||
|
:class="msg.isRead ? 'text-neutral-500' : 'text-neutral-800 font-medium'"
|
||||||
|
>
|
||||||
|
{{ msg.subject ?? t('mail.noSubject') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-0.5 flex items-center gap-1.5">
|
||||||
|
<Icon
|
||||||
|
v-if="msg.isFlagged"
|
||||||
|
name="material-symbols:star"
|
||||||
|
size="14"
|
||||||
|
class="text-amber-400 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
v-if="msg.hasAttachments"
|
||||||
|
name="material-symbols:attach-file"
|
||||||
|
size="14"
|
||||||
|
class="text-neutral-400 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
v-if="msg.linkedTaskIds.length > 0"
|
||||||
|
name="material-symbols:task-outline"
|
||||||
|
size="14"
|
||||||
|
class="text-primary-400 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div ref="sentinelRef" class="h-px" />
|
||||||
|
|
||||||
|
<div v-if="loading && messages.length > 0" class="flex items-center justify-center py-4">
|
||||||
|
<Icon name="material-symbols:progress-activity" size="20" class="animate-spin text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading && messages.length === 0" class="flex flex-1 items-center justify-center">
|
||||||
|
<Icon name="material-symbols:progress-activity" size="24" class="animate-spin text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { MailMessageDetailDto, MailAddressDto } from '~/services/dto/mail'
|
||||||
|
import { sanitizeMailHtml } from '~/utils/sanitizeMailHtml'
|
||||||
|
import { useMailService } from '~/services/mail'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** Détail complet du message. null = aucun message sélectionné. */
|
||||||
|
detail: MailMessageDetailDto | null
|
||||||
|
loading: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
createTask: [mailId: number]
|
||||||
|
linkTask: [mailId: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const mailService = useMailService()
|
||||||
|
|
||||||
|
const showImages = ref(false)
|
||||||
|
|
||||||
|
const sanitizedBody = computed((): string => {
|
||||||
|
if (!props.detail?.bodyHtml) return ''
|
||||||
|
return sanitizeMailHtml(props.detail.bodyHtml, { allowImages: showImages.value })
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.detail?.header.id,
|
||||||
|
() => {
|
||||||
|
showImages.value = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async function handleDownload(downloadId: string, filename: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { data } = await mailService.downloadAttachment(downloadId)
|
||||||
|
const url = URL.createObjectURL(data)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
} catch {
|
||||||
|
// L'erreur est gérée par useApi (toast automatique)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string | null): string {
|
||||||
|
if (!iso) return ''
|
||||||
|
return new Date(iso).toLocaleString('fr', {
|
||||||
|
dateStyle: 'long',
|
||||||
|
timeStyle: 'short',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinAddresses(addresses: MailAddressDto[]): string {
|
||||||
|
return addresses
|
||||||
|
.map((a) => (a.name ? `${a.name} <${a.email}>` : a.email))
|
||||||
|
.join(', ')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex h-full flex-col overflow-hidden">
|
||||||
|
<div
|
||||||
|
v-if="!detail && !loading"
|
||||||
|
class="flex flex-1 items-center justify-center text-sm text-neutral-400 italic px-8 text-center"
|
||||||
|
>
|
||||||
|
{{ t('mail.empty.viewer') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="loading" class="flex flex-1 items-center justify-center">
|
||||||
|
<Icon name="material-symbols:progress-activity" size="28" class="animate-spin text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else-if="detail">
|
||||||
|
<div class="flex-shrink-0 border-b border-neutral-200 px-4 py-3 space-y-1.5">
|
||||||
|
<h2 class="text-base font-semibold text-neutral-900 break-words">
|
||||||
|
{{ detail.header.subject ?? t('mail.noSubject') }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<dl class="text-xs text-neutral-500 space-y-0.5">
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<dt class="font-medium text-neutral-600 w-5 flex-shrink-0">{{ t('mail.from') }}</dt>
|
||||||
|
<dd class="break-all">
|
||||||
|
{{
|
||||||
|
detail.header.fromName
|
||||||
|
? `${detail.header.fromName} <${detail.header.fromEmail}>`
|
||||||
|
: (detail.header.fromEmail ?? '')
|
||||||
|
}}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="detail.header.toRecipients.length > 0" class="flex gap-1.5">
|
||||||
|
<dt class="font-medium text-neutral-600 w-5 flex-shrink-0">{{ t('mail.to') }}</dt>
|
||||||
|
<dd class="break-all">{{ joinAddresses(detail.header.toRecipients) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="detail.header.ccRecipients.length > 0" class="flex gap-1.5">
|
||||||
|
<dt class="font-medium text-neutral-600 w-5 flex-shrink-0">{{ t('mail.cc') }}</dt>
|
||||||
|
<dd class="break-all">{{ joinAddresses(detail.header.ccRecipients) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<dt class="font-medium text-neutral-600 w-5 flex-shrink-0">{{ t('mail.date') }}</dt>
|
||||||
|
<dd>{{ formatDate(detail.header.sentAt ?? detail.header.receivedAt) }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2 pt-1">
|
||||||
|
<MalioButton
|
||||||
|
:label="t('mail.actions.createTask')"
|
||||||
|
variant="primary"
|
||||||
|
icon-name="material-symbols:add-task-outline"
|
||||||
|
icon-position="left"
|
||||||
|
:icon-size="13"
|
||||||
|
button-class="text-xs px-2.5 py-1"
|
||||||
|
@click="emit('createTask', detail.header.id)"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
:label="t('mail.actions.linkTask')"
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="material-symbols:link"
|
||||||
|
icon-position="left"
|
||||||
|
:icon-size="13"
|
||||||
|
button-class="text-xs px-2.5 py-1"
|
||||||
|
@click="emit('linkTask', detail.header.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto px-4 py-3">
|
||||||
|
<div
|
||||||
|
v-if="!showImages && detail.bodyHtml"
|
||||||
|
class="mb-3 flex items-center gap-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<Icon name="material-symbols:image-outline" size="16" class="text-amber-500 flex-shrink-0" />
|
||||||
|
<span class="flex-1 text-amber-700">
|
||||||
|
{{ t('mail.remoteImagesBlocked') }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-xs font-medium text-amber-700 underline hover:text-amber-900 transition-colors"
|
||||||
|
@click="showImages = true"
|
||||||
|
>
|
||||||
|
{{ t('mail.actions.showImages') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="detail.bodyHtml"
|
||||||
|
class="prose prose-sm max-w-none text-neutral-800"
|
||||||
|
v-html="sanitizedBody"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<pre
|
||||||
|
v-else-if="detail.bodyText"
|
||||||
|
class="whitespace-pre-wrap font-sans text-sm text-neutral-700"
|
||||||
|
>{{ detail.bodyText }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="detail.attachments.length > 0"
|
||||||
|
class="flex-shrink-0 border-t border-neutral-200 px-4 py-3"
|
||||||
|
>
|
||||||
|
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
|
{{ t('mail.attachments') }} ({{ detail.attachments.length }})
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="att in detail.attachments"
|
||||||
|
:key="att.downloadId"
|
||||||
|
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"
|
||||||
|
:title="att.filename"
|
||||||
|
@click="handleDownload(att.downloadId, att.filename)"
|
||||||
|
>
|
||||||
|
<Icon name="material-symbols:attach-file" size="14" class="flex-shrink-0 text-neutral-400" />
|
||||||
|
<span class="max-w-[180px] truncate">{{ att.filename }}</span>
|
||||||
|
<span class="text-neutral-400">({{ Math.round(att.size / 1024) }} Ko)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useMailStore } from '~/stores/mail'
|
||||||
|
|
||||||
|
const store = useMailStore()
|
||||||
|
const { syncing } = storeToRefs(store)
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
async function handleRefresh(): Promise<void> {
|
||||||
|
await store.triggerSync()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<MalioButton
|
||||||
|
:label="t('mail.actions.refresh')"
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="material-symbols:refresh"
|
||||||
|
icon-position="left"
|
||||||
|
:icon-size="16"
|
||||||
|
:disabled="syncing"
|
||||||
|
@click="handleRefresh"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -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"
|
||||||
@@ -87,10 +87,35 @@
|
|||||||
</MalioButton>
|
</MalioButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="props.project" class="mt-4 rounded border border-neutral-200 p-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase text-neutral-500">{{ $t('workflows.title') }}</p>
|
||||||
|
<p class="text-sm font-semibold text-neutral-900">{{ props.project.workflow?.name }}</p>
|
||||||
|
</div>
|
||||||
|
<MalioButton
|
||||||
|
v-if="canManageWorkflows"
|
||||||
|
type="button"
|
||||||
|
icon-name="mdi:swap-horizontal"
|
||||||
|
icon-position="left"
|
||||||
|
button-class="w-auto px-3 py-1 text-xs"
|
||||||
|
:label="$t('workflows.switchTitle')"
|
||||||
|
@click="switchModalOpen = true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ConfirmDeleteProjectModal
|
<ConfirmDeleteProjectModal
|
||||||
v-model="confirmDeleteOpen"
|
v-model="confirmDeleteOpen"
|
||||||
@confirm="handleDelete"
|
@confirm="handleDelete"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ProjectWorkflowSwitchModal
|
||||||
|
v-if="props.project"
|
||||||
|
v-model="switchModalOpen"
|
||||||
|
:project="props.project"
|
||||||
|
@switched="onWorkflowSwitched"
|
||||||
|
/>
|
||||||
</MalioDrawer>
|
</MalioDrawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -122,6 +147,15 @@ const isOpen = computed({
|
|||||||
const isEditing = computed(() => !!props.project)
|
const isEditing = computed(() => !!props.project)
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const confirmDeleteOpen = ref(false)
|
const confirmDeleteOpen = ref(false)
|
||||||
|
const switchModalOpen = ref(false)
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const canManageWorkflows = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||||
|
|
||||||
|
function onWorkflowSwitched() {
|
||||||
|
emit('saved')
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
const { listRepositories } = useGiteaService()
|
const { listRepositories } = useGiteaService()
|
||||||
const giteaRepos = ref<GiteaRepository[]>([])
|
const giteaRepos = ref<GiteaRepository[]>([])
|
||||||
@@ -152,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 }))
|
||||||
)
|
)
|
||||||
@@ -188,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 {
|
||||||
@@ -220,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #cell-description="{ item }">
|
<template #cell-description="{ item }">
|
||||||
{{ item.description ?? '—' }}
|
{{ stripRichText(item.description) || '—' }}
|
||||||
</template>
|
</template>
|
||||||
<template #actions="{ item }">
|
<template #actions="{ item }">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
@@ -71,6 +71,7 @@ import type { TaskGroup } from '~/services/dto/task-group'
|
|||||||
import type { Task } from '~/services/dto/task'
|
import type { Task } from '~/services/dto/task'
|
||||||
import { useTaskGroupService } from '~/services/task-groups'
|
import { useTaskGroupService } from '~/services/task-groups'
|
||||||
import { useTaskService } from '~/services/tasks'
|
import { useTaskService } from '~/services/tasks'
|
||||||
|
import { stripRichText } from '~/utils/format'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
projectId: number
|
projectId: number
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport v-if="modelValue" to="body">
|
||||||
|
<Transition name="modal" appear>
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div class="absolute inset-0 bg-black/30" @click="close" />
|
||||||
|
<div class="relative z-10 w-full max-w-2xl rounded-lg bg-white p-6 shadow-xl">
|
||||||
|
<h3 class="text-lg font-bold text-neutral-900">{{ $t('workflows.switchTitle') }}</h3>
|
||||||
|
|
||||||
|
<div class="mt-5 flex flex-col gap-5">
|
||||||
|
<MalioSelect
|
||||||
|
v-model="targetWorkflowId"
|
||||||
|
:options="targetOptions"
|
||||||
|
:label="$t('workflows.switchTargetLabel')"
|
||||||
|
empty-option-label="—"
|
||||||
|
min-width="!w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="targetWorkflow" class="flex flex-col gap-2">
|
||||||
|
<h4 class="text-sm font-bold text-neutral-900">{{ $t('workflows.switchMappingTitle') }}</h4>
|
||||||
|
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b text-left text-xs text-neutral-500">
|
||||||
|
<th class="py-2 pr-3">{{ $t('workflows.switchSourceCol') }}</th>
|
||||||
|
<th class="py-2 pr-3">{{ $t('workflows.switchTargetCol') }}</th>
|
||||||
|
<th class="py-2 text-right">{{ $t('workflows.switchTaskCountCol') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in mappingRows" :key="row.sourceId ?? 'backlog'" class="border-b last:border-0">
|
||||||
|
<td class="py-2 pr-3">
|
||||||
|
<span
|
||||||
|
v-if="row.source"
|
||||||
|
class="mr-2 inline-block h-3 w-3 rounded-full align-middle"
|
||||||
|
:style="{ backgroundColor: row.source.color }"
|
||||||
|
/>
|
||||||
|
{{ row.source?.label ?? $t('myTasks.backlog') }}
|
||||||
|
<span class="ml-1 text-xs text-neutral-400">
|
||||||
|
({{ row.source?.category ? $t(`workflows.categories.${row.source.category}`) : '—' }})
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 pr-3">
|
||||||
|
<select
|
||||||
|
v-model="row.targetId"
|
||||||
|
class="h-9 w-full rounded border border-neutral-300 px-2 text-sm"
|
||||||
|
>
|
||||||
|
<option :value="null">{{ $t('workflows.switchToBacklog') }}</option>
|
||||||
|
<option
|
||||||
|
v-for="s in targetWorkflow.statuses"
|
||||||
|
:key="s.id"
|
||||||
|
:value="s.id"
|
||||||
|
>
|
||||||
|
{{ s.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 text-right text-neutral-700">{{ row.count }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<MalioButton
|
||||||
|
variant="tertiary"
|
||||||
|
label="Annuler"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
@click="close"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
:label="$t('workflows.switchConfirm')"
|
||||||
|
button-class="w-auto px-6"
|
||||||
|
:disabled="!canConfirm || isSubmitting"
|
||||||
|
@click="confirm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Project } from '~/services/dto/project'
|
||||||
|
import type { Task } from '~/services/dto/task'
|
||||||
|
import type { Workflow } from '~/services/dto/workflow'
|
||||||
|
import type { TaskStatus } from '~/services/dto/task-status'
|
||||||
|
import { useWorkflowService } from '~/services/workflows'
|
||||||
|
import { useTaskService } from '~/services/tasks'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
project: Project
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
(e: 'switched'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const workflows = ref<Workflow[]>([])
|
||||||
|
const projectTasks = ref<Task[]>([])
|
||||||
|
const targetWorkflowId = ref<number | null>(null)
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
|
||||||
|
const workflowService = useWorkflowService()
|
||||||
|
const taskService = useTaskService()
|
||||||
|
|
||||||
|
const targetOptions = computed(() =>
|
||||||
|
workflows.value
|
||||||
|
.filter(w => w.id !== props.project.workflow.id)
|
||||||
|
.map(w => ({ label: w.name, value: w.id })),
|
||||||
|
)
|
||||||
|
|
||||||
|
const targetWorkflow = computed<Workflow | null>(() =>
|
||||||
|
workflows.value.find(w => w.id === targetWorkflowId.value) ?? null,
|
||||||
|
)
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
sourceId: number | null
|
||||||
|
source: TaskStatus | null
|
||||||
|
targetId: number | null
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappingRows = ref<Row[]>([])
|
||||||
|
|
||||||
|
function smartPrefill(source: TaskStatus | null, target: Workflow): number | null {
|
||||||
|
if (!source) return null
|
||||||
|
const sameCat = target.statuses
|
||||||
|
.filter(s => s.category === source.category)
|
||||||
|
.sort((a, b) => a.position - b.position)
|
||||||
|
return sameCat[0]?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(targetWorkflow, (tw) => {
|
||||||
|
if (!tw) {
|
||||||
|
mappingRows.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const usedStatusIds = new Map<number | null, number>()
|
||||||
|
for (const t of projectTasks.value) {
|
||||||
|
const key = t.status?.id ?? null
|
||||||
|
usedStatusIds.set(key, (usedStatusIds.get(key) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
mappingRows.value = [...usedStatusIds.entries()].map(([sourceId, count]) => {
|
||||||
|
const source = props.project.workflow.statuses.find(s => s.id === sourceId) ?? null
|
||||||
|
return {
|
||||||
|
sourceId,
|
||||||
|
source,
|
||||||
|
targetId: smartPrefill(source, tw),
|
||||||
|
count,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const canConfirm = computed(() => {
|
||||||
|
if (!targetWorkflow.value) return false
|
||||||
|
return mappingRows.value.every(r => r.sourceId === null || r.targetId !== undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.modelValue, async (open) => {
|
||||||
|
if (!open) return
|
||||||
|
targetWorkflowId.value = null
|
||||||
|
const [allWorkflows, tasks] = await Promise.all([
|
||||||
|
workflowService.getAll(),
|
||||||
|
taskService.getFiltered({ project: `/api/projects/${props.project.id}`, archived: false }),
|
||||||
|
])
|
||||||
|
workflows.value = allWorkflows
|
||||||
|
projectTasks.value = tasks
|
||||||
|
})
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirm() {
|
||||||
|
if (!targetWorkflow.value) return
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const mapping: Record<string, number | null> = {}
|
||||||
|
for (const r of mappingRows.value) {
|
||||||
|
if (r.sourceId !== null) {
|
||||||
|
mapping[String(r.sourceId)] = r.targetId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await workflowService.switchOnProject(props.project.id, {
|
||||||
|
workflowId: targetWorkflow.value.id,
|
||||||
|
mapping,
|
||||||
|
})
|
||||||
|
emit('switched')
|
||||||
|
close()
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-enter-active,
|
||||||
|
.modal-leave-active {
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
.modal-enter-from,
|
||||||
|
.modal-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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>
|
||||||
@@ -14,8 +14,9 @@
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div v-if="selectedCount > 0" class="ml-2 flex items-center gap-1">
|
<div v-if="selectedCount > 0" class="ml-2 flex items-center gap-1">
|
||||||
<!-- Bulk status -->
|
<!-- Bulk status (scoped to single project's workflow) -->
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
|
v-if="!isMultiProject"
|
||||||
:model-value="null"
|
:model-value="null"
|
||||||
:options="statusOptions"
|
:options="statusOptions"
|
||||||
label="Status"
|
label="Status"
|
||||||
@@ -25,6 +26,13 @@
|
|||||||
text-value="text-xs"
|
text-value="text-xs"
|
||||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'status', v)"
|
@update:model-value="(v: number | null) => v && emit('bulk-update', 'status', v)"
|
||||||
/>
|
/>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="rounded border border-neutral-200 px-2 py-1 text-xs text-neutral-400"
|
||||||
|
title="Sélection multi-projets — le statut dépend du workflow de chaque projet"
|
||||||
|
>
|
||||||
|
Status —
|
||||||
|
</span>
|
||||||
<!-- Bulk user -->
|
<!-- Bulk user -->
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
:model-value="null"
|
:model-value="null"
|
||||||
@@ -85,13 +93,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { Task } from '~/services/dto/task'
|
||||||
import type { TaskStatus } from '~/services/dto/task-status'
|
import type { TaskStatus } from '~/services/dto/task-status'
|
||||||
import type { TaskEffort } from '~/services/dto/task-effort'
|
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||||
import type { TaskPriority } from '~/services/dto/task-priority'
|
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||||
import type { TaskGroup } from '~/services/dto/task-group'
|
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'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
selectedCount: number
|
selectedCount: number
|
||||||
totalCount: number
|
totalCount: number
|
||||||
allSelected: boolean
|
allSelected: boolean
|
||||||
@@ -101,7 +111,12 @@ const props = defineProps<{
|
|||||||
priorities: TaskPriority[]
|
priorities: TaskPriority[]
|
||||||
efforts: TaskEffort[]
|
efforts: TaskEffort[]
|
||||||
groups: TaskGroup[]
|
groups: TaskGroup[]
|
||||||
}>()
|
selectedTasks?: Task[]
|
||||||
|
projects?: Project[]
|
||||||
|
}>(), {
|
||||||
|
selectedTasks: () => [],
|
||||||
|
projects: () => [],
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'toggle-all'): void
|
(e: 'toggle-all'): void
|
||||||
@@ -110,23 +125,42 @@ const emit = defineEmits<{
|
|||||||
(e: 'bulk-delete'): void
|
(e: 'bulk-delete'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const statusOptions = computed(() =>
|
const distinctProjectIds = computed(() => {
|
||||||
props.statuses.map(s => ({ label: s.label, value: s.id }))
|
const ids = new Set<number>()
|
||||||
)
|
for (const t of props.selectedTasks) {
|
||||||
|
if (t.project) ids.add(t.project.id)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
})
|
||||||
|
|
||||||
|
const isMultiProject = computed(() => distinctProjectIds.value.size > 1)
|
||||||
|
|
||||||
|
const statusOptions = computed<{ label: string, value: number }[]>(() => {
|
||||||
|
// Si on connait les projets et qu'on est sur un seul, on scope au workflow de ce projet
|
||||||
|
if (distinctProjectIds.value.size === 1 && props.projects.length > 0) {
|
||||||
|
const projectId = [...distinctProjectIds.value][0]
|
||||||
|
const project = props.projects.find(p => p.id === projectId)
|
||||||
|
if (project?.workflow?.statuses) {
|
||||||
|
return project.workflow.statuses.map(s => ({ label: s.label, value: s.id }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback : statuts globaux fournis en props (ex. depuis projects/[id])
|
||||||
|
return props.statuses.map(s => ({ label: s.label, value: s.id }))
|
||||||
|
})
|
||||||
|
|
||||||
const userOptions = computed(() =>
|
const userOptions = computed(() =>
|
||||||
props.users.map(u => ({ label: u.username, value: u.id }))
|
props.users.map(u => ({ label: u.username, value: u.id })),
|
||||||
)
|
)
|
||||||
|
|
||||||
const priorityOptions = computed(() =>
|
const priorityOptions = computed(() =>
|
||||||
props.priorities.map(p => ({ label: p.label, value: p.id }))
|
props.priorities.map(p => ({ label: p.label, value: p.id })),
|
||||||
)
|
)
|
||||||
|
|
||||||
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 })),
|
||||||
)
|
)
|
||||||
|
|
||||||
const groupOptions = computed(() =>
|
const groupOptions = computed(() =>
|
||||||
props.groups.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id }))
|
props.groups.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id })),
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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,14 @@
|
|||||||
/>
|
/>
|
||||||
</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
|
||||||
|
v-if="showStatusBadge && task.status"
|
||||||
|
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||||
|
:style="{ backgroundColor: task.status.color }"
|
||||||
|
>
|
||||||
|
{{ task.status.label }}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="task.priority"
|
v-if="task.priority"
|
||||||
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"
|
||||||
@@ -78,11 +85,17 @@
|
|||||||
class="text-blue-500"
|
class="text-blue-500"
|
||||||
size="14"
|
size="14"
|
||||||
/>
|
/>
|
||||||
|
<Icon
|
||||||
|
v-if="task.collaborators?.length"
|
||||||
|
name="mdi:account-group"
|
||||||
|
class="ml-auto h-4 w-4 text-neutral-400"
|
||||||
|
:title="task.collaborators.map(c => c.username).join(', ')"
|
||||||
|
/>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
v-if="task.assignee"
|
v-if="task.assignee"
|
||||||
:user="task.assignee"
|
:user="task.assignee"
|
||||||
size="xs"
|
size="xs"
|
||||||
class="ml-auto"
|
:class="task.collaborators?.length ? '' : 'ml-auto'"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
v-else
|
v-else
|
||||||
@@ -100,8 +113,10 @@ import type { Task } from '~/services/dto/task'
|
|||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
task: Task
|
task: Task
|
||||||
showProjectColor?: boolean
|
showProjectColor?: boolean
|
||||||
|
showStatusBadge?: boolean
|
||||||
}>(), {
|
}>(), {
|
||||||
showProjectColor: false,
|
showProjectColor: false,
|
||||||
|
showStatusBadge: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|||||||
@@ -8,10 +8,10 @@
|
|||||||
:error="touched.title && !form.title.trim() ? 'Le titre est requis' : ''"
|
:error="touched.title && !form.title.trim() ? 'Le titre est requis' : ''"
|
||||||
@blur="touched.title = true"
|
@blur="touched.title = true"
|
||||||
/>
|
/>
|
||||||
<MalioInputTextArea
|
<MalioInputRichText
|
||||||
v-model="form.description"
|
v-model="form.description"
|
||||||
label="Description"
|
label="Description"
|
||||||
:size="3"
|
min-height="120px"
|
||||||
/>
|
/>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<ColorPicker v-model="form.color" />
|
<ColorPicker v-model="form.color" />
|
||||||
|
|||||||
@@ -86,6 +86,13 @@
|
|||||||
:button-class="isTimerOnTask ? 'shrink-0 text-[#F18619] hover:text-[#d97314]' : 'shrink-0 text-neutral-400 hover:text-primary-500'"
|
:button-class="isTimerOnTask ? 'shrink-0 text-[#F18619] hover:text-[#d97314]' : 'shrink-0 text-neutral-400 hover:text-primary-500'"
|
||||||
@click.stop="isTimerOnTask ? timerStore.stop() : timerStore.startFromTask(task)"
|
@click.stop="isTimerOnTask ? timerStore.stop() : timerStore.startFromTask(task)"
|
||||||
/>
|
/>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Icon
|
||||||
|
v-if="task.collaborators?.length"
|
||||||
|
name="mdi:account-group"
|
||||||
|
class="h-4 w-4 text-neutral-400"
|
||||||
|
:title="task.collaborators.map(c => c.username).join(', ')"
|
||||||
|
/>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
v-if="task.assignee"
|
v-if="task.assignee"
|
||||||
:user="task.assignee"
|
:user="task.assignee"
|
||||||
@@ -99,6 +106,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
@@ -55,21 +55,21 @@
|
|||||||
</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">
|
||||||
<button
|
<button
|
||||||
v-for="tab in ['details', 'planning']"
|
v-for="tab in availableTabs"
|
||||||
:key="tab"
|
:key="tab"
|
||||||
type="button"
|
type="button"
|
||||||
class="px-1 pb-3 text-sm font-semibold transition"
|
class="px-1 pb-3 text-sm font-semibold transition"
|
||||||
:class="activeTab === tab
|
:class="activeTab === tab
|
||||||
? 'border-b-2 border-primary-500 text-primary-500'
|
? 'border-b-2 border-primary-500 text-primary-500'
|
||||||
: 'text-neutral-500 hover:text-neutral-700'"
|
: 'text-neutral-500 hover:text-neutral-700'"
|
||||||
@click="activeTab = tab as 'details' | 'planning'"
|
@click="activeTab = tab as 'details' | 'planning' | 'mails'"
|
||||||
>
|
>
|
||||||
{{ $t(`tasks.${tab}Tab`) }}
|
{{ tab === 'mails' ? $t('mail.taskTab.title') : $t(`tasks.${tab}Tab`) }}
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,15 +170,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Collaborators -->
|
||||||
|
<div v-if="collaboratorOptions.length" class="mt-5">
|
||||||
|
<p class="mb-2 text-sm font-medium text-neutral-700">Collaborateurs</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<label
|
||||||
|
v-for="user in collaboratorOptions"
|
||||||
|
:key="user.value"
|
||||||
|
class="cursor-pointer rounded-full px-3 py-1 text-xs font-semibold transition-all"
|
||||||
|
:class="form.collaboratorIds.includes(user.value)
|
||||||
|
? 'bg-primary-500 text-white shadow-sm'
|
||||||
|
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="hidden"
|
||||||
|
:value="user.value"
|
||||||
|
:checked="form.collaboratorIds.includes(user.value)"
|
||||||
|
@change="toggleCollaborator(user.value)"
|
||||||
|
/>
|
||||||
|
{{ user.label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div class="mt-5">
|
<div class="mt-5">
|
||||||
<MalioInputTextArea
|
<MalioInputRichText
|
||||||
v-model="form.description"
|
v-model="form.description"
|
||||||
label="Description"
|
label="Description"
|
||||||
:size="5"
|
min-height="180px"
|
||||||
resize="vertical"
|
|
||||||
:min-resize-height="140"
|
|
||||||
:max-resize-height="500"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -412,9 +433,61 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Onglet Mails -->
|
||||||
|
<div v-show="activeTab === 'mails'" class="space-y-4">
|
||||||
|
<!-- Chargement -->
|
||||||
|
<div v-if="mailsLoading" class="flex items-center justify-center py-8">
|
||||||
|
<Icon name="material-symbols:progress-activity" size="24" class="animate-spin text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vide -->
|
||||||
|
<div
|
||||||
|
v-else-if="linkedMails.length === 0"
|
||||||
|
class="flex flex-col items-center justify-center gap-3 py-8 text-center"
|
||||||
|
>
|
||||||
|
<Icon name="material-symbols:mail-outline" size="32" class="text-neutral-300" />
|
||||||
|
<p class="text-sm text-neutral-400 italic">{{ $t('mail.taskTab.empty') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Liste mails liés -->
|
||||||
|
<div v-else class="divide-y divide-neutral-100 rounded-lg border border-neutral-200">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="mail in linkedMails"
|
||||||
|
:key="mail.id"
|
||||||
|
:to="`/mail?messageId=${mail.id}`"
|
||||||
|
class="flex items-start gap-3 px-4 py-3 text-sm transition-colors hover:bg-neutral-50"
|
||||||
|
:title="$t('mail.taskTab.openInMailer')"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
{{ mail.subject ?? $t('mail.noSubject') }}
|
||||||
|
</p>
|
||||||
|
<p class="flex items-center gap-2 text-xs text-neutral-500">
|
||||||
|
<span class="truncate">{{ mail.fromName ?? mail.fromEmail }}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span class="flex-shrink-0">{{ formatMailDate(mail.sentAt ?? mail.receivedAt) }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Icon
|
||||||
|
name="material-symbols:open-in-new"
|
||||||
|
size="14"
|
||||||
|
class="flex-shrink-0 text-neutral-300"
|
||||||
|
/>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
<!-- 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
|
||||||
@@ -456,7 +529,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
|
|
||||||
<ConfirmDeleteTaskModal
|
<ConfirmDeleteTaskModal
|
||||||
v-model="confirmDeleteOpen"
|
v-model="confirmDeleteOpen"
|
||||||
@@ -492,6 +564,8 @@ import { useTaskService } from '~/services/tasks'
|
|||||||
import { useTaskRecurrenceService } from '~/services/task-recurrences'
|
import { useTaskRecurrenceService } from '~/services/task-recurrences'
|
||||||
|
|
||||||
import type { Project } from '~/services/dto/project'
|
import type { Project } from '~/services/dto/project'
|
||||||
|
import { useMailService } from '~/services/mail'
|
||||||
|
import type { MailMessageHeaderDto } from '~/services/dto/mail'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
@@ -524,7 +598,13 @@ function close() {
|
|||||||
const isEditing = computed(() => !!props.task)
|
const isEditing = computed(() => !!props.task)
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const confirmDeleteOpen = ref(false)
|
const confirmDeleteOpen = ref(false)
|
||||||
const activeTab = ref<'details' | 'planning'>('details')
|
const activeTab = ref<'details' | 'planning' | 'mails'>('details')
|
||||||
|
|
||||||
|
// ─── Onglet Mails ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const mailService = useMailService()
|
||||||
|
const linkedMails = ref<MailMessageHeaderDto[]>([])
|
||||||
|
const mailsLoading = ref(false)
|
||||||
|
|
||||||
const giteaUrl = ref('')
|
const giteaUrl = ref('')
|
||||||
const { getSettings: getGiteaSettings } = useGiteaService()
|
const { getSettings: getGiteaSettings } = useGiteaService()
|
||||||
@@ -544,6 +624,7 @@ const form = reactive({
|
|||||||
effortId: null as number | null,
|
effortId: null as number | null,
|
||||||
priorityId: null as number | null,
|
priorityId: null as number | null,
|
||||||
assigneeId: null as number | null,
|
assigneeId: null as number | null,
|
||||||
|
collaboratorIds: [] as number[],
|
||||||
groupId: null as number | null,
|
groupId: null as number | null,
|
||||||
tagIds: [] as number[],
|
tagIds: [] as number[],
|
||||||
clientTicketId: null as number | null,
|
clientTicketId: null as number | null,
|
||||||
@@ -570,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 }))
|
||||||
)
|
)
|
||||||
@@ -586,6 +684,18 @@ const userOptions = computed(() =>
|
|||||||
props.users.map(u => ({ label: u.username, value: u.id }))
|
props.users.map(u => ({ label: u.username, value: u.id }))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const collaboratorOptions = computed(() =>
|
||||||
|
props.users
|
||||||
|
.filter(u => u.id !== form.assigneeId)
|
||||||
|
.map(u => ({ label: u.username, value: u.id }))
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(() => form.assigneeId, (newAssigneeId) => {
|
||||||
|
if (newAssigneeId) {
|
||||||
|
form.collaboratorIds = form.collaboratorIds.filter(id => id !== newAssigneeId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const groupOptions = computed(() => {
|
const groupOptions = computed(() => {
|
||||||
let filtered = props.groups.filter(g => !g.archived)
|
let filtered = props.groups.filter(g => !g.archived)
|
||||||
if (showProjectSelect.value && form.projectId) {
|
if (showProjectSelect.value && form.projectId) {
|
||||||
@@ -594,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
|
||||||
@@ -624,6 +724,12 @@ function toggleTag(id: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleCollaborator(userId: number) {
|
||||||
|
const idx = form.collaboratorIds.indexOf(userId)
|
||||||
|
if (idx >= 0) form.collaboratorIds.splice(idx, 1)
|
||||||
|
else form.collaboratorIds.push(userId)
|
||||||
|
}
|
||||||
|
|
||||||
const weekDays = computed(() => [
|
const weekDays = computed(() => [
|
||||||
{ value: 'monday', label: t('tasks.planning.days.mon') },
|
{ value: 'monday', label: t('tasks.planning.days.mon') },
|
||||||
{ value: 'tuesday', label: t('tasks.planning.days.tue') },
|
{ value: 'tuesday', label: t('tasks.planning.days.tue') },
|
||||||
@@ -648,6 +754,7 @@ function populateForm(task: Task | null) {
|
|||||||
form.effortId = task.effort?.id ?? null
|
form.effortId = task.effort?.id ?? null
|
||||||
form.priorityId = task.priority?.id ?? null
|
form.priorityId = task.priority?.id ?? null
|
||||||
form.assigneeId = task.assignee?.id ?? null
|
form.assigneeId = task.assignee?.id ?? null
|
||||||
|
form.collaboratorIds = task.collaborators?.map(c => c.id) ?? []
|
||||||
form.groupId = task.group?.id ?? null
|
form.groupId = task.group?.id ?? null
|
||||||
form.tagIds = task.tags.map(t => t.id)
|
form.tagIds = task.tags.map(t => t.id)
|
||||||
form.clientTicketId = task.clientTicket?.id ?? null
|
form.clientTicketId = task.clientTicket?.id ?? null
|
||||||
@@ -694,6 +801,7 @@ function populateForm(task: Task | null) {
|
|||||||
form.effortId = null
|
form.effortId = null
|
||||||
form.priorityId = null
|
form.priorityId = null
|
||||||
form.assigneeId = null
|
form.assigneeId = null
|
||||||
|
form.collaboratorIds = []
|
||||||
form.groupId = null
|
form.groupId = null
|
||||||
form.tagIds = []
|
form.tagIds = []
|
||||||
form.clientTicketId = null
|
form.clientTicketId = null
|
||||||
@@ -723,6 +831,7 @@ watch(() => props.modelValue, async (open) => {
|
|||||||
activeTab.value = 'details'
|
activeTab.value = 'details'
|
||||||
confirmDeleteDocOpen.value = false
|
confirmDeleteDocOpen.value = false
|
||||||
documentToDelete.value = null
|
documentToDelete.value = null
|
||||||
|
linkedMails.value = []
|
||||||
populateForm(props.task)
|
populateForm(props.task)
|
||||||
const pid = resolvedProjectId.value
|
const pid = resolvedProjectId.value
|
||||||
if (pid) {
|
if (pid) {
|
||||||
@@ -781,6 +890,44 @@ watch(() => form.projectId, async (pid) => {
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||||
|
|
||||||
|
const isClientOnly = computed(() =>
|
||||||
|
authStore.user?.roles?.includes('ROLE_CLIENT') === true
|
||||||
|
&& authStore.user?.roles?.includes('ROLE_ADMIN') !== true,
|
||||||
|
)
|
||||||
|
const isMailUser = computed(() => !isClientOnly.value)
|
||||||
|
|
||||||
|
const availableTabs = computed(() => {
|
||||||
|
const base: Array<'details' | 'planning' | 'mails'> = ['details', 'planning']
|
||||||
|
if (isEditing.value && isMailUser.value) base.push('mails')
|
||||||
|
return base
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadLinkedMails(): Promise<void> {
|
||||||
|
if (!props.task || !isMailUser.value) return
|
||||||
|
mailsLoading.value = true
|
||||||
|
try {
|
||||||
|
linkedMails.value = await mailService.listMailsForTask(props.task.id)
|
||||||
|
} catch {
|
||||||
|
linkedMails.value = []
|
||||||
|
} finally {
|
||||||
|
mailsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(activeTab, async (tab) => {
|
||||||
|
if (tab === 'mails' && props.task) {
|
||||||
|
await loadLinkedMails()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatMailDate(iso: string | null): string {
|
||||||
|
if (!iso) return ''
|
||||||
|
return new Date(iso).toLocaleDateString('fr', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function ticketStatusClass(status: string): string {
|
function ticketStatusClass(status: string): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'new': return 'bg-blue-100 text-blue-700'
|
case 'new': return 'bg-blue-100 text-blue-700'
|
||||||
@@ -906,6 +1053,7 @@ async function handleSubmit() {
|
|||||||
effort: form.effortId ? `/api/task_efforts/${form.effortId}` : null,
|
effort: form.effortId ? `/api/task_efforts/${form.effortId}` : null,
|
||||||
priority: form.priorityId ? `/api/task_priorities/${form.priorityId}` : null,
|
priority: form.priorityId ? `/api/task_priorities/${form.priorityId}` : null,
|
||||||
assignee: form.assigneeId ? `/api/users/${form.assigneeId}` : null,
|
assignee: form.assigneeId ? `/api/users/${form.assigneeId}` : null,
|
||||||
|
collaborators: form.collaboratorIds.map(id => `/api/users/${id}`),
|
||||||
group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
|
group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
|
||||||
project: `/api/projects/${resolvedProjectId.value}`,
|
project: `/api/projects/${resolvedProjectId.value}`,
|
||||||
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
<template>
|
|
||||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskStatuses.editStatus') : $t('taskStatuses.addStatus')">
|
|
||||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
|
||||||
<MalioInputText
|
|
||||||
v-model="form.label"
|
|
||||||
label="Libellé"
|
|
||||||
input-class="w-full"
|
|
||||||
:error="touched.label && !form.label.trim() ? 'Le libellé est requis' : ''"
|
|
||||||
@blur="touched.label = true"
|
|
||||||
/>
|
|
||||||
<MalioInputText
|
|
||||||
v-model="form.position"
|
|
||||||
label="Position"
|
|
||||||
input-class="w-full"
|
|
||||||
type="number"
|
|
||||||
/>
|
|
||||||
<div class="mt-4">
|
|
||||||
<ColorPicker v-model="form.color" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
id="isFinal"
|
|
||||||
v-model="form.isFinal"
|
|
||||||
type="checkbox"
|
|
||||||
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
|
|
||||||
/>
|
|
||||||
<label for="isFinal" class="text-sm font-medium text-neutral-700">
|
|
||||||
{{ $t('archive.statusFinal') }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end">
|
|
||||||
<MalioButton
|
|
||||||
label="Enregistrer"
|
|
||||||
button-class="w-auto px-6"
|
|
||||||
:disabled="isSubmitting"
|
|
||||||
@click="handleSubmit"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</MalioDrawer>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { TaskStatus, TaskStatusWrite } from '~/services/dto/task-status'
|
|
||||||
import { useTaskStatusService } from '~/services/task-statuses'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
modelValue: boolean
|
|
||||||
item: TaskStatus | null
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'update:modelValue', value: boolean): void
|
|
||||||
(e: 'saved'): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const isOpen = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: (v) => emit('update:modelValue', v),
|
|
||||||
})
|
|
||||||
|
|
||||||
const isEditing = computed(() => !!props.item)
|
|
||||||
const isSubmitting = ref(false)
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
label: '',
|
|
||||||
position: '0',
|
|
||||||
color: '#222783',
|
|
||||||
isFinal: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const touched = reactive({
|
|
||||||
label: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => props.modelValue, (open) => {
|
|
||||||
if (open) {
|
|
||||||
if (props.item) {
|
|
||||||
form.label = props.item.label ?? ''
|
|
||||||
form.position = String(props.item.position ?? 0)
|
|
||||||
form.color = props.item.color ?? '#222783'
|
|
||||||
form.isFinal = props.item.isFinal ?? false
|
|
||||||
} else {
|
|
||||||
form.label = ''
|
|
||||||
form.position = '0'
|
|
||||||
form.color = '#222783'
|
|
||||||
form.isFinal = false
|
|
||||||
}
|
|
||||||
touched.label = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const { create, update } = useTaskStatusService()
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
touched.label = true
|
|
||||||
if (!form.label.trim()) return
|
|
||||||
|
|
||||||
isSubmitting.value = true
|
|
||||||
try {
|
|
||||||
const payload: TaskStatusWrite = {
|
|
||||||
label: form.label.trim(),
|
|
||||||
position: Number(form.position),
|
|
||||||
color: form.color,
|
|
||||||
isFinal: form.isFinal,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEditing.value && props.item) {
|
|
||||||
await update(props.item.id, payload)
|
|
||||||
} else {
|
|
||||||
await create(payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
emit('saved')
|
|
||||||
isOpen.value = false
|
|
||||||
} finally {
|
|
||||||
isSubmitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -11,14 +11,11 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<MalioInputRichText
|
||||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Description</label>
|
|
||||||
<textarea
|
|
||||||
v-model="form.description"
|
v-model="form.description"
|
||||||
rows="3"
|
label="Description"
|
||||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
|
min-height="120px"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Date</label>
|
<label class="mb-1 block text-sm font-semibold text-neutral-700">Date</label>
|
||||||
|
|||||||
@@ -33,8 +33,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-0.5 flex items-center gap-2 text-xs text-neutral-500">
|
<div class="mt-0.5 flex items-center gap-2 text-xs text-neutral-500">
|
||||||
<span v-if="entry.project">{{ entry.project.name }}</span>
|
<span v-if="entry.project">{{ entry.project.name }}</span>
|
||||||
<span v-if="entry.project && entry.description" class="text-neutral-300">·</span>
|
<span v-if="entry.project && stripRichText(entry.description)" class="text-neutral-300">·</span>
|
||||||
<span v-if="entry.description" class="truncate">{{ entry.description }}</span>
|
<span v-if="stripRichText(entry.description)" class="truncate">{{ stripRichText(entry.description) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -68,6 +68,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||||
|
import { stripRichText } from '~/utils/format'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
entries: TimeEntry[]
|
entries: TimeEntry[]
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -10,17 +10,17 @@
|
|||||||
@click="ui.openMobileSidebar()"
|
@click="ui.openMobileSidebar()"
|
||||||
/>
|
/>
|
||||||
<div class="hidden items-center gap-2 lg:flex">
|
<div class="hidden items-center gap-2 lg:flex">
|
||||||
<h1 class="text-lg font-bold tracking-tight">{{ appTitle }}</h1>
|
<h1 class="text-lg font-bold tracking-tight">Lesstime</h1>
|
||||||
<MalioButtonIcon
|
|
||||||
icon="mdi:swap-horizontal"
|
|
||||||
:aria-label="appTitle === 'NeauTime' ? 'Switch to Lesstime' : 'Switch to NeauTime'"
|
|
||||||
variant="ghost"
|
|
||||||
icon-size="18"
|
|
||||||
button-class="text-white/60 hover:bg-primary-600 hover:text-white"
|
|
||||||
@click="toggleTitle"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
|
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:help-circle-outline"
|
||||||
|
aria-label="Centre d'aide"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="22"
|
||||||
|
button-class="text-white/70 hover:bg-primary-600 hover:text-white"
|
||||||
|
@click="navigateTo('/help')"
|
||||||
|
/>
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
:icon="ui.darkMode ? 'mdi:weather-sunny' : 'mdi:weather-night'"
|
:icon="ui.darkMode ? 'mdi:weather-sunny' : 'mdi:weather-night'"
|
||||||
:aria-label="ui.darkMode ? 'Mode clair' : 'Mode sombre'"
|
:aria-label="ui.darkMode ? 'Mode clair' : 'Mode sombre'"
|
||||||
@@ -66,13 +66,6 @@ defineProps<{
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const ui = useUiStore()
|
const ui = useUiStore()
|
||||||
|
|
||||||
const appTitle = ref(localStorage.getItem('appTitle') || 'NeauTime')
|
|
||||||
|
|
||||||
function toggleTitle() {
|
|
||||||
appTitle.value = appTitle.value === 'NeauTime' ? 'Lesstime' : 'NeauTime'
|
|
||||||
localStorage.setItem('appTitle', appTitle.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await auth.logout()
|
await auth.logout()
|
||||||
await navigateTo('/login')
|
await navigateTo('/login')
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Teleport v-if="modelValue" to="body">
|
|
||||||
<Transition name="modal" appear>
|
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
|
||||||
<div class="absolute inset-0 bg-black/30" @click="cancel" />
|
|
||||||
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
|
||||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('taskStatuses.deleteStatus', { label: statusLabel }) }}</h3>
|
|
||||||
|
|
||||||
<p class="mt-3 text-sm text-neutral-600">
|
|
||||||
{{ taskCount > 1 ? $t('taskStatuses.linkedTasksPlural', { count: taskCount }) : $t('taskStatuses.linkedTasks', { count: taskCount }) }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
|
||||||
<MalioSelect
|
|
||||||
v-model="targetStatusId"
|
|
||||||
:options="targetOptions"
|
|
||||||
:label="$t('taskStatuses.moveTo')"
|
|
||||||
:empty-option-label="$t('taskStatuses.backlog')"
|
|
||||||
min-width="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
|
||||||
<MalioButton
|
|
||||||
variant="tertiary"
|
|
||||||
:label="$t('common.cancel')"
|
|
||||||
button-class="w-auto px-4"
|
|
||||||
@click="cancel"
|
|
||||||
/>
|
|
||||||
<MalioButton
|
|
||||||
variant="danger"
|
|
||||||
:label="$t('common.delete')"
|
|
||||||
button-class="w-auto px-4"
|
|
||||||
:disabled="isProcessing"
|
|
||||||
@click="confirm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { TaskStatus } from '~/services/dto/task-status'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
modelValue: boolean
|
|
||||||
statusLabel: string
|
|
||||||
taskCount: number
|
|
||||||
availableStatuses: TaskStatus[]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'update:modelValue', value: boolean): void
|
|
||||||
(e: 'confirm', targetStatusId: number | null): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const targetStatusId = ref<number | null>(null)
|
|
||||||
const isProcessing = ref(false)
|
|
||||||
|
|
||||||
const targetOptions = computed(() =>
|
|
||||||
props.availableStatuses.map(s => ({ label: s.label, value: s.id }))
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(() => props.modelValue, (open) => {
|
|
||||||
if (open) {
|
|
||||||
targetStatusId.value = null
|
|
||||||
isProcessing.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function cancel() {
|
|
||||||
emit('update:modelValue', false)
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirm() {
|
|
||||||
isProcessing.value = true
|
|
||||||
emit('confirm', targetStatusId.value)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.modal-enter-active,
|
|
||||||
.modal-leave-active {
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-enter-from,
|
|
||||||
.modal-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="md-preview" appear>
|
||||||
|
<div v-if="modelValue" class="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||||
|
@click="emit('update:modelValue', false)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Modal -->
|
||||||
|
<div
|
||||||
|
class="relative z-10 flex w-full max-w-2xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5"
|
||||||
|
style="max-height: min(80vh, 700px)"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between border-b border-slate-100 px-6 py-4">
|
||||||
|
<h3 class="text-lg font-semibold text-slate-800">
|
||||||
|
{{ title }}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
class="rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
|
||||||
|
@click="emit('update:modelValue', false)"
|
||||||
|
>
|
||||||
|
<Icon name="heroicons:x-mark" class="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="overflow-y-auto px-6 py-4">
|
||||||
|
<div
|
||||||
|
v-if="content"
|
||||||
|
class="prose prose-slate max-w-none prose-headings:font-semibold prose-a:text-blue-600 prose-code:rounded prose-code:bg-slate-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:text-sm prose-code:before:content-none prose-code:after:content-none prose-pre:bg-slate-900 prose-pre:text-slate-100 prose-pre:overflow-x-auto [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-inherit [&_pre_code]:text-[0.875rem] [&_pre_code]:leading-relaxed"
|
||||||
|
v-html="renderedHtml"
|
||||||
|
/>
|
||||||
|
<p v-else class="text-sm italic text-slate-400">
|
||||||
|
Aucune description
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { marked } from 'marked'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
content: string
|
||||||
|
title?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const renderedHtml = computed(() => {
|
||||||
|
if (!props.content) return ''
|
||||||
|
return marked.parse(props.content, { async: false }) as string
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.md-preview-enter-active,
|
||||||
|
.md-preview-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-preview-enter-from,
|
||||||
|
.md-preview-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -8,12 +8,11 @@
|
|||||||
:error="touched.username && !form.username.trim() ? 'Le nom est requis' : ''"
|
:error="touched.username && !form.username.trim() ? 'Le nom est requis' : ''"
|
||||||
@blur="touched.username = true"
|
@blur="touched.username = true"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputPassword
|
||||||
v-model="form.password"
|
v-model="form.password"
|
||||||
label="Mot de passe"
|
label="Mot de passe"
|
||||||
input-class="w-full"
|
input-class="w-full"
|
||||||
type="password"
|
:hint="isEditing ? 'Laisser vide pour ne pas changer' : ''"
|
||||||
:placeholder="isEditing ? 'Laisser vide pour ne pas changer' : ''"
|
|
||||||
:error="touched.password && !isEditing && !form.password ? 'Le mot de passe est requis' : ''"
|
:error="touched.password && !isEditing && !form.password ? 'Le mot de passe est requis' : ''"
|
||||||
@blur="touched.password = true"
|
@blur="touched.password = true"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Mapping des chemins de dossiers système IMAP vers les clés i18n.
|
||||||
|
* Les clés sont normalisées en minuscules pour la comparaison.
|
||||||
|
* Couvre les variantes OVH courantes (INBOX, INBOX.Sent, Sent, etc.)
|
||||||
|
*/
|
||||||
|
const SYSTEM_FOLDER_MAP: Record<string, string> = {
|
||||||
|
'inbox': 'mail.systemFolder.inbox',
|
||||||
|
'sent': 'mail.systemFolder.sent',
|
||||||
|
'inbox.sent': 'mail.systemFolder.sent',
|
||||||
|
'sent messages': 'mail.systemFolder.sent',
|
||||||
|
'drafts': 'mail.systemFolder.drafts',
|
||||||
|
'inbox.drafts': 'mail.systemFolder.drafts',
|
||||||
|
'archive': 'mail.systemFolder.archive',
|
||||||
|
'archives': 'mail.systemFolder.archive',
|
||||||
|
'inbox.archive': 'mail.systemFolder.archive',
|
||||||
|
'trash': 'mail.systemFolder.trash',
|
||||||
|
'deleted': 'mail.systemFolder.trash',
|
||||||
|
'deleted items': 'mail.systemFolder.trash',
|
||||||
|
'inbox.trash': 'mail.systemFolder.trash',
|
||||||
|
'junk': 'mail.systemFolder.junk',
|
||||||
|
'junk e-mail': 'mail.systemFolder.junk',
|
||||||
|
'spam': 'mail.systemFolder.junk',
|
||||||
|
'inbox.junk': 'mail.systemFolder.junk',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icônes Material Symbols associées aux dossiers système.
|
||||||
|
* Pour les dossiers non reconnus : utiliser une icône générique.
|
||||||
|
*/
|
||||||
|
const SYSTEM_FOLDER_ICONS: Record<string, string> = {
|
||||||
|
'mail.systemFolder.inbox': 'material-symbols:inbox-outline',
|
||||||
|
'mail.systemFolder.sent': 'material-symbols:send-outline',
|
||||||
|
'mail.systemFolder.drafts': 'material-symbols:draft-outline',
|
||||||
|
'mail.systemFolder.archive': 'material-symbols:archive-outline',
|
||||||
|
'mail.systemFolder.trash': 'material-symbols:delete-outline',
|
||||||
|
'mail.systemFolder.junk': 'material-symbols:report-outline',
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_FOLDER_ICON = 'material-symbols:folder-outline'
|
||||||
|
|
||||||
|
export function useSystemFolderLabel() {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne le label traduit d'un dossier système, ou son displayName si inconnu.
|
||||||
|
* @param path - Chemin IMAP du dossier (ex: "INBOX", "INBOX.Sent")
|
||||||
|
* @param displayName - Nom affiché par défaut si non reconnu
|
||||||
|
*/
|
||||||
|
function getFolderLabel(path: string, displayName: string): string {
|
||||||
|
const key = SYSTEM_FOLDER_MAP[path.toLowerCase()]
|
||||||
|
return key ? t(key) : displayName
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne le nom de l'icône Material Symbols pour un dossier.
|
||||||
|
* @param path - Chemin IMAP du dossier
|
||||||
|
*/
|
||||||
|
function getFolderIcon(path: string): string {
|
||||||
|
const key = SYSTEM_FOLDER_MAP[path.toLowerCase()]
|
||||||
|
return key ? (SYSTEM_FOLDER_ICONS[key] ?? DEFAULT_FOLDER_ICON) : DEFAULT_FOLDER_ICON
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indique si un dossier est un dossier système reconnu.
|
||||||
|
*/
|
||||||
|
function isSystemFolder(path: string): boolean {
|
||||||
|
return path.toLowerCase() in SYSTEM_FOLDER_MAP
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getFolderLabel,
|
||||||
|
getFolderIcon,
|
||||||
|
isSystemFolder,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Bienvenue dans Lesstime
|
||||||
|
|
||||||
|
Lesstime est un outil de **gestion de projets** qui combine 4 grandes capacités :
|
||||||
|
|
||||||
|
- 🗂️ **Gestion de projets** avec kanban personnalisable (workflows)
|
||||||
|
- ✅ **Suivi de tâches** avec assignations, priorités, efforts, deadlines, tags
|
||||||
|
- ⏱️ **Time tracking** intégré, lié aux projets et aux tâches
|
||||||
|
- 🎫 **Portail client** pour que tes clients déposent leurs tickets
|
||||||
|
|
||||||
|
## Comprendre les rôles
|
||||||
|
|
||||||
|
| Rôle | Accès |
|
||||||
|
|---|---|
|
||||||
|
| **Admin** | Tout : projets, utilisateurs, intégrations, workflows |
|
||||||
|
| **User** | Ses tâches, time tracking, projets auxquels il a accès |
|
||||||
|
| **Client** | Portal dédié — tickets sur ses projets uniquement |
|
||||||
|
|
||||||
|
## Vues principales
|
||||||
|
|
||||||
|
- **Dashboard** : vue d'ensemble personnelle (KPIs, tâches du jour)
|
||||||
|
- **Mes tâches** : kanban perso groupé par catégorie, toutes projets confondus
|
||||||
|
- **Projets** : un kanban par projet, statuts du workflow associé
|
||||||
|
- **Time tracking** : timer, time entries, vue mois
|
||||||
|
- **Admin** : gestion globale (visible uniquement par les admins)
|
||||||
|
- **Portal** : interface dédiée aux utilisateurs ROLE_CLIENT
|
||||||
|
|
||||||
|
> 💡 **Astuce** : utilise l'avatar en haut à droite pour accéder à ton profil et y générer un **token MCP** (cf. section *Token MCP & API*) pour piloter Lesstime depuis Claude / Cursor.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Projets & Workflows
|
||||||
|
|
||||||
|
## Qu'est-ce qu'un projet ?
|
||||||
|
|
||||||
|
Un projet regroupe un ensemble de **tâches**, **time entries** et éventuellement **tickets client**. Il est défini par :
|
||||||
|
|
||||||
|
- Un **code court** (2-10 lettres majuscules, ex: `SIRH`, `CRM`) qui préfixe les numéros de tâches
|
||||||
|
- Un **client** optionnel (ou interne si null)
|
||||||
|
- Une **couleur** d'identification
|
||||||
|
- Un **workflow** (obligatoire) qui définit ses colonnes kanban
|
||||||
|
|
||||||
|
## Qu'est-ce qu'un workflow ?
|
||||||
|
|
||||||
|
Un **workflow** est un *jeu de statuts kanban* réutilisable. Au lieu d'avoir une liste globale de statuts comme dans la plupart des outils, chaque projet a son propre kanban adapté à sa façon de travailler.
|
||||||
|
|
||||||
|
### Exemple
|
||||||
|
|
||||||
|
| Workflow | Statuts |
|
||||||
|
|---|---|
|
||||||
|
| **Standard** (par défaut) | À faire → En cours → Bloqué → En attente de validation → Terminé |
|
||||||
|
| **DevKanban** | Backlog → Spec → In Dev → Review PR → QA → Done |
|
||||||
|
| **Support** | Nouveau → Diagnostic → Résolu |
|
||||||
|
|
||||||
|
Tu peux créer autant de workflows que tu veux depuis **Admin → Workflows**.
|
||||||
|
|
||||||
|
## Les 5 catégories canoniques
|
||||||
|
|
||||||
|
Chaque statut, peu importe son workflow, appartient à **une catégorie canonique** parmi :
|
||||||
|
|
||||||
|
| Catégorie | Description |
|
||||||
|
|---|---|
|
||||||
|
| `todo` | À faire — pas encore commencé |
|
||||||
|
| `in_progress` | En cours — quelqu'un bosse dessus |
|
||||||
|
| `blocked` | Bloqué — attente d'une dépendance |
|
||||||
|
| `review` | En validation — relecture, PR, QA |
|
||||||
|
| `done` | Terminé — close |
|
||||||
|
|
||||||
|
> 🎯 **Pourquoi des catégories ?** Pour que la vue *Mes tâches* puisse regrouper des tâches venant de projets avec des workflows différents (ex: une tâche "In Dev" de DevKanban et "En cours" de Standard apparaissent dans la même colonne `in_progress`).
|
||||||
|
|
||||||
|
## Changer le workflow d'un projet
|
||||||
|
|
||||||
|
1. Ouvrir le projet → **Modifier le projet** (drawer)
|
||||||
|
2. Section **Workflow** → cliquer sur **Changer de workflow**
|
||||||
|
3. Sélectionner le workflow cible
|
||||||
|
4. **Mapper chaque statut source vers un statut cible** (le mapping est pré-rempli automatiquement par catégorie)
|
||||||
|
5. **Confirmer** — toutes les tâches migrent dans une seule transaction
|
||||||
|
|
||||||
|
### Règles du mapping
|
||||||
|
|
||||||
|
- ✅ Chaque statut actuellement utilisé par une tâche **doit** être mappé (sinon erreur 422)
|
||||||
|
- ✅ Un statut peut être mappé vers `null` → la tâche passe en backlog (sans statut)
|
||||||
|
- ❌ Tu ne peux pas mapper vers un statut qui n'appartient pas au workflow cible
|
||||||
|
|
||||||
|
## Supprimer un workflow
|
||||||
|
|
||||||
|
Tu peux supprimer un workflow uniquement s'il n'est **lié à aucun projet** (HTTP 409 sinon). Réassigne d'abord les projets vers un autre workflow.
|
||||||
|
|
||||||
|
> ⚠️ Le workflow **Standard** ne peut pas être supprimé tant qu'il reste le défaut (un seul workflow peut avoir `isDefault=true` à la fois, garanti par un listener Doctrine).
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Mes tâches & Dashboard
|
||||||
|
|
||||||
|
## Vue *Mes tâches*
|
||||||
|
|
||||||
|
Accessible via la sidebar, c'est ta vue **transverse** : toutes les tâches dont tu es l'**assigné** ou un **collaborateur**, peu importe le projet.
|
||||||
|
|
||||||
|
### Deux modes d'affichage
|
||||||
|
|
||||||
|
#### 1. Kanban (par défaut)
|
||||||
|
|
||||||
|
Regroupé par les **5 catégories canoniques** :
|
||||||
|
|
||||||
|
```
|
||||||
|
À faire → En cours → Bloqué → En validation → Terminé
|
||||||
|
```
|
||||||
|
|
||||||
|
Chaque card affiche :
|
||||||
|
- Le **code projet + numéro** (ex: `SIRH-12`) coloré selon le projet
|
||||||
|
- Un **badge statut** (utile quand des tâches de projets différents cohabitent)
|
||||||
|
- Priorité, tags, deadline, icônes (sync calendrier, récurrence, collaborateurs)
|
||||||
|
- L'**avatar de l'assigné** + bouton timer (▶ / ⏹)
|
||||||
|
|
||||||
|
> 💡 Le **drag-to-status** est intentionnellement désactivé dans *Mes tâches* — pour changer un statut, ouvre la tâche (la valeur dépend du workflow du projet, pas de la catégorie).
|
||||||
|
|
||||||
|
#### 2. Liste
|
||||||
|
|
||||||
|
Vue tableau triable, avec **bulk actions** :
|
||||||
|
- Cocher plusieurs tâches → barre d'actions en haut
|
||||||
|
- Changer statut (désactivé si tâches de **projets différents**), assigné, priorité, effort, groupe
|
||||||
|
- Supprimer en lot
|
||||||
|
|
||||||
|
### Filtres disponibles
|
||||||
|
|
||||||
|
| Filtre | Notes |
|
||||||
|
|---|---|
|
||||||
|
| **Projet** | Restreint à un projet précis |
|
||||||
|
| **Groupe** | Disponible uniquement si un projet est sélectionné |
|
||||||
|
| **Tag** | Tags globaux |
|
||||||
|
| **Priorité / Effort** | |
|
||||||
|
| **Assigné** | Par défaut : toi-même |
|
||||||
|
|
||||||
|
### Tri (vue liste uniquement)
|
||||||
|
|
||||||
|
- Par **deadline** (les plus proches en premier)
|
||||||
|
- Par **scheduled start** (planification calendrier)
|
||||||
|
|
||||||
|
## Vue *Backlog*
|
||||||
|
|
||||||
|
Sous le kanban, les tâches **sans statut** apparaissent dans la section *Backlog*. Pratique pour les idées non encore qualifiées.
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
|
||||||
|
Le **dashboard** (page d'accueil après login) affiche :
|
||||||
|
|
||||||
|
- 📊 **KPIs personnels** : tâches en cours / à faire / en retard
|
||||||
|
- 📈 **Charts** : répartition par statut, par priorité, time tracking cette semaine
|
||||||
|
- 🔔 **Notifications** : assignations, commentaires (cf. cloche en topbar)
|
||||||
|
- ⏱ **Timer actif** s'il y en a un
|
||||||
|
|
||||||
|
> 💡 Tu peux changer le filtre user du dashboard via le sélecteur en haut pour voir les KPIs d'un collègue (utile pour les leads).
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Time tracking
|
||||||
|
|
||||||
|
## Le timer
|
||||||
|
|
||||||
|
Le timer **flottant** est accessible depuis la sidebar ou directement depuis une tâche.
|
||||||
|
|
||||||
|
### Démarrer un timer
|
||||||
|
|
||||||
|
Trois façons :
|
||||||
|
|
||||||
|
1. **Depuis une TaskCard** : clique sur l'icône ▶ à droite de la card
|
||||||
|
2. **Depuis le détail d'une tâche** : bouton *Démarrer le timer*
|
||||||
|
3. **Manuellement** : depuis */time-tracking*, créer une time entry sans tâche
|
||||||
|
|
||||||
|
### Arrêter
|
||||||
|
|
||||||
|
- Clique sur ⏹ sur la card de la tâche en cours
|
||||||
|
- Ou depuis la sidebar (icône timer pulsante en orange `#F18619`)
|
||||||
|
|
||||||
|
> 💡 Un seul timer actif à la fois. Démarrer un nouveau timer arrête automatiquement le précédent.
|
||||||
|
|
||||||
|
## Time entries
|
||||||
|
|
||||||
|
Chaque entrée a :
|
||||||
|
|
||||||
|
| Champ | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Titre** | Description courte (ex: "Réunion daily") |
|
||||||
|
| **Projet** | Obligatoire |
|
||||||
|
| **Tâche** | Optionnel — lie l'entrée à une tâche précise |
|
||||||
|
| **Tags** | Pour catégoriser (ex: "Backend", "Réunion") |
|
||||||
|
| **Début / Fin** | Datetimes — la durée est calculée |
|
||||||
|
| **User** | Qui a fait le travail |
|
||||||
|
|
||||||
|
### Vue *Time tracking*
|
||||||
|
|
||||||
|
Disponible en deux modes :
|
||||||
|
|
||||||
|
- **Vue semaine** : ligne par ligne, par jour
|
||||||
|
- **Vue mois** : agrégation mensuelle, totaux par projet et par tag
|
||||||
|
|
||||||
|
### Filtres
|
||||||
|
|
||||||
|
- **Projet** (server-side)
|
||||||
|
- **Tag** (server-side)
|
||||||
|
- **User** (admin uniquement)
|
||||||
|
- **Période** (date début / date fin)
|
||||||
|
|
||||||
|
## Édition
|
||||||
|
|
||||||
|
- Clique sur une time entry → drawer d'édition
|
||||||
|
- Tu peux modifier projet, tâche, tags, dates a posteriori
|
||||||
|
- La suppression est libre — pense à exporter avant si nécessaire
|
||||||
|
|
||||||
|
## Tags
|
||||||
|
|
||||||
|
Les tags sont **globaux** (partagés entre tous les projets, comme les statuts l'étaient avant les workflows). Définis depuis **Admin → Tags**.
|
||||||
|
|
||||||
|
> 📊 **Cas d'usage typique** : créer un tag par typologie d'activité (Dev, Réunion, Support, Veille) pour pouvoir agréger ton temps en fin de mois.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Détail d'une tâche
|
||||||
|
|
||||||
|
## Champs principaux
|
||||||
|
|
||||||
|
| Champ | Notes |
|
||||||
|
|---|---|
|
||||||
|
| **Numéro** | Auto-incrémenté **par projet** (ex: `SIRH-1`, `SIRH-2`, `CRM-1`…) |
|
||||||
|
| **Titre** | Obligatoire |
|
||||||
|
| **Description** | Markdown supporté (preview disponible) |
|
||||||
|
| **Statut** | Doit appartenir au workflow du projet (sinon erreur 422) |
|
||||||
|
| **Priorité** | Basse / Moyenne / Haute — couleurs personnalisables |
|
||||||
|
| **Effort** | S / M / L / XL / XXL — pour estimer la charge |
|
||||||
|
| **Assigné** | Un seul user responsable |
|
||||||
|
| **Collaborateurs** | Multiples — visibles via icône `mdi:account-group` |
|
||||||
|
| **Groupe** | Optionnel — regroupe des tâches au sein d'un projet |
|
||||||
|
| **Tags** | Globaux, plusieurs par tâche |
|
||||||
|
| **Deadline** | Date — un badge coloré apparaît sur la card |
|
||||||
|
| **Scheduled start / end** | Planification calendrier (sync optionnelle) |
|
||||||
|
|
||||||
|
## Récurrence
|
||||||
|
|
||||||
|
Une tâche peut être **récurrente** (icône 🔁 sur la card) :
|
||||||
|
|
||||||
|
- **Type** : quotidien, hebdomadaire, mensuel
|
||||||
|
- **Intervalle** : tous les N jours/semaines/mois
|
||||||
|
- **Jours de la semaine** (pour le mode hebdomadaire) : `monday`, `tuesday`, etc.
|
||||||
|
|
||||||
|
Chaque occurrence est gérée séparément ; cocher une tâche récurrente comme *Terminée* peut générer l'occurrence suivante selon le pattern.
|
||||||
|
|
||||||
|
## Sync calendrier
|
||||||
|
|
||||||
|
Si Zimbra est configuré (cf. Intégrations), tu peux activer **Sync calendrier** sur une tâche planifiée pour qu'elle apparaisse dans ton calendrier Zimbra (CalDav).
|
||||||
|
|
||||||
|
Icônes correspondantes :
|
||||||
|
- 🟢 `mdi:calendar-check` → sync OK
|
||||||
|
- 🔴 `mdi:alert-circle` → erreur de sync (passe sur l'icône pour le détail)
|
||||||
|
|
||||||
|
## Documents
|
||||||
|
|
||||||
|
Chaque tâche peut avoir des **documents attachés** (PDF, images, etc.) :
|
||||||
|
|
||||||
|
- Drag & drop dans la tâche pour uploader
|
||||||
|
- Validation du **MIME type côté serveur** (pas seulement l'extension)
|
||||||
|
- Téléchargement via lien dédié
|
||||||
|
|
||||||
|
## Liaison Gitea (si configuré)
|
||||||
|
|
||||||
|
Si le projet a un repo Gitea lié, tu peux :
|
||||||
|
|
||||||
|
- **Créer une branche** depuis la tâche : `feature/` `fix/` `refactor/` `hotfix/` `chore/` (5 types disponibles)
|
||||||
|
- Convention de nommage : `<type>/<CODE>-<NUMBER>-<slug>` (ex: `feature/SIRH-12-add-login`)
|
||||||
|
- **Voir les PRs** liées (état CI inclus)
|
||||||
|
|
||||||
|
## Liaison ticket client
|
||||||
|
|
||||||
|
Si la tâche découle d'un ticket client, l'icône 👤 (`heroicons:user-circle`) bleue apparaît avec le numéro du ticket (ex: `CT-001`).
|
||||||
|
|
||||||
|
## Commentaires & notifications
|
||||||
|
|
||||||
|
- Ajouter un commentaire notifie les watchers (assigné, collaborateurs)
|
||||||
|
- Les @mentions notifient l'utilisateur cité
|
||||||
|
- La cloche en topbar (`NotificationBell`) liste toutes les notifications non lues
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Portal client
|
||||||
|
|
||||||
|
> 🎫 Section dédiée aux utilisateurs avec le rôle **ROLE_CLIENT**.
|
||||||
|
|
||||||
|
## Accès
|
||||||
|
|
||||||
|
Les utilisateurs *client* sont **automatiquement redirigés vers `/portal`** après login. Ils ne voient pas les vues internes (projets, time tracking, admin).
|
||||||
|
|
||||||
|
## Ce que voit un client
|
||||||
|
|
||||||
|
- 📋 La liste de ses **projets autorisés** (définis par l'admin dans le user)
|
||||||
|
- 🎫 Sur chaque projet, la liste de ses **tickets** (ses créations uniquement)
|
||||||
|
- ➕ Le bouton **Nouveau ticket** sur chaque projet
|
||||||
|
|
||||||
|
## Soumettre un ticket
|
||||||
|
|
||||||
|
Depuis `/portal/projects/<id>/new-ticket` :
|
||||||
|
|
||||||
|
| Champ | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Type** | `bug` / `improvement` / `other` |
|
||||||
|
| **Titre** | Court et descriptif |
|
||||||
|
| **Description** | Détails — markdown supporté |
|
||||||
|
| **URL** | Optionnel — page où le problème se manifeste |
|
||||||
|
|
||||||
|
Le ticket est automatiquement numéroté **par projet** (ex: `CT-001`).
|
||||||
|
|
||||||
|
## Statuts d'un ticket
|
||||||
|
|
||||||
|
| Statut | Visible côté client | Signification |
|
||||||
|
|---|---|---|
|
||||||
|
| `new` | Oui | Reçu, pas encore traité |
|
||||||
|
| `in_progress` | Oui | Une tâche interne y est liée |
|
||||||
|
| `done` | Oui | Résolu et clôturé |
|
||||||
|
| `rejected` | Oui | Non retenu (avec commentaire explicatif) |
|
||||||
|
|
||||||
|
Le `statusComment` est visible par le client quand fourni.
|
||||||
|
|
||||||
|
## Côté équipe interne
|
||||||
|
|
||||||
|
- Les tickets apparaissent dans **Admin → Tickets client**
|
||||||
|
- On peut **transformer un ticket en tâche** (la tâche garde une référence au ticket — icône 👤 bleue sur la card)
|
||||||
|
- Le client voit l'avancement passer en `in_progress` automatiquement quand une tâche est liée
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Administration
|
||||||
|
|
||||||
|
> 🛡️ Section visible uniquement par les utilisateurs **ROLE_ADMIN**.
|
||||||
|
|
||||||
|
L'admin (`/admin`) est divisé en plusieurs onglets, chacun gérant une ressource globale ou une intégration.
|
||||||
|
|
||||||
|
## Onglet *Clients*
|
||||||
|
|
||||||
|
- Liste des clients (entreprise / organisation)
|
||||||
|
- Champs : nom, email, téléphone, adresse
|
||||||
|
- Lier un client à des projets
|
||||||
|
|
||||||
|
## Onglet *Workflows*
|
||||||
|
|
||||||
|
⭐ **Nouveau** — remplace l'ancien onglet *Statuts*.
|
||||||
|
|
||||||
|
- Lister les workflows existants
|
||||||
|
- **Créer un workflow** : nom, isDefault (un seul à la fois), liste de statuts éditables inline
|
||||||
|
- Chaque statut : libellé, couleur, position, **catégorie** (5 valeurs canoniques), isFinal
|
||||||
|
- **Éditer** un workflow modifie les statuts (sync intelligent : create / update / delete par diff)
|
||||||
|
|
||||||
|
> ⚠️ Supprimer un workflow lié à un projet renvoie une erreur **409**. Réassigne d'abord les projets.
|
||||||
|
|
||||||
|
## Onglet *Efforts*
|
||||||
|
|
||||||
|
- Tailles d'effort (S, M, L, XL, XXL)
|
||||||
|
- Globales (partagées entre tous les projets)
|
||||||
|
|
||||||
|
## Onglet *Priorités*
|
||||||
|
|
||||||
|
- Niveaux de priorité (Basse, Moyenne, Haute) + couleur
|
||||||
|
- Une priorité "Haute" affiche un drapeau rouge `mdi:flag-variant` sur la card
|
||||||
|
|
||||||
|
## Onglet *Tags*
|
||||||
|
|
||||||
|
- Tags globaux (tâches **et** time entries)
|
||||||
|
- Couleur personnalisable
|
||||||
|
- Pas de hiérarchie (flat list)
|
||||||
|
|
||||||
|
## Onglet *Utilisateurs*
|
||||||
|
|
||||||
|
- Créer / éditer / désactiver
|
||||||
|
- Rôles : `ROLE_ADMIN`, `ROLE_USER`, `ROLE_CLIENT`
|
||||||
|
- **ROLE_CLIENT** : associer un *client* et une liste de *projets autorisés*
|
||||||
|
- Reset password depuis l'admin
|
||||||
|
|
||||||
|
> 🔐 Un user *admin+client* (les deux rôles) **n'est pas bloqué** par le middleware portal — le check est sur `ROLE_CLIENT && !ROLE_ADMIN`.
|
||||||
|
|
||||||
|
## Onglet *Gitea*
|
||||||
|
|
||||||
|
- URL serveur + token API
|
||||||
|
- Lier un projet à un repo : `giteaOwner` + `giteaRepo`
|
||||||
|
- Active les fonctionnalités branches / PRs sur les tâches
|
||||||
|
|
||||||
|
## Onglet *BookStack*
|
||||||
|
|
||||||
|
- URL + token API
|
||||||
|
- Lier un projet à un **shelf** BookStack (`bookstackShelfId`)
|
||||||
|
- Les tâches peuvent être liées à des pages BookStack (cf. `TaskBookStackLink`)
|
||||||
|
|
||||||
|
## Onglet *Zimbra*
|
||||||
|
|
||||||
|
- URL serveur + credentials (chiffrés via libsodium)
|
||||||
|
- Configure le calendrier CalDav par défaut
|
||||||
|
- Test de connexion intégré
|
||||||
|
- Active la **sync calendrier** sur les tâches planifiées
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Intégrations
|
||||||
|
|
||||||
|
Lesstime s'intègre avec **3 outils externes** pour fluidifier le workflow dev.
|
||||||
|
|
||||||
|
## 🌳 Gitea
|
||||||
|
|
||||||
|
Lesstime parle à un serveur Gitea pour automatiser les conventions de branches et suivre les PRs.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
1. **Admin → Gitea** : URL serveur + token API
|
||||||
|
2. Sur un projet : définir `giteaOwner` (org/user) et `giteaRepo` (nom du repo)
|
||||||
|
|
||||||
|
### Utilisation
|
||||||
|
|
||||||
|
Sur une tâche, le panneau Gitea propose :
|
||||||
|
|
||||||
|
- **Créer une branche** : choisir un type (`feature` / `fix` / `refactor` / `hotfix` / `chore`)
|
||||||
|
- La branche est nommée automatiquement : `<type>/<PROJECT_CODE>-<NUMBER>-<slug-du-titre>`
|
||||||
|
- **Lister les PRs liées** : par convention, toute PR qui contient `<PROJECT_CODE>-<NUMBER>` dans son nom ou sa description est reliée
|
||||||
|
- **État CI** : ✅ ou ❌ affiché si le repo a des Actions/Workflows configurées
|
||||||
|
|
||||||
|
> 💡 La convention `<PROJECT_CODE>-<NUMBER>` permet à Gitea et Lesstime de se synchroniser **sans webhook** — juste par parsing des noms.
|
||||||
|
|
||||||
|
## 📚 BookStack
|
||||||
|
|
||||||
|
Lien tâche → documentation.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
1. **Admin → BookStack** : URL + token (token ID + token secret, chiffrés via libsodium)
|
||||||
|
2. Sur un projet : définir `bookstackShelfId` + `bookstackShelfName`
|
||||||
|
|
||||||
|
### Utilisation
|
||||||
|
|
||||||
|
- Depuis une tâche : bouton **Lier à une page BookStack**
|
||||||
|
- Sélectionner la page dans le shelf du projet
|
||||||
|
- Le lien est bidirectionnel (BookStack peut afficher les tâches liées)
|
||||||
|
|
||||||
|
## 📅 Zimbra (CalDav)
|
||||||
|
|
||||||
|
Sync calendrier pour les tâches planifiées.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
1. **Admin → Zimbra** :
|
||||||
|
- URL serveur (ex: `https://mail.ovh.com`)
|
||||||
|
- Username (ex: `lesstime@ovh.fr`)
|
||||||
|
- Password (chiffré côté serveur)
|
||||||
|
- Calendar path (ex: `/dav/lesstime@ovh.fr/Calendar/`)
|
||||||
|
- **Test de connexion** intégré
|
||||||
|
2. Active la config (toggle `enabled`)
|
||||||
|
|
||||||
|
### Utilisation
|
||||||
|
|
||||||
|
Sur une tâche avec **scheduled start + end** :
|
||||||
|
|
||||||
|
1. Cocher **Sync calendrier**
|
||||||
|
2. Au save, Lesstime crée/met à jour l'événement CalDav
|
||||||
|
3. L'icône `mdi:calendar-check` (verte) apparaît sur la card si succès
|
||||||
|
4. L'icône `mdi:alert-circle` (rouge) apparaît si erreur — passe dessus pour voir le détail
|
||||||
|
|
||||||
|
### Limites
|
||||||
|
|
||||||
|
- **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)
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Token MCP & API
|
||||||
|
|
||||||
|
Lesstime expose un serveur **MCP** (Model Context Protocol) qui permet à un assistant IA (Claude, Cursor, etc.) de piloter ton instance Lesstime — créer des tâches, lire des projets, démarrer un timer, etc.
|
||||||
|
|
||||||
|
## Générer ton token
|
||||||
|
|
||||||
|
1. Va sur **Profil** (avatar → Profil)
|
||||||
|
2. Section **Token MCP** → **Générer un token**
|
||||||
|
3. **Copie le token immédiatement** — il ne sera plus affiché ensuite
|
||||||
|
|
||||||
|
> 🔐 **Sécurité** : Le token donne accès à toutes les actions de ton compte. Ne le partage jamais. Tu peux le régénérer à tout moment (l'ancien sera révoqué).
|
||||||
|
|
||||||
|
## Configurer Claude Code
|
||||||
|
|
||||||
|
Dans `.mcp.json` (à la racine de ton projet) :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"lesstime": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://ton-instance-lesstime/_mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer TON_TOKEN_ICI"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour une instance locale :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"lesstime-local": {
|
||||||
|
"command": "docker",
|
||||||
|
"args": ["exec", "-i", "php-lesstime-fpm", "php", "bin/console", "mcp:server"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tools disponibles (27 au total)
|
||||||
|
|
||||||
|
### Projets
|
||||||
|
|
||||||
|
- `list-projects`, `get-project`, `create-project`, `update-project`
|
||||||
|
|
||||||
|
### Tâches
|
||||||
|
|
||||||
|
- `list-tasks` (avec filtres : projet, assigné, statut, archived…)
|
||||||
|
- `get-task`, `create-task`, `update-task`, `delete-task`
|
||||||
|
|
||||||
|
### Métadonnées
|
||||||
|
|
||||||
|
- `list-statuses` (param **`projectId`** optionnel — sans : tous les statuts ; avec : statuts du workflow du projet)
|
||||||
|
- `list-priorities`, `list-efforts`, `list-tags`
|
||||||
|
|
||||||
|
### Workflows ⭐ Nouveau
|
||||||
|
|
||||||
|
- `list-workflows` — liste tous les workflows avec leurs statuts groupés
|
||||||
|
- `switch-project-workflow` (ROLE_ADMIN) — change le workflow d'un projet avec mapping
|
||||||
|
|
||||||
|
### Time tracking
|
||||||
|
|
||||||
|
- `list-time-entries`, `create-time-entry`, `update-time-entry`, `delete-time-entry`
|
||||||
|
|
||||||
|
### Récurrence
|
||||||
|
|
||||||
|
- `create-task-recurrence`, `update-task-recurrence`, `delete-task-recurrence`
|
||||||
|
|
||||||
|
### Groupes / Users / Clients
|
||||||
|
|
||||||
|
- `list-groups`, `create-group`, `update-group`
|
||||||
|
- `list-users`, `list-clients`
|
||||||
|
|
||||||
|
## Règles importantes
|
||||||
|
|
||||||
|
> ⚠️ **Statut hors workflow rejeté** : si tu appelles `create-task` ou `update-task` avec un `status` qui n'appartient pas au workflow du projet, l'appel est rejeté avec **422 Validation error**. Utilise `list-statuses(projectId)` pour découvrir les statuts valides du projet.
|
||||||
|
|
||||||
|
## Exemples de prompts
|
||||||
|
|
||||||
|
```
|
||||||
|
"Crée une tâche dans Lesstime sur le projet SIRH avec le titre
|
||||||
|
'Ajouter l'export PDF' et la priorité Haute, assignée à alice"
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
"Liste mes tâches en cours dans le projet CRM"
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
"Démarre un timer sur la tâche SIRH-12 avec le tag Backend"
|
||||||
|
```
|
||||||
|
|
||||||
|
L'agent appelle les bons tools tout seul si la description est claire.
|
||||||
@@ -56,6 +56,37 @@
|
|||||||
"moveTo": "Déplacer vers",
|
"moveTo": "Déplacer vers",
|
||||||
"backlog": "Backlog (sans statut)"
|
"backlog": "Backlog (sans statut)"
|
||||||
},
|
},
|
||||||
|
"workflows": {
|
||||||
|
"title": "Workflows",
|
||||||
|
"addWorkflow": "Ajouter un workflow",
|
||||||
|
"editWorkflow": "Modifier le workflow",
|
||||||
|
"name": "Nom",
|
||||||
|
"isDefault": "Workflow par défaut",
|
||||||
|
"statuses": "Statuts",
|
||||||
|
"addStatus": "Ajouter un statut",
|
||||||
|
"category": "Catégorie",
|
||||||
|
"created": "Workflow créé",
|
||||||
|
"updated": "Workflow mis à jour",
|
||||||
|
"deleted": "Workflow supprimé",
|
||||||
|
"switched": "Workflow du projet changé",
|
||||||
|
"switchTitle": "Changer de workflow",
|
||||||
|
"switchTargetLabel": "Nouveau workflow",
|
||||||
|
"switchMappingTitle": "Mapping des statuts",
|
||||||
|
"switchSourceCol": "Statut actuel",
|
||||||
|
"switchTargetCol": "Statut cible",
|
||||||
|
"switchTaskCountCol": "Tâches",
|
||||||
|
"switchToBacklog": "Mapper vers le backlog",
|
||||||
|
"switchConfirm": "Confirmer la migration",
|
||||||
|
"switchSummary": "{count} tâche(s) migrée(s), projet sur workflow « {name} »",
|
||||||
|
"deleteUsedBy": "Workflow utilisé par {count} projet(s) — impossible de supprimer.",
|
||||||
|
"categories": {
|
||||||
|
"todo": "À faire",
|
||||||
|
"in_progress": "En cours",
|
||||||
|
"blocked": "Bloqué",
|
||||||
|
"review": "En validation",
|
||||||
|
"done": "Terminé"
|
||||||
|
}
|
||||||
|
},
|
||||||
"taskEfforts": {
|
"taskEfforts": {
|
||||||
"created": "Effort créé avec succès.",
|
"created": "Effort créé avec succès.",
|
||||||
"updated": "Effort mis à jour avec succès.",
|
"updated": "Effort mis à jour avec succès.",
|
||||||
@@ -205,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",
|
||||||
@@ -393,7 +425,21 @@
|
|||||||
"title": "Mon profil",
|
"title": "Mon profil",
|
||||||
"changeAvatar": "Changer l'avatar",
|
"changeAvatar": "Changer l'avatar",
|
||||||
"removeAvatar": "Supprimer l'avatar",
|
"removeAvatar": "Supprimer l'avatar",
|
||||||
"cropAvatar": "Recadrer l'avatar"
|
"cropAvatar": "Recadrer l'avatar",
|
||||||
|
"apiToken": {
|
||||||
|
"title": "Token API MCP",
|
||||||
|
"help": "Utilisé pour authentifier le serveur MCP HTTP (à coller dans le header Authorization: Bearer …). Ne pas partager.",
|
||||||
|
"label": "Token",
|
||||||
|
"empty": "Aucun token généré pour le moment.",
|
||||||
|
"generate": "Générer un token",
|
||||||
|
"regenerate": "Régénérer",
|
||||||
|
"copy": "Copier",
|
||||||
|
"copied": "Token copié dans le presse-papiers.",
|
||||||
|
"copyFailed": "Impossible de copier le token.",
|
||||||
|
"regenerated": "Nouveau token généré. L'ancien token est désormais invalide.",
|
||||||
|
"confirmTitle": "Régénérer le token MCP ?",
|
||||||
|
"confirmMessage": "L'ancien token sera immédiatement invalidé. Tous les clients MCP utilisant ce token devront être reconfigurés."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"bookstack": {
|
"bookstack": {
|
||||||
"settings": {
|
"settings": {
|
||||||
@@ -447,5 +493,120 @@
|
|||||||
"weekly": "Hebdomadaire",
|
"weekly": "Hebdomadaire",
|
||||||
"monthly": "Mensuel",
|
"monthly": "Mensuel",
|
||||||
"yearly": "Annuel"
|
"yearly": "Annuel"
|
||||||
|
},
|
||||||
|
"mail": {
|
||||||
|
"title": "Messagerie",
|
||||||
|
"sidebar": {
|
||||||
|
"title": "Messagerie",
|
||||||
|
"ariaLabel": "Accès à la messagerie, {count} messages non lus"
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"title": "Configuration messagerie",
|
||||||
|
"protocol": "Protocole",
|
||||||
|
"imapSection": "Réception (IMAP)",
|
||||||
|
"smtpSection": "Envoi (SMTP)",
|
||||||
|
"host": "Serveur",
|
||||||
|
"port": "Port",
|
||||||
|
"encryption": "Chiffrement",
|
||||||
|
"username": "Adresse e-mail",
|
||||||
|
"password": "Mot de passe",
|
||||||
|
"passwordSet": "Mot de passe déjà configuré — laisser vide pour conserver",
|
||||||
|
"sentFolderPath": "Dossier des envois",
|
||||||
|
"enabled": "Activer la synchronisation mail",
|
||||||
|
"test": "Tester la connexion",
|
||||||
|
"testSuccess": "Connexion IMAP réussie",
|
||||||
|
"testFailed": "Échec de connexion",
|
||||||
|
"save": "Enregistrer",
|
||||||
|
"saveSuccess": "Configuration enregistrée",
|
||||||
|
"ovhDefaultsHelp": "OVH : ssl0.ovh.net (port 993 IMAP / 465 SMTP)"
|
||||||
|
},
|
||||||
|
"folders": "Dossiers",
|
||||||
|
"messages": "Messages",
|
||||||
|
"viewer": "Lecture",
|
||||||
|
"empty": {
|
||||||
|
"folder": "Aucun dossier disponible.",
|
||||||
|
"list": "Aucun message dans ce dossier.",
|
||||||
|
"viewer": "Sélectionnez un message pour le lire."
|
||||||
|
},
|
||||||
|
"folderTree": {
|
||||||
|
"expand": "Déplier le dossier",
|
||||||
|
"collapse": "Replier le dossier"
|
||||||
|
},
|
||||||
|
"systemFolder": {
|
||||||
|
"inbox": "Boîte de réception",
|
||||||
|
"sent": "Éléments envoyés",
|
||||||
|
"drafts": "Brouillons",
|
||||||
|
"archive": "Archives",
|
||||||
|
"trash": "Corbeille",
|
||||||
|
"junk": "Indésirables"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"refresh": "Actualiser",
|
||||||
|
"createTask": "Créer une tâche",
|
||||||
|
"linkTask": "Lier à une tâche",
|
||||||
|
"markRead": "Marquer comme lu",
|
||||||
|
"markUnread": "Marquer comme non lu",
|
||||||
|
"flag": "Marquer important",
|
||||||
|
"unflag": "Retirer l'importance",
|
||||||
|
"download": "Télécharger",
|
||||||
|
"showImages": "Afficher les images"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"syncFailed": "Erreur lors de la synchronisation.",
|
||||||
|
"fetchFailed": "Impossible de charger les messages.",
|
||||||
|
"notAuthorized": "Vous n'avez pas accès à la messagerie."
|
||||||
|
},
|
||||||
|
"configuration": {
|
||||||
|
"saved": "Configuration mail enregistrée."
|
||||||
|
},
|
||||||
|
"task": {
|
||||||
|
"created": "Tâche créée depuis le mail.",
|
||||||
|
"linked": "Mail lié à la tâche.",
|
||||||
|
"unlinked": "Lien supprimé."
|
||||||
|
},
|
||||||
|
"createTaskModal": {
|
||||||
|
"title": "Créer une tâche depuis ce mail",
|
||||||
|
"submit": "Créer la tâche",
|
||||||
|
"projectLabel": "Projet *",
|
||||||
|
"projectPlaceholder": "Sélectionner un projet",
|
||||||
|
"groupLabel": "Groupe (optionnel)",
|
||||||
|
"groupPlaceholder": "Aucun groupe",
|
||||||
|
"statusLabel": "Statut",
|
||||||
|
"assigneeLabel": "Assigné à",
|
||||||
|
"assigneePlaceholder": "Aucun",
|
||||||
|
"titleHint": "Le titre sera rempli depuis le sujet du mail.",
|
||||||
|
"descriptionHint": "La description sera remplie depuis le corps du mail."
|
||||||
|
},
|
||||||
|
"linkTaskModal": {
|
||||||
|
"title": "Lier à une tâche existante",
|
||||||
|
"submit": "Lier la tâche",
|
||||||
|
"searchPlaceholder": "Rechercher une tâche par titre…",
|
||||||
|
"projectFilter": "Filtrer par projet",
|
||||||
|
"projectAll": "Tous les projets",
|
||||||
|
"empty": "Aucune tâche correspondante.",
|
||||||
|
"loading": "Recherche en cours…"
|
||||||
|
},
|
||||||
|
"taskTab": {
|
||||||
|
"title": "Mails",
|
||||||
|
"empty": "Aucun mail lié à cette tâche.",
|
||||||
|
"openInMailer": "Ouvrir dans la messagerie",
|
||||||
|
"unlinkConfirm": "Délier ce mail ?"
|
||||||
|
},
|
||||||
|
"sync": {
|
||||||
|
"dispatched": "Synchronisation lancée en arrière-plan."
|
||||||
|
},
|
||||||
|
"attachments": "Pièces jointes",
|
||||||
|
"noAttachments": "Aucune pièce jointe.",
|
||||||
|
"from": "De",
|
||||||
|
"to": "À",
|
||||||
|
"cc": "Cc",
|
||||||
|
"date": "Date",
|
||||||
|
"subject": "Sujet",
|
||||||
|
"noSubject": "(Sans objet)",
|
||||||
|
"loadMore": "Charger plus",
|
||||||
|
"loading": "Chargement…",
|
||||||
|
"hasAttachments": "Pièces jointes",
|
||||||
|
"unread": "non lu | non lus",
|
||||||
|
"remoteImagesBlocked": "Les images distantes sont masquées pour votre sécurité."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,23 @@
|
|||||||
:collapsed="sidebarIsCollapsed"
|
:collapsed="sidebarIsCollapsed"
|
||||||
@click="ui.closeMobileSidebar()"
|
@click="ui.closeMobileSidebar()"
|
||||||
/>
|
/>
|
||||||
|
<div v-if="isMailVisible" class="relative">
|
||||||
|
<SidebarLink
|
||||||
|
to="/mail"
|
||||||
|
icon="mdi:email-outline"
|
||||||
|
:label="$t('mail.sidebar.title')"
|
||||||
|
:collapsed="sidebarIsCollapsed"
|
||||||
|
@click="ui.closeMobileSidebar()"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-if="mailStore.globalUnreadCount > 0"
|
||||||
|
class="pointer-events-none absolute right-3 top-1/2 flex h-5 min-w-5 -translate-y-1/2 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
|
||||||
|
:class="{ 'right-1 top-1 translate-y-0': sidebarIsCollapsed }"
|
||||||
|
:aria-label="`${mailStore.globalUnreadCount} messages non lus`"
|
||||||
|
>
|
||||||
|
{{ mailStore.globalUnreadCount > 99 ? '99+' : mailStore.globalUnreadCount }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<SidebarLink
|
<SidebarLink
|
||||||
to="/projects"
|
to="/projects"
|
||||||
icon="mdi:folder-outline"
|
icon="mdi:folder-outline"
|
||||||
@@ -162,9 +179,18 @@ import { extractHydraMembers } from '~/utils/api'
|
|||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const ui = useUiStore()
|
const ui = useUiStore()
|
||||||
|
const mailStore = useMailStore()
|
||||||
const {version} = useAppVersion()
|
const {version} = useAppVersion()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
|
const isMailVisible = computed(() => {
|
||||||
|
const roles: string[] = auth.user?.roles ?? []
|
||||||
|
const isClientOnly = roles.includes('ROLE_CLIENT')
|
||||||
|
&& !roles.includes('ROLE_ADMIN')
|
||||||
|
&& !roles.includes('ROLE_USER')
|
||||||
|
return !isClientOnly && (roles.includes('ROLE_USER') || roles.includes('ROLE_ADMIN'))
|
||||||
|
})
|
||||||
|
|
||||||
// On mobile, sidebar is always expanded (not collapsed icon mode)
|
// On mobile, sidebar is always expanded (not collapsed icon mode)
|
||||||
const sidebarIsCollapsed = computed(() => {
|
const sidebarIsCollapsed = computed(() => {
|
||||||
if (ui.sidebarOpen) return false
|
if (ui.sidebarOpen) return false
|
||||||
@@ -207,6 +233,17 @@ watch(
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
timerStore.fetchActive()
|
timerStore.fetchActive()
|
||||||
|
if (isMailVisible.value) {
|
||||||
|
mailStore.startPolling()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => auth.user, (user) => {
|
||||||
|
if (!user) {
|
||||||
|
mailStore.stopPolling()
|
||||||
|
} else if (isMailVisible.value) {
|
||||||
|
mailStore.startPolling()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const completeDrawerOpen = ref(false)
|
const completeDrawerOpen = ref(false)
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ['markdown-it-task-lists'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
toast: {
|
toast: {
|
||||||
settings: {
|
settings: {
|
||||||
|
|||||||
Generated
+885
-63
File diff suppressed because it is too large
Load Diff
@@ -11,13 +11,16 @@
|
|||||||
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@malio/layer-ui": "^1.2.0",
|
"@malio/layer-ui": "^1.4.8",
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/i18n": "^10.2.3",
|
"@nuxtjs/i18n": "^10.2.3",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
"@pinia/nuxt": "^0.11.3",
|
"@pinia/nuxt": "^0.11.3",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@vuepic/vue-datepicker": "^12.1.0",
|
"@vuepic/vue-datepicker": "^12.1.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
"dompurify": "^3.4.5",
|
||||||
|
"marked": "^18.0.0",
|
||||||
"nuxt": "^4.3.1",
|
"nuxt": "^4.3.1",
|
||||||
"nuxt-toast": "^1.4.0",
|
"nuxt-toast": "^1.4.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
@@ -25,5 +28,8 @@
|
|||||||
"vue-advanced-cropper": "^2.8.9",
|
"vue-advanced-cropper": "^2.8.9",
|
||||||
"vue-chartjs": "^5.3.3",
|
"vue-chartjs": "^5.3.3",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/dompurify": "^3.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<AdminClientTab v-if="activeTab === 'clients'" />
|
<AdminClientTab v-if="activeTab === 'clients'" />
|
||||||
<AdminStatusTab v-if="activeTab === 'statuses'" />
|
<AdminWorkflowTab v-if="activeTab === 'workflows'" />
|
||||||
<AdminEffortTab v-if="activeTab === 'efforts'" />
|
<AdminEffortTab v-if="activeTab === 'efforts'" />
|
||||||
<AdminPriorityTab v-if="activeTab === 'priorities'" />
|
<AdminPriorityTab v-if="activeTab === 'priorities'" />
|
||||||
<AdminTagTab v-if="activeTab === 'tags'" />
|
<AdminTagTab v-if="activeTab === 'tags'" />
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
<AdminGiteaTab v-if="activeTab === 'gitea'" />
|
<AdminGiteaTab v-if="activeTab === 'gitea'" />
|
||||||
<AdminBookStackTab v-if="activeTab === 'bookstack'" />
|
<AdminBookStackTab v-if="activeTab === 'bookstack'" />
|
||||||
<AdminZimbraTab v-if="activeTab === 'zimbra'" />
|
<AdminZimbraTab v-if="activeTab === 'zimbra'" />
|
||||||
|
<AdminMailTab v-if="activeTab === 'mail'" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -40,7 +41,7 @@ useHead({ title: 'Administration' })
|
|||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ key: 'clients', label: 'Clients' },
|
{ key: 'clients', label: 'Clients' },
|
||||||
{ key: 'statuses', label: 'Statuts' },
|
{ key: 'workflows', label: 'Workflows' },
|
||||||
{ key: 'efforts', label: 'Efforts' },
|
{ key: 'efforts', label: 'Efforts' },
|
||||||
{ key: 'priorities', label: 'Priorités' },
|
{ key: 'priorities', label: 'Priorités' },
|
||||||
{ key: 'tags', label: 'Tags' },
|
{ key: 'tags', label: 'Tags' },
|
||||||
@@ -48,6 +49,7 @@ const tabs = [
|
|||||||
{ key: 'gitea', label: 'Gitea' },
|
{ key: 'gitea', label: 'Gitea' },
|
||||||
{ key: 'bookstack', label: 'BookStack' },
|
{ key: 'bookstack', label: 'BookStack' },
|
||||||
{ key: 'zimbra', label: 'Zimbra' },
|
{ key: 'zimbra', label: 'Zimbra' },
|
||||||
|
{ key: 'mail', label: 'Mail' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
type TabKey = typeof tabs[number]['key']
|
type TabKey = typeof tabs[number]['key']
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { marked } from 'marked'
|
||||||
|
|
||||||
|
useHead({ title: 'Aide' })
|
||||||
|
|
||||||
|
type Section = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
icon: string
|
||||||
|
accent: string
|
||||||
|
roles: ('admin' | 'user' | 'client')[]
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawModules = import.meta.glob('~/content/help/*.md', { eager: true, query: '?raw', import: 'default' }) as Record<string, string>
|
||||||
|
|
||||||
|
const META: Record<string, { title: string, icon: string, accent: string, roles: ('admin' | 'user' | 'client')[] }> = {
|
||||||
|
'01-getting-started': { title: 'Bienvenue', icon: 'mdi:hand-wave', accent: 'from-amber-400 to-rose-500', roles: ['admin', 'user', 'client'] },
|
||||||
|
'02-projects-workflows': { title: 'Projets & Workflows', icon: 'mdi:view-column-outline', accent: 'from-indigo-500 to-fuchsia-500', roles: ['admin', 'user'] },
|
||||||
|
'03-my-tasks': { title: 'Mes tâches', icon: 'mdi:checkbox-marked-circle-outline', accent: 'from-sky-500 to-cyan-500', roles: ['admin', 'user'] },
|
||||||
|
'04-time-tracking': { title: 'Time tracking', icon: 'mdi:timer-outline', accent: 'from-emerald-500 to-teal-500', roles: ['admin', 'user'] },
|
||||||
|
'05-tasks-detail': { title: 'Tâches en détail', icon: 'mdi:file-document-edit-outline', accent: 'from-violet-500 to-purple-600', roles: ['admin', 'user'] },
|
||||||
|
'06-client-portal': { title: 'Portal client', icon: 'mdi:account-tie-outline', accent: 'from-orange-500 to-amber-500', roles: ['admin', 'client'] },
|
||||||
|
'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'] },
|
||||||
|
'09-mcp-api': { title: 'Token MCP & API', icon: 'mdi:robot-outline', accent: 'from-slate-700 to-slate-900', roles: ['admin', 'user'] },
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections = computed<Section[]>(() => {
|
||||||
|
return Object.entries(rawModules).map(([path, raw]) => {
|
||||||
|
const id = path.split('/').pop()!.replace(/\.md$/, '')
|
||||||
|
const meta = META[id] ?? { title: id, icon: 'mdi:file-document-outline', accent: 'from-neutral-500 to-neutral-700', roles: ['admin', 'user', 'client'] as ('admin' | 'user' | 'client')[] }
|
||||||
|
return { id, ...meta, content: raw }
|
||||||
|
}).sort((a, b) => a.id.localeCompare(b.id))
|
||||||
|
})
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
const userRole = computed<'admin' | 'user' | 'client'>(() => {
|
||||||
|
const roles = auth.user?.roles ?? []
|
||||||
|
if (roles.includes('ROLE_ADMIN')) return 'admin'
|
||||||
|
if (roles.includes('ROLE_CLIENT')) return 'client'
|
||||||
|
return 'user'
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleSections = computed(() =>
|
||||||
|
sections.value.filter(s => s.roles.includes(userRole.value)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const activeId = ref(visibleSections.value[0]?.id ?? '')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const hash = (route.query.section as string) ?? route.hash.replace('#', '')
|
||||||
|
if (hash && visibleSections.value.some(s => s.id === hash)) {
|
||||||
|
activeId.value = hash
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(activeId, (id) => {
|
||||||
|
router.replace({ query: { ...route.query, section: id } })
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeSection = computed(() => visibleSections.value.find(s => s.id === activeId.value) ?? visibleSections.value[0])
|
||||||
|
|
||||||
|
const renderedHtml = computed(() => {
|
||||||
|
if (!activeSection.value) return ''
|
||||||
|
return marked.parse(activeSection.value.content, { async: false }) as string
|
||||||
|
})
|
||||||
|
|
||||||
|
const prevSection = computed(() => {
|
||||||
|
const idx = visibleSections.value.findIndex(s => s.id === activeId.value)
|
||||||
|
return idx > 0 ? visibleSections.value[idx - 1] : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const nextSection = computed(() => {
|
||||||
|
const idx = visibleSections.value.findIndex(s => s.id === activeId.value)
|
||||||
|
return idx >= 0 && idx < visibleSections.value.length - 1 ? visibleSections.value[idx + 1] : null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex min-h-[calc(100vh-60px)] flex-col lg:flex-row">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="shrink-0 border-b border-neutral-200 bg-gradient-to-b from-white to-neutral-50 px-3 py-4 lg:w-72 lg:border-b-0 lg:border-r lg:px-4 lg:py-6">
|
||||||
|
<div class="mb-4 flex items-center gap-2 lg:mb-6">
|
||||||
|
<div class="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 text-white shadow-sm">
|
||||||
|
<Icon name="mdi:lifebuoy" size="20" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-base font-bold text-neutral-900">Centre d'aide</h1>
|
||||||
|
<p class="text-xs text-neutral-500">Lesstime — Guide utilisateur</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="flex flex-row gap-1 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible lg:pb-0">
|
||||||
|
<button
|
||||||
|
v-for="section in visibleSections"
|
||||||
|
:key="section.id"
|
||||||
|
type="button"
|
||||||
|
class="group flex shrink-0 items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm font-medium transition-all lg:shrink"
|
||||||
|
:class="activeId === section.id
|
||||||
|
? 'bg-white text-neutral-900 shadow-sm ring-1 ring-neutral-200'
|
||||||
|
: 'text-neutral-600 hover:bg-white hover:text-neutral-900'"
|
||||||
|
@click="activeId = section.id"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br text-white shadow-sm"
|
||||||
|
:class="section.accent"
|
||||||
|
>
|
||||||
|
<Icon :name="section.icon" size="16" />
|
||||||
|
</span>
|
||||||
|
<span class="whitespace-nowrap lg:whitespace-normal">{{ section.title }}</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<main class="flex-1 px-4 py-6 sm:px-8 lg:px-12 lg:py-10">
|
||||||
|
<div v-if="activeSection" class="mx-auto max-w-3xl">
|
||||||
|
<!-- Hero header -->
|
||||||
|
<div
|
||||||
|
class="mb-8 overflow-hidden rounded-2xl bg-gradient-to-br p-6 text-white shadow-lg sm:p-8"
|
||||||
|
:class="activeSection.accent"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-white/20 backdrop-blur-sm">
|
||||||
|
<Icon :name="activeSection.icon" size="28" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wider text-white/80">Section</p>
|
||||||
|
<h2 class="text-2xl font-bold tracking-tight sm:text-3xl">{{ activeSection.title }}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Markdown content -->
|
||||||
|
<article
|
||||||
|
class="prose prose-neutral max-w-none prose-headings:font-bold prose-headings:tracking-tight prose-h1:hidden prose-h2:mt-10 prose-h2:border-b prose-h2:border-neutral-200 prose-h2:pb-2 prose-h3:text-neutral-800 prose-a:text-primary-600 prose-strong:text-neutral-900 prose-code:rounded prose-code:bg-neutral-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:text-sm prose-code:font-medium prose-code:text-rose-600 prose-code:before:content-none prose-code:after:content-none prose-pre:rounded-xl prose-pre:bg-slate-900 prose-table:border prose-table:border-neutral-200 prose-th:bg-neutral-50 prose-th:px-3 prose-th:py-2 prose-td:px-3 prose-td:py-2 prose-blockquote:rounded-r-lg prose-blockquote:border-l-4 prose-blockquote:border-amber-400 prose-blockquote:bg-amber-50 prose-blockquote:px-4 prose-blockquote:py-2 prose-blockquote:not-italic prose-blockquote:text-amber-900"
|
||||||
|
v-html="renderedHtml"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Footer nav -->
|
||||||
|
<div class="mt-12 flex items-center justify-between border-t border-neutral-200 pt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-neutral-900 disabled:invisible"
|
||||||
|
:disabled="!prevSection"
|
||||||
|
@click="prevSection && (activeId = prevSection.id)"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:arrow-left" size="18" />
|
||||||
|
<span>{{ prevSection?.title ?? '' }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-neutral-900 disabled:invisible"
|
||||||
|
:disabled="!nextSection"
|
||||||
|
@click="nextSection && (activeId = nextSection.id)"
|
||||||
|
>
|
||||||
|
<span>{{ nextSection?.title ?? '' }}</span>
|
||||||
|
<Icon name="mdi:arrow-right" size="18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -93,11 +93,22 @@ const isWeekPeriod = computed(() =>
|
|||||||
selectedPeriod.value === 'thisWeek' || selectedPeriod.value === 'lastWeek'
|
selectedPeriod.value === 'thisWeek' || selectedPeriod.value === 'lastWeek'
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Filtered data (client-side project filter) ──
|
// ── Filtered data (client-side project + user filter) ──
|
||||||
|
|
||||||
|
const effectiveUserId = computed(() => selectedUserId.value ?? auth.user?.id ?? null)
|
||||||
|
|
||||||
const tasks = computed(() => {
|
const tasks = computed(() => {
|
||||||
if (!selectedProjectId.value) return allTasks.value
|
let result = allTasks.value
|
||||||
return allTasks.value.filter(t => t.project?.id === selectedProjectId.value)
|
if (selectedProjectId.value) {
|
||||||
|
result = result.filter(t => t.project?.id === selectedProjectId.value)
|
||||||
|
}
|
||||||
|
if (selectedUserId.value) {
|
||||||
|
result = result.filter(t =>
|
||||||
|
t.assignee?.id === selectedUserId.value
|
||||||
|
|| t.collaborators?.some(c => c.id === selectedUserId.value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
const timeEntries = computed(() => {
|
const timeEntries = computed(() => {
|
||||||
@@ -172,7 +183,10 @@ const totalHoursThisWeek = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const myTasks = computed(() =>
|
const myTasks = computed(() =>
|
||||||
tasks.value.filter(t => t.assignee?.id === auth.user?.id)
|
tasks.value.filter(t =>
|
||||||
|
t.assignee?.id === effectiveUserId.value
|
||||||
|
|| t.collaborators?.some(c => c.id === effectiveUserId.value),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
const myTasksDone = computed(() =>
|
const myTasksDone = computed(() =>
|
||||||
|
|||||||
@@ -17,24 +17,18 @@
|
|||||||
v-model="username"
|
v-model="username"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<MalioInputPassword
|
||||||
<label class="text-sm font-semibold text-neutral-700" for="password">
|
|
||||||
Mot de passe
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
v-model="password"
|
v-model="password"
|
||||||
type="password"
|
label="Mot de passe"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
input-class="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<MalioButton
|
<MalioButton
|
||||||
label="Se connecter"
|
label="Se connecter"
|
||||||
button-class="w-full"
|
button-class="w-full"
|
||||||
|
type="submit"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
@click="handleSubmit"
|
|
||||||
/>
|
/>
|
||||||
<p class="font-bold">v{{ version }}</p>
|
<p class="font-bold">v{{ version }}</p>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Task } from '~/services/dto/task'
|
||||||
|
import { useMailStore } from '~/stores/mail'
|
||||||
|
import { useAuthStore } from '~/stores/auth'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
useHead({ title: t('mail.title') })
|
||||||
|
|
||||||
|
// ─── Contrôle d'accès ROLE_CLIENT ─────────────────────────────────────────
|
||||||
|
// Le middleware global gère auth + ROLE_CLIENT → /portal. Ici : double check
|
||||||
|
// en SPA car la session peut être hydratée après le rendu initial.
|
||||||
|
|
||||||
|
const isClientOnly = computed(() =>
|
||||||
|
auth.user?.roles?.includes('ROLE_CLIENT') === true
|
||||||
|
&& auth.user?.roles?.includes('ROLE_ADMIN') !== true,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isClientOnly.value) {
|
||||||
|
await navigateTo('/portal')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Store ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const store = useMailStore()
|
||||||
|
const {
|
||||||
|
folderTree,
|
||||||
|
selectedFolderPath,
|
||||||
|
messages,
|
||||||
|
messagesLoading,
|
||||||
|
hasMoreMessages,
|
||||||
|
selectedMessageId,
|
||||||
|
selectedMessageDetail,
|
||||||
|
detailLoading,
|
||||||
|
} = storeToRefs(store)
|
||||||
|
|
||||||
|
// ─── Init : charge les dossiers + deep-link ───────────────────────────────
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (isClientOnly.value) {
|
||||||
|
router.replace('/portal')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (folderTree.value.length === 0) {
|
||||||
|
await store.fetchFolders()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedFolderPath.value && folderTree.value.length > 0) {
|
||||||
|
const inbox = folderTree.value.find((f) => f.path.toUpperCase() === 'INBOX')
|
||||||
|
const first = folderTree.value[0]
|
||||||
|
const target = inbox?.path ?? first?.path
|
||||||
|
if (target) {
|
||||||
|
await store.selectFolder(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageIdParam = route.query.messageId
|
||||||
|
if (messageIdParam) {
|
||||||
|
const id = parseInt(String(messageIdParam), 10)
|
||||||
|
if (!isNaN(id)) {
|
||||||
|
await store.selectMessage(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── Handlers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function handleFolderSelect(path: string): Promise<void> {
|
||||||
|
await store.selectFolder(path)
|
||||||
|
if (route.query.messageId) {
|
||||||
|
const nextQuery = { ...route.query }
|
||||||
|
delete nextQuery.messageId
|
||||||
|
router.replace({ query: nextQuery })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMessageSelect(id: number): Promise<void> {
|
||||||
|
await store.selectMessage(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLoadMore(): void {
|
||||||
|
store.fetchMessages(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Modals Phase 6 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const showCreateTaskModal = ref(false)
|
||||||
|
const showLinkTaskModal = ref(false)
|
||||||
|
const activeMailIdForModal = ref<number | null>(null)
|
||||||
|
|
||||||
|
function handleCreateTask(mailId: number): void {
|
||||||
|
activeMailIdForModal.value = mailId
|
||||||
|
showCreateTaskModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLinkTask(mailId: number): void {
|
||||||
|
activeMailIdForModal.value = mailId
|
||||||
|
showLinkTaskModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTaskCreated(_task: Task): void {
|
||||||
|
showCreateTaskModal.value = false
|
||||||
|
// La tâche est créée et liée côté backend — toast géré par useMailService.createTaskFromMail
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTaskLinked(_taskId: number): void {
|
||||||
|
showLinkTaskModal.value = false
|
||||||
|
// Toast géré par useMailService.linkTask
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex h-full flex-col overflow-hidden">
|
||||||
|
<div class="flex flex-shrink-0 items-center justify-between border-b border-neutral-200 bg-white px-4 py-3">
|
||||||
|
<h1 class="text-lg font-semibold text-neutral-900">
|
||||||
|
{{ t('mail.title') }}
|
||||||
|
</h1>
|
||||||
|
<MailRefreshButton />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-1 overflow-hidden">
|
||||||
|
<aside class="w-[220px] flex-shrink-0 overflow-y-auto border-r border-neutral-200 bg-neutral-50 py-2">
|
||||||
|
<p class="mb-1 px-3 text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
||||||
|
{{ t('mail.folders') }}
|
||||||
|
</p>
|
||||||
|
<MailFolderTree
|
||||||
|
:folders="folderTree"
|
||||||
|
:selected-path="selectedFolderPath"
|
||||||
|
@select="handleFolderSelect"
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="flex w-[320px] flex-shrink-0 flex-col overflow-hidden border-r border-neutral-200 bg-white">
|
||||||
|
<div class="flex flex-shrink-0 items-center justify-between border-b border-neutral-100 px-3 py-2">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
||||||
|
{{ t('mail.messages') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
<MailMessageList
|
||||||
|
:messages="messages"
|
||||||
|
:selected-id="selectedMessageId"
|
||||||
|
:loading="messagesLoading"
|
||||||
|
:has-more="hasMoreMessages"
|
||||||
|
@select="handleMessageSelect"
|
||||||
|
@load-more="handleLoadMore"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-hidden bg-white">
|
||||||
|
<MailMessageViewer
|
||||||
|
:detail="selectedMessageDetail"
|
||||||
|
:loading="detailLoading"
|
||||||
|
@create-task="handleCreateTask"
|
||||||
|
@link-task="handleLinkTask"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal créer tâche depuis mail -->
|
||||||
|
<MailCreateTaskModal
|
||||||
|
v-if="activeMailIdForModal !== null"
|
||||||
|
v-model="showCreateTaskModal"
|
||||||
|
:message-id="activeMailIdForModal"
|
||||||
|
:message-detail="selectedMessageDetail"
|
||||||
|
@created="handleTaskCreated"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Modal lier mail à tâche -->
|
||||||
|
<MailLinkTaskModal
|
||||||
|
v-if="activeMailIdForModal !== null"
|
||||||
|
v-model="showLinkTaskModal"
|
||||||
|
:message-id="activeMailIdForModal"
|
||||||
|
@linked="handleTaskLinked"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+120
-95
@@ -7,6 +7,8 @@ import type { TaskTag } from '~/services/dto/task-tag'
|
|||||||
import type { TaskGroup } from '~/services/dto/task-group'
|
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 { 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'
|
||||||
@@ -51,14 +53,16 @@ const selectedEffortId = ref<number | null>(null)
|
|||||||
const selectedAssigneeId = ref<number | null>(auth.user?.id ?? null)
|
const selectedAssigneeId = ref<number | null>(auth.user?.id ?? null)
|
||||||
|
|
||||||
// Sort
|
// Sort
|
||||||
type SortOption = 'default' | 'deadline' | 'scheduledStart'
|
const SORT_DEADLINE = 1
|
||||||
const sortBy = ref<SortOption>('default')
|
const SORT_SCHEDULED = 2
|
||||||
|
const sortById = ref<number | null>(null)
|
||||||
|
|
||||||
// View toggle
|
// View toggle
|
||||||
const viewMode = ref<'kanban' | 'list'>('kanban')
|
const viewMode = ref<'kanban' | 'list'>('kanban')
|
||||||
|
|
||||||
// Bulk selection
|
// Bulk selection
|
||||||
const selectedTaskIds = reactive(new Set<number>())
|
const selectedTaskIds = reactive(new Set<number>())
|
||||||
|
const selectedTasksArray = computed(() => tasks.value.filter(t => selectedTaskIds.has(t.id)))
|
||||||
|
|
||||||
// Modal
|
// Modal
|
||||||
const taskModalOpen = ref(false)
|
const taskModalOpen = ref(false)
|
||||||
@@ -67,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
|
||||||
@@ -106,13 +152,16 @@ const assigneeOptions = computed(() =>
|
|||||||
users.value.map(u => ({ label: u.username, value: u.id }))
|
users.value.map(u => ({ label: u.username, value: u.id }))
|
||||||
)
|
)
|
||||||
|
|
||||||
// Kanban helpers
|
const sortOptions = computed(() => [
|
||||||
const sortedStatuses = computed(() =>
|
{ label: t('myTasks.sortDeadline'), value: SORT_DEADLINE },
|
||||||
[...statuses.value].sort((a, b) => a.position - b.position)
|
{ label: t('myTasks.sortScheduledStart'), value: SORT_SCHEDULED },
|
||||||
)
|
])
|
||||||
|
|
||||||
function tasksByStatus(statusId: number): Task[] {
|
// Kanban helpers (grouped by canonical status category)
|
||||||
return tasks.value.filter(t => t.status?.id === statusId)
|
const CATEGORIES: StatusCategory[] = ['todo', 'in_progress', 'blocked', 'review', 'done']
|
||||||
|
|
||||||
|
function tasksByCategory(category: StatusCategory): Task[] {
|
||||||
|
return tasks.value.filter(t => t.status?.category === category)
|
||||||
}
|
}
|
||||||
|
|
||||||
const backlogTasks = computed(() =>
|
const backlogTasks = computed(() =>
|
||||||
@@ -140,33 +189,43 @@ async function loadReferenceData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadTasks() {
|
async function loadTasks() {
|
||||||
const params: Record<string, string | number | boolean | string[]> = {
|
const baseParams: Record<string, string | number | boolean | string[]> = {
|
||||||
archived: false,
|
archived: false,
|
||||||
}
|
}
|
||||||
if (selectedAssigneeId.value) {
|
|
||||||
params.assignee = `/api/users/${selectedAssigneeId.value}`
|
|
||||||
}
|
|
||||||
if (selectedProjectId.value) {
|
if (selectedProjectId.value) {
|
||||||
params.project = `/api/projects/${selectedProjectId.value}`
|
baseParams.project = `/api/projects/${selectedProjectId.value}`
|
||||||
}
|
}
|
||||||
if (selectedGroupId.value) {
|
if (selectedGroupId.value) {
|
||||||
params.group = `/api/task_groups/${selectedGroupId.value}`
|
baseParams.group = `/api/task_groups/${selectedGroupId.value}`
|
||||||
}
|
}
|
||||||
if (selectedPriorityId.value) {
|
if (selectedPriorityId.value) {
|
||||||
params.priority = `/api/task_priorities/${selectedPriorityId.value}`
|
baseParams.priority = `/api/task_priorities/${selectedPriorityId.value}`
|
||||||
}
|
}
|
||||||
if (selectedEffortId.value) {
|
if (selectedEffortId.value) {
|
||||||
params.effort = `/api/task_efforts/${selectedEffortId.value}`
|
baseParams.effort = `/api/task_efforts/${selectedEffortId.value}`
|
||||||
}
|
}
|
||||||
if (selectedTagId.value) {
|
if (selectedTagId.value) {
|
||||||
params['tags[]'] = `/api/task_tags/${selectedTagId.value}`
|
baseParams['tags[]'] = `/api/task_tags/${selectedTagId.value}`
|
||||||
}
|
}
|
||||||
if (sortBy.value === 'deadline') {
|
if (sortById.value === SORT_DEADLINE) {
|
||||||
params['order[deadline]'] = 'asc'
|
baseParams['order[deadline]'] = 'asc'
|
||||||
} else if (sortBy.value === 'scheduledStart') {
|
} else if (sortById.value === SORT_SCHEDULED) {
|
||||||
params['order[scheduledStart]'] = 'asc'
|
baseParams['order[scheduledStart]'] = 'asc'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedAssigneeId.value) {
|
||||||
|
const userIri = `/api/users/${selectedAssigneeId.value}`
|
||||||
|
const [assigneeTasks, collabTasks] = await Promise.all([
|
||||||
|
taskService.getFiltered({ ...baseParams, assignee: userIri }),
|
||||||
|
taskService.getFiltered({ ...baseParams, 'collaborators[]': userIri }),
|
||||||
|
])
|
||||||
|
const map = new Map<number, Task>()
|
||||||
|
for (const t of assigneeTasks) map.set(t.id, t)
|
||||||
|
for (const t of collabTasks) map.set(t.id, t)
|
||||||
|
tasks.value = [...map.values()].sort((a, b) => b.id - a.id)
|
||||||
|
} else {
|
||||||
|
tasks.value = await taskService.getFiltered(baseParams)
|
||||||
}
|
}
|
||||||
tasks.value = await taskService.getFiltered(params)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
@@ -180,7 +239,7 @@ async function loadAll() {
|
|||||||
|
|
||||||
// Watch filters and sort to reload tasks
|
// Watch filters and sort to reload tasks
|
||||||
watch(
|
watch(
|
||||||
[selectedProjectId, selectedGroupId, selectedTagId, selectedPriorityId, selectedEffortId, selectedAssigneeId, sortBy],
|
[selectedProjectId, selectedGroupId, selectedTagId, selectedPriorityId, selectedEffortId, selectedAssigneeId, sortById],
|
||||||
() => { loadTasks() },
|
() => { loadTasks() },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -189,44 +248,6 @@ watch(selectedProjectId, () => {
|
|||||||
selectedGroupId.value = null
|
selectedGroupId.value = null
|
||||||
}, { flush: 'sync' })
|
}, { flush: 'sync' })
|
||||||
|
|
||||||
// Drag & drop
|
|
||||||
const dragOverStatusId = ref<number | null>(null)
|
|
||||||
const dragCounter = ref(0)
|
|
||||||
|
|
||||||
function onDragEnter(id: number) {
|
|
||||||
dragCounter.value++
|
|
||||||
dragOverStatusId.value = id
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDragLeave() {
|
|
||||||
dragCounter.value--
|
|
||||||
if (dragCounter.value === 0) {
|
|
||||||
dragOverStatusId.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDrop(event: DragEvent) {
|
|
||||||
dragCounter.value = 0
|
|
||||||
dragOverStatusId.value = null
|
|
||||||
return Number(event.dataTransfer!.getData('text/plain'))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onDropStatus(event: DragEvent, status: TaskStatus) {
|
|
||||||
const taskId = onDrop(event)
|
|
||||||
const task = tasks.value.find(t => t.id === taskId)
|
|
||||||
if (!task || task.status?.id === status.id) return
|
|
||||||
task.status = status
|
|
||||||
await taskService.update(taskId, { status: `/api/task_statuses/${status.id}` })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onDropBacklog(event: DragEvent) {
|
|
||||||
const taskId = onDrop(event)
|
|
||||||
const task = tasks.value.find(t => t.id === taskId)
|
|
||||||
if (!task || !task.status) return
|
|
||||||
task.status = null
|
|
||||||
await taskService.update(taskId, { status: null })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modal
|
// Modal
|
||||||
function openTaskCreate() {
|
function openTaskCreate() {
|
||||||
selectedTask.value = null
|
selectedTask.value = null
|
||||||
@@ -400,50 +421,48 @@ onMounted(async () => {
|
|||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-col gap-0.5">
|
<MalioSelect
|
||||||
<span class="text-xs font-semibold text-neutral-500">{{ $t('myTasks.sortBy') }}</span>
|
v-model="sortById"
|
||||||
<select
|
:options="sortOptions"
|
||||||
v-model="sortBy"
|
:label="$t('myTasks.sortBy')"
|
||||||
class="rounded-lg border border-neutral-300 bg-white px-2 py-1.5 text-sm text-neutral-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
:empty-option-label="$t('myTasks.sortDefault')"
|
||||||
>
|
min-width="!w-40"
|
||||||
<option value="default">{{ $t('myTasks.sortDefault') }}</option>
|
text-field="text-sm"
|
||||||
<option value="deadline">{{ $t('myTasks.sortDeadline') }}</option>
|
text-value="text-sm"
|
||||||
<option value="scheduledStart">{{ $t('myTasks.sortScheduledStart') }}</option>
|
/>
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Kanban View -->
|
<!-- Kanban View — grouped by canonical category -->
|
||||||
<div v-if="viewMode === 'kanban'">
|
<div v-if="viewMode === 'kanban'">
|
||||||
<div class="mt-6 flex h-[calc(100vh-260px)] gap-3 overflow-x-auto pb-4">
|
<div class="mt-6 flex h-[calc(100vh-260px)] gap-3 overflow-x-auto pb-4">
|
||||||
<div
|
<div
|
||||||
v-for="status in sortedStatuses"
|
v-for="cat in CATEGORIES"
|
||||||
:key="status.id"
|
:key="cat"
|
||||||
class="flex min-w-36 flex-1 flex-col rounded-lg transition-colors"
|
class="flex min-w-40 flex-1 flex-col rounded-lg bg-neutral-50 transition"
|
||||||
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
|
:class="dragOverCategory === cat ? 'ring-2 ring-primary-400' : ''"
|
||||||
@dragover.prevent
|
@dragover.prevent="dragOverCategory = cat"
|
||||||
@dragenter.prevent="onDragEnter(status.id)"
|
@dragleave="dragOverCategory = null"
|
||||||
@dragleave="onDragLeave"
|
@drop="onDrop(cat, $event)"
|
||||||
@drop.prevent="onDropStatus($event, status)"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="shrink-0 rounded-t-lg px-4 py-3 text-sm font-bold text-white"
|
class="shrink-0 rounded-t-lg px-4 py-3 text-sm font-bold"
|
||||||
:style="{ backgroundColor: status.color }"
|
:style="{ backgroundColor: STATUS_CATEGORY_COLOR[cat], color: contrastText(STATUS_CATEGORY_COLOR[cat]) }"
|
||||||
>
|
>
|
||||||
{{ status.label }} ({{ tasksByStatus(status.id).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">
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<TaskCard
|
<TaskCard
|
||||||
v-for="task in tasksByStatus(status.id)"
|
v-for="task in tasksByCategory(cat)"
|
||||||
:key="task.id"
|
:key="task.id"
|
||||||
:task="task"
|
:task="task"
|
||||||
show-project-color
|
show-project-color
|
||||||
|
show-status-badge
|
||||||
@click="openTaskEdit(task)"
|
@click="openTaskEdit(task)"
|
||||||
/>
|
/>
|
||||||
<p
|
<p
|
||||||
v-if="tasksByStatus(status.id).length === 0"
|
v-if="tasksByCategory(cat).length === 0"
|
||||||
class="py-4 text-center text-xs text-neutral-400"
|
class="py-4 text-center text-xs text-neutral-400"
|
||||||
>
|
>
|
||||||
{{ $t('myTasks.noTasks') }}
|
{{ $t('myTasks.noTasks') }}
|
||||||
@@ -453,15 +472,8 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Backlog below kanban -->
|
<!-- Backlog below kanban (no drag/drop — status change goes through TaskModal) -->
|
||||||
<div
|
<div class="mt-8 rounded-lg bg-neutral-50 p-4">
|
||||||
class="mt-8 rounded-lg p-4 transition-colors"
|
|
||||||
:class="dragOverStatusId === 0 ? 'bg-neutral-200' : 'bg-neutral-50'"
|
|
||||||
@dragover.prevent
|
|
||||||
@dragenter.prevent="onDragEnter(0)"
|
|
||||||
@dragleave="onDragLeave"
|
|
||||||
@drop.prevent="onDropBacklog($event)"
|
|
||||||
>
|
|
||||||
<h2 class="text-lg font-bold text-neutral-900">{{ $t('myTasks.backlog') }} ({{ backlogTasks.length }})</h2>
|
<h2 class="text-lg font-bold text-neutral-900">{{ $t('myTasks.backlog') }} ({{ backlogTasks.length }})</h2>
|
||||||
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
<TaskCard
|
<TaskCard
|
||||||
@@ -469,6 +481,7 @@ onMounted(async () => {
|
|||||||
:key="task.id"
|
:key="task.id"
|
||||||
:task="task"
|
:task="task"
|
||||||
show-project-color
|
show-project-color
|
||||||
|
show-status-badge
|
||||||
@click="openTaskEdit(task)"
|
@click="openTaskEdit(task)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -493,6 +506,8 @@ onMounted(async () => {
|
|||||||
:priorities="priorities"
|
:priorities="priorities"
|
||||||
:efforts="efforts"
|
:efforts="efforts"
|
||||||
:groups="groups"
|
:groups="groups"
|
||||||
|
:selected-tasks="selectedTasksArray"
|
||||||
|
:projects="projects"
|
||||||
@toggle-all="toggleSelectAll(tasks)"
|
@toggle-all="toggleSelectAll(tasks)"
|
||||||
@bulk-update="onBulkUpdate"
|
@bulk-update="onBulkUpdate"
|
||||||
@bulk-archive="onBulkArchive"
|
@bulk-archive="onBulkArchive"
|
||||||
@@ -515,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"
|
||||||
|
|||||||
@@ -37,15 +37,10 @@
|
|||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<MalioInputTextArea
|
<MalioInputRichText
|
||||||
v-model="form.description"
|
v-model="form.description"
|
||||||
:label="$t('clientTicket.description')"
|
:label="$t('clientTicket.description')"
|
||||||
:size="5"
|
min-height="180px"
|
||||||
resize="vertical"
|
|
||||||
:min-resize-height="140"
|
|
||||||
:max-resize-height="500"
|
|
||||||
min-resize-width="100%"
|
|
||||||
max-resize-width="100%"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,56 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- API Token MCP (interne uniquement) -->
|
||||||
|
<div
|
||||||
|
v-if="!isClientOnly"
|
||||||
|
class="mt-8 rounded-xl border border-neutral-200 bg-white p-6 shadow-sm"
|
||||||
|
>
|
||||||
|
<h2 class="mb-1 text-lg font-bold text-neutral-900">{{ $t('profile.apiToken.title') }}</h2>
|
||||||
|
<p class="mb-4 text-sm text-neutral-600">{{ $t('profile.apiToken.help') }}</p>
|
||||||
|
|
||||||
|
<div v-if="auth.user?.apiToken">
|
||||||
|
<MalioInputPassword
|
||||||
|
:model-value="auth.user.apiToken"
|
||||||
|
:label="$t('profile.apiToken.label')"
|
||||||
|
readonly
|
||||||
|
@update:model-value="() => {}"
|
||||||
|
/>
|
||||||
|
<div class="mt-3 flex flex-wrap gap-3">
|
||||||
|
<MalioButton
|
||||||
|
variant="secondary"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
icon-name="mdi:content-copy"
|
||||||
|
icon-position="left"
|
||||||
|
:label="$t('profile.apiToken.copy')"
|
||||||
|
@click="onCopy"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="danger"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
icon-name="mdi:refresh"
|
||||||
|
icon-position="left"
|
||||||
|
:disabled="regenerating"
|
||||||
|
:label="$t('profile.apiToken.regenerate')"
|
||||||
|
@click="showConfirm = true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<p class="mb-4 text-sm text-neutral-500 italic">{{ $t('profile.apiToken.empty') }}</p>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
icon-name="mdi:key-plus"
|
||||||
|
icon-position="left"
|
||||||
|
:disabled="regenerating"
|
||||||
|
:label="$t('profile.apiToken.generate')"
|
||||||
|
@click="onRegenerate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Crop modal -->
|
<!-- Crop modal -->
|
||||||
<AvatarCropper
|
<AvatarCropper
|
||||||
v-if="selectedFile"
|
v-if="selectedFile"
|
||||||
@@ -44,14 +94,45 @@
|
|||||||
@crop="onCrop"
|
@crop="onCrop"
|
||||||
@cancel="selectedFile = null"
|
@cancel="selectedFile = null"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Confirm regenerate modal -->
|
||||||
|
<Teleport v-if="showConfirm" to="body">
|
||||||
|
<div class="fixed inset-0 z-[70] flex items-center justify-center">
|
||||||
|
<div class="absolute inset-0 bg-black/30" @click.stop="showConfirm = false" />
|
||||||
|
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||||
|
<h3 class="text-lg font-bold text-neutral-900">{{ $t('profile.apiToken.confirmTitle') }}</h3>
|
||||||
|
<p class="mt-3 text-sm text-neutral-600">
|
||||||
|
{{ $t('profile.apiToken.confirmMessage') }}
|
||||||
|
</p>
|
||||||
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
|
<MalioButton
|
||||||
|
variant="tertiary"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
:label="$t('common.cancel')"
|
||||||
|
@click="showConfirm = false"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="danger"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
:disabled="regenerating"
|
||||||
|
:label="$t('profile.apiToken.regenerate')"
|
||||||
|
@click="onRegenerate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useAvatarService } from '~/composables/useAvatarService'
|
import { useAvatarService } from '~/composables/useAvatarService'
|
||||||
|
import { useApiTokenService } from '~/services/api-token'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
const toast = useToast()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const isClientOnly = computed(() =>
|
const isClientOnly = computed(() =>
|
||||||
auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN')
|
auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN')
|
||||||
@@ -61,9 +142,12 @@ definePageMeta({
|
|||||||
layout: false,
|
layout: false,
|
||||||
})
|
})
|
||||||
const { upload, remove } = useAvatarService()
|
const { upload, remove } = useAvatarService()
|
||||||
|
const { regenerate } = useApiTokenService()
|
||||||
|
|
||||||
const selectedFile = ref<File | null>(null)
|
const selectedFile = ref<File | null>(null)
|
||||||
const removing = ref(false)
|
const removing = ref(false)
|
||||||
|
const regenerating = ref(false)
|
||||||
|
const showConfirm = ref(false)
|
||||||
|
|
||||||
function onFileSelect(event: Event) {
|
function onFileSelect(event: Event) {
|
||||||
const input = event.target as HTMLInputElement
|
const input = event.target as HTMLInputElement
|
||||||
@@ -97,4 +181,28 @@ async function onRemove() {
|
|||||||
removing.value = false
|
removing.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onCopy() {
|
||||||
|
if (!auth.user?.apiToken) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(auth.user.apiToken)
|
||||||
|
toast.success({ message: t('profile.apiToken.copied') })
|
||||||
|
} catch {
|
||||||
|
toast.error({ message: t('profile.apiToken.copyFailed') })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRegenerate() {
|
||||||
|
regenerating.value = true
|
||||||
|
try {
|
||||||
|
const newToken = await regenerate()
|
||||||
|
if (auth.user) {
|
||||||
|
auth.user.apiToken = newToken
|
||||||
|
}
|
||||||
|
showConfirm.value = false
|
||||||
|
toast.success({ message: t('profile.apiToken.regenerated') })
|
||||||
|
} finally {
|
||||||
|
regenerating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ 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 { useProjectService } from '~/services/projects'
|
import { useProjectService } from '~/services/projects'
|
||||||
import { useTaskService } from '~/services/tasks'
|
import { useTaskService } from '~/services/tasks'
|
||||||
import { useTaskStatusService } from '~/services/task-statuses'
|
|
||||||
import { useTaskEffortService } from '~/services/task-efforts'
|
import { useTaskEffortService } from '~/services/task-efforts'
|
||||||
import { useTaskPriorityService } from '~/services/task-priorities'
|
import { useTaskPriorityService } from '~/services/task-priorities'
|
||||||
import { useTaskTagService } from '~/services/task-tags'
|
import { useTaskTagService } from '~/services/task-tags'
|
||||||
@@ -96,7 +95,6 @@ useHead({ title: 'Archives' })
|
|||||||
|
|
||||||
const projectService = useProjectService()
|
const projectService = useProjectService()
|
||||||
const taskService = useTaskService()
|
const taskService = useTaskService()
|
||||||
const statusService = useTaskStatusService()
|
|
||||||
const effortService = useTaskEffortService()
|
const effortService = useTaskEffortService()
|
||||||
const priorityService = useTaskPriorityService()
|
const priorityService = useTaskPriorityService()
|
||||||
const tagService = useTaskTagService()
|
const tagService = useTaskTagService()
|
||||||
@@ -105,8 +103,11 @@ const userService = useUserService()
|
|||||||
|
|
||||||
const project = ref<Project | null>(null)
|
const project = ref<Project | null>(null)
|
||||||
const archivedTasks = ref<Task[]>([])
|
const archivedTasks = ref<Task[]>([])
|
||||||
const statuses = ref<TaskStatus[]>([])
|
|
||||||
const efforts = ref<TaskEffort[]>([])
|
const efforts = ref<TaskEffort[]>([])
|
||||||
|
|
||||||
|
const statuses = computed<TaskStatus[]>(() =>
|
||||||
|
[...(project.value?.workflow?.statuses ?? [])].sort((a, b) => a.position - b.position),
|
||||||
|
)
|
||||||
const priorities = ref<TaskPriority[]>([])
|
const priorities = ref<TaskPriority[]>([])
|
||||||
const tags = ref<TaskTag[]>([])
|
const tags = ref<TaskTag[]>([])
|
||||||
const groups = ref<TaskGroup[]>([])
|
const groups = ref<TaskGroup[]>([])
|
||||||
@@ -126,10 +127,9 @@ const filteredTasks = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
const [p, t, s, e, pr, ty, g, u] = await Promise.all([
|
const [p, t, e, pr, ty, g, u] = await Promise.all([
|
||||||
projectService.getById(projectId.value),
|
projectService.getById(projectId.value),
|
||||||
taskService.getByProject(projectId.value, true),
|
taskService.getByProject(projectId.value, true),
|
||||||
statusService.getAll(),
|
|
||||||
effortService.getAll(),
|
effortService.getAll(),
|
||||||
priorityService.getAll(),
|
priorityService.getAll(),
|
||||||
tagService.getAll(),
|
tagService.getAll(),
|
||||||
@@ -138,7 +138,6 @@ async function loadData() {
|
|||||||
])
|
])
|
||||||
project.value = p
|
project.value = p
|
||||||
archivedTasks.value = t
|
archivedTasks.value = t
|
||||||
statuses.value = s
|
|
||||||
efforts.value = e
|
efforts.value = e
|
||||||
priorities.value = pr
|
priorities.value = pr
|
||||||
tags.value = ty
|
tags.value = ty
|
||||||
|
|||||||
@@ -84,7 +84,12 @@
|
|||||||
|
|
||||||
<!-- Expanded details -->
|
<!-- Expanded details -->
|
||||||
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-4 py-3">
|
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-4 py-3">
|
||||||
<p class="text-sm text-neutral-600 whitespace-pre-wrap">{{ ticket.description }}</p>
|
<MalioInputRichText
|
||||||
|
v-if="ticket.description"
|
||||||
|
:model-value="ticket.description"
|
||||||
|
:editable="false"
|
||||||
|
/>
|
||||||
|
<p v-else class="text-sm italic text-neutral-400">—</p>
|
||||||
<div v-if="ticket.url" class="mt-2">
|
<div v-if="ticket.url" class="mt-2">
|
||||||
<a
|
<a
|
||||||
:href="ticket.url"
|
:href="ticket.url"
|
||||||
|
|||||||
@@ -61,6 +61,7 @@
|
|||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
|
v-if="viewMode === 'list'"
|
||||||
v-model="selectedStatusId"
|
v-model="selectedStatusId"
|
||||||
:options="statusFilterOptions"
|
:options="statusFilterOptions"
|
||||||
label="Status"
|
label="Status"
|
||||||
@@ -217,7 +218,6 @@ import type { Client } from '~/services/dto/client'
|
|||||||
import { useProjectService } from '~/services/projects'
|
import { useProjectService } from '~/services/projects'
|
||||||
import { useClientService } from '~/services/clients'
|
import { useClientService } from '~/services/clients'
|
||||||
import { useTaskService } from '~/services/tasks'
|
import { useTaskService } from '~/services/tasks'
|
||||||
import { useTaskStatusService } from '~/services/task-statuses'
|
|
||||||
import { useTaskEffortService } from '~/services/task-efforts'
|
import { useTaskEffortService } from '~/services/task-efforts'
|
||||||
import { useTaskPriorityService } from '~/services/task-priorities'
|
import { useTaskPriorityService } from '~/services/task-priorities'
|
||||||
import { useTaskTagService } from '~/services/task-tags'
|
import { useTaskTagService } from '~/services/task-tags'
|
||||||
@@ -233,7 +233,6 @@ useHead({ title: 'Projet' })
|
|||||||
const projectService = useProjectService()
|
const projectService = useProjectService()
|
||||||
const clientService = useClientService()
|
const clientService = useClientService()
|
||||||
const taskService = useTaskService()
|
const taskService = useTaskService()
|
||||||
const statusService = useTaskStatusService()
|
|
||||||
const effortService = useTaskEffortService()
|
const effortService = useTaskEffortService()
|
||||||
const priorityService = useTaskPriorityService()
|
const priorityService = useTaskPriorityService()
|
||||||
const tagService = useTaskTagService()
|
const tagService = useTaskTagService()
|
||||||
@@ -242,7 +241,6 @@ const userService = useUserService()
|
|||||||
|
|
||||||
const project = ref<Project | null>(null)
|
const project = ref<Project | null>(null)
|
||||||
const tasks = ref<Task[]>([])
|
const tasks = ref<Task[]>([])
|
||||||
const statuses = ref<TaskStatus[]>([])
|
|
||||||
const efforts = ref<TaskEffort[]>([])
|
const efforts = ref<TaskEffort[]>([])
|
||||||
const priorities = ref<TaskPriority[]>([])
|
const priorities = ref<TaskPriority[]>([])
|
||||||
const tags = ref<TaskTag[]>([])
|
const tags = ref<TaskTag[]>([])
|
||||||
@@ -251,6 +249,10 @@ const users = ref<UserData[]>([])
|
|||||||
const clients = ref<Client[]>([])
|
const clients = ref<Client[]>([])
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
|
|
||||||
|
const statuses = computed<TaskStatus[]>(() =>
|
||||||
|
[...(project.value?.workflow?.statuses ?? [])].sort((a, b) => a.position - b.position),
|
||||||
|
)
|
||||||
|
|
||||||
const selectedGroupId = ref<number | null>(null)
|
const selectedGroupId = ref<number | null>(null)
|
||||||
const selectedTagId = ref<number | null>(null)
|
const selectedTagId = ref<number | null>(null)
|
||||||
const selectedAssigneeId = ref<number | null>(null)
|
const selectedAssigneeId = ref<number | null>(null)
|
||||||
@@ -258,6 +260,12 @@ const selectedStatusId = ref<number | null>(null)
|
|||||||
const selectedPriorityId = ref<number | null>(null)
|
const selectedPriorityId = ref<number | null>(null)
|
||||||
const selectedEffortId = ref<number | null>(null)
|
const selectedEffortId = ref<number | null>(null)
|
||||||
const viewMode = ref<'kanban' | 'list'>('kanban')
|
const viewMode = ref<'kanban' | 'list'>('kanban')
|
||||||
|
|
||||||
|
watch(viewMode, (mode) => {
|
||||||
|
if (mode === 'kanban') {
|
||||||
|
selectedStatusId.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
const selectedTaskIds = reactive(new Set<number>())
|
const selectedTaskIds = reactive(new Set<number>())
|
||||||
const dragOverStatusId = ref<number | null>(null)
|
const dragOverStatusId = ref<number | null>(null)
|
||||||
const dragCounter = ref(0)
|
const dragCounter = ref(0)
|
||||||
@@ -298,7 +306,10 @@ const filteredTasks = computed(() => {
|
|||||||
result = result.filter(t => t.tags?.some(tag => tag.id === selectedTagId.value))
|
result = result.filter(t => t.tags?.some(tag => tag.id === selectedTagId.value))
|
||||||
}
|
}
|
||||||
if (selectedAssigneeId.value) {
|
if (selectedAssigneeId.value) {
|
||||||
result = result.filter(t => t.assignee?.id === selectedAssigneeId.value)
|
result = result.filter(t =>
|
||||||
|
t.assignee?.id === selectedAssigneeId.value
|
||||||
|
|| t.collaborators?.some(c => c.id === selectedAssigneeId.value)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (selectedStatusId.value) {
|
if (selectedStatusId.value) {
|
||||||
result = result.filter(t => t.status?.id === selectedStatusId.value)
|
result = result.filter(t => t.status?.id === selectedStatusId.value)
|
||||||
@@ -323,10 +334,9 @@ const backlogTasks = computed(() =>
|
|||||||
async function loadData() {
|
async function loadData() {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
const [p, t, s, e, pr, ty, g, u, c] = await Promise.all([
|
const [p, t, e, pr, ty, g, u, c] = await Promise.all([
|
||||||
projectService.getById(projectId.value),
|
projectService.getById(projectId.value),
|
||||||
taskService.getByProject(projectId.value),
|
taskService.getByProject(projectId.value),
|
||||||
statusService.getAll(),
|
|
||||||
effortService.getAll(),
|
effortService.getAll(),
|
||||||
priorityService.getAll(),
|
priorityService.getAll(),
|
||||||
tagService.getAll(),
|
tagService.getAll(),
|
||||||
@@ -336,7 +346,6 @@ async function loadData() {
|
|||||||
])
|
])
|
||||||
project.value = p
|
project.value = p
|
||||||
tasks.value = t
|
tasks.value = t
|
||||||
statuses.value = s
|
|
||||||
efforts.value = e
|
efforts.value = e
|
||||||
priorities.value = pr
|
priorities.value = pr
|
||||||
tags.value = ty
|
tags.value = ty
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
label="User"
|
label="User"
|
||||||
empty-option-label="User"
|
empty-option-label="Tous"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -217,16 +217,7 @@ function updatePageHeaderHeight() {
|
|||||||
pageHeaderHeight.value = pageHeaderEl.value?.offsetHeight ?? 0
|
pageHeaderHeight.value = pageHeaderEl.value?.offsetHeight ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredEntries = computed(() => {
|
const filteredEntries = computed(() => entries.value)
|
||||||
let result = entries.value
|
|
||||||
if (selectedProjectId.value) {
|
|
||||||
result = result.filter((e) => e.project?.id === selectedProjectId.value)
|
|
||||||
}
|
|
||||||
if (selectedTagId.value) {
|
|
||||||
result = result.filter((e) => e.tags.some((t) => t.id === selectedTagId.value))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
function getMonday(d: Date): Date {
|
function getMonday(d: Date): Date {
|
||||||
const date = new Date(d)
|
const date = new Date(d)
|
||||||
@@ -239,15 +230,35 @@ function getMonday(d: Date): Date {
|
|||||||
|
|
||||||
function navigatePrev() {
|
function navigatePrev() {
|
||||||
const d = new Date(startDate.value)
|
const d = new Date(startDate.value)
|
||||||
d.setDate(d.getDate() - (viewMode.value === 'day' ? 1 : 7))
|
if (viewMode.value === 'day') {
|
||||||
startDate.value = viewMode.value === 'day' ? d : getMonday(d)
|
d.setDate(d.getDate() - 1)
|
||||||
|
startDate.value = d
|
||||||
|
} else if (viewMode.value === 'list') {
|
||||||
|
d.setMonth(d.getMonth() - 1)
|
||||||
|
d.setDate(1)
|
||||||
|
d.setHours(0, 0, 0, 0)
|
||||||
|
startDate.value = d
|
||||||
|
} else {
|
||||||
|
d.setDate(d.getDate() - 7)
|
||||||
|
startDate.value = getMonday(d)
|
||||||
|
}
|
||||||
loadEntries()
|
loadEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateNext() {
|
function navigateNext() {
|
||||||
const d = new Date(startDate.value)
|
const d = new Date(startDate.value)
|
||||||
d.setDate(d.getDate() + (viewMode.value === 'day' ? 1 : 7))
|
if (viewMode.value === 'day') {
|
||||||
startDate.value = viewMode.value === 'day' ? d : getMonday(d)
|
d.setDate(d.getDate() + 1)
|
||||||
|
startDate.value = d
|
||||||
|
} else if (viewMode.value === 'list') {
|
||||||
|
d.setMonth(d.getMonth() + 1)
|
||||||
|
d.setDate(1)
|
||||||
|
d.setHours(0, 0, 0, 0)
|
||||||
|
startDate.value = d
|
||||||
|
} else {
|
||||||
|
d.setDate(d.getDate() + 7)
|
||||||
|
startDate.value = getMonday(d)
|
||||||
|
}
|
||||||
loadEntries()
|
loadEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,12 +370,20 @@ async function onExport(params: {
|
|||||||
|
|
||||||
async function loadEntries() {
|
async function loadEntries() {
|
||||||
const end = new Date(startDate.value)
|
const end = new Date(startDate.value)
|
||||||
end.setDate(end.getDate() + (viewMode.value === 'day' ? 1 : 7))
|
if (viewMode.value === 'day') {
|
||||||
|
end.setDate(end.getDate() + 1)
|
||||||
|
} else if (viewMode.value === 'list') {
|
||||||
|
end.setMonth(end.getMonth() + 1)
|
||||||
|
} else {
|
||||||
|
end.setDate(end.getDate() + 7)
|
||||||
|
}
|
||||||
|
|
||||||
entries.value = await timeEntryService.getByDateRange({
|
entries.value = await timeEntryService.getByDateRange({
|
||||||
after: startDate.value.toISOString(),
|
after: startDate.value.toISOString(),
|
||||||
before: end.toISOString(),
|
before: end.toISOString(),
|
||||||
user: selectedUserId.value ?? undefined,
|
user: selectedUserId.value ?? undefined,
|
||||||
|
project: selectedProjectId.value ?? undefined,
|
||||||
|
tag: selectedTagId.value ?? undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,11 +419,20 @@ onMounted(async () => {
|
|||||||
|
|
||||||
watch(viewMode, () => {
|
watch(viewMode, () => {
|
||||||
selectedDateFilter.value = null
|
selectedDateFilter.value = null
|
||||||
startDate.value = viewMode.value === 'day' ? startDate.value : getMonday(startDate.value)
|
if (viewMode.value === 'day') {
|
||||||
|
// keep current date
|
||||||
|
} else if (viewMode.value === 'list') {
|
||||||
|
const d = new Date(startDate.value)
|
||||||
|
d.setDate(1)
|
||||||
|
d.setHours(0, 0, 0, 0)
|
||||||
|
startDate.value = d
|
||||||
|
} else {
|
||||||
|
startDate.value = getMonday(startDate.value)
|
||||||
|
}
|
||||||
loadEntries()
|
loadEntries()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(selectedUserId, () => {
|
watch([selectedUserId, selectedProjectId, selectedTagId], () => {
|
||||||
loadEntries()
|
loadEntries()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export function useApiTokenService() {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
async function regenerate(): Promise<string> {
|
||||||
|
const data = await api.post<{ apiToken: string }>('/me/regenerate-api-token', {}, {
|
||||||
|
toast: false,
|
||||||
|
})
|
||||||
|
return data.apiToken
|
||||||
|
}
|
||||||
|
|
||||||
|
return { regenerate }
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// Lecture de la configuration mail (singleton admin)
|
||||||
|
export type MailConfigurationDto = {
|
||||||
|
protocol: string | null
|
||||||
|
imapHost: string | null
|
||||||
|
imapPort: number | null
|
||||||
|
imapEncryption: string | null
|
||||||
|
smtpHost: string | null
|
||||||
|
smtpPort: number | null
|
||||||
|
smtpEncryption: string | null
|
||||||
|
username: string | null
|
||||||
|
sentFolderPath: string | null
|
||||||
|
enabled: boolean
|
||||||
|
hasPassword: boolean
|
||||||
|
// password JAMAIS présent dans les réponses GET
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input PATCH configuration (password optionnel, write-only)
|
||||||
|
export type MailConfigurationUpdateDto = {
|
||||||
|
protocol?: string | null
|
||||||
|
imapHost?: string | null
|
||||||
|
imapPort?: number | null
|
||||||
|
imapEncryption?: string | null
|
||||||
|
smtpHost?: string | null
|
||||||
|
smtpPort?: number | null
|
||||||
|
smtpEncryption?: string | null
|
||||||
|
username?: string | null
|
||||||
|
sentFolderPath?: string | null
|
||||||
|
enabled?: boolean
|
||||||
|
password?: string // write-only, jamais retourné
|
||||||
|
}
|
||||||
|
|
||||||
|
// Résultat du test de connexion
|
||||||
|
export type MailTestConnectionResultDto = {
|
||||||
|
ok: boolean
|
||||||
|
foldersCount?: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dossier mail (peut être imbriqué)
|
||||||
|
export type MailFolderDto = {
|
||||||
|
path: string
|
||||||
|
displayName: string
|
||||||
|
parentPath: string | null
|
||||||
|
unreadCount: number
|
||||||
|
totalCount: number
|
||||||
|
children?: MailFolderDto[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adresse mail (nom + email)
|
||||||
|
export type MailAddressDto = {
|
||||||
|
name: string | null
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// En-tête d'un message (liste)
|
||||||
|
export type MailMessageHeaderDto = {
|
||||||
|
id: number
|
||||||
|
messageId: string // identifiant IMAP unique
|
||||||
|
folderPath: string
|
||||||
|
subject: string | null
|
||||||
|
fromName: string | null
|
||||||
|
fromEmail: string | null
|
||||||
|
toRecipients: MailAddressDto[]
|
||||||
|
ccRecipients: MailAddressDto[]
|
||||||
|
sentAt: string | null // ISO 8601
|
||||||
|
receivedAt: string // ISO 8601
|
||||||
|
isRead: boolean
|
||||||
|
isFlagged: boolean
|
||||||
|
hasAttachments: boolean
|
||||||
|
linkedTaskIds: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pièce jointe (métadonnées uniquement, téléchargement via downloadId)
|
||||||
|
export type MailAttachmentDto = {
|
||||||
|
downloadId: string
|
||||||
|
filename: string
|
||||||
|
mimeType: string
|
||||||
|
size: number // octets
|
||||||
|
}
|
||||||
|
|
||||||
|
// Détail complet d'un message (enrichi avec body + PJ)
|
||||||
|
export type MailMessageDetailDto = {
|
||||||
|
header: MailMessageHeaderDto
|
||||||
|
bodyHtml: string | null // HTML brut — TOUJOURS passer par sanitizeMailHtml() avant affichage
|
||||||
|
bodyText: string | null // Fallback texte plain
|
||||||
|
attachments: MailAttachmentDto[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page de messages paginée (cursor-based)
|
||||||
|
export type MailMessagesPageDto = {
|
||||||
|
items: MailMessageHeaderDto[]
|
||||||
|
nextCursor: string | null // null = plus de page suivante
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input : marquer lu/non-lu
|
||||||
|
export type MailMessageReadInput = {
|
||||||
|
read: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input : marquer étoilé/non-étoilé
|
||||||
|
export type MailMessageFlagInput = {
|
||||||
|
flagged: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input : créer une tâche depuis un mail
|
||||||
|
export type MailCreateTaskInput = {
|
||||||
|
projectId: number
|
||||||
|
taskGroupId?: number | null
|
||||||
|
assigneeId?: number
|
||||||
|
statusId?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input : lier une tâche existante à un mail
|
||||||
|
export type MailLinkTaskInput = {
|
||||||
|
taskId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Résultat de la sync manuelle
|
||||||
|
export type MailSyncResultDto = {
|
||||||
|
dispatched: boolean
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user