Compare commits
188 Commits
efa42b6039
...
v0.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | |||
|
|
b50cfb5049 | ||
|
|
a5227b9936 | ||
|
|
0d298db797 | ||
|
|
cbe71a1f32 | ||
|
|
a8fa8fd7e0 | ||
|
|
4aa2abd396 | ||
|
|
fa3326e99c | ||
|
|
21e050ce29 | ||
|
|
e480e2821b | ||
|
|
2d7e9b9226 | ||
|
|
93e0c4052c | ||
|
|
22373a0b87 | ||
|
|
d7968af525 | ||
| df2a48c20d | |||
| 7f1c02256b | |||
| fdc9b8b60d | |||
| 1025fed0d1 | |||
| 0331d94ca5 | |||
| 755c39a0f6 | |||
| 8f8eeddd91 | |||
| 548b101d82 | |||
|
|
e3149f8a27 | ||
|
|
32aff3d4d3 | ||
|
|
9760de1805 | ||
|
|
f72dd57bd0 | ||
|
|
a8f7c77758 | ||
|
|
a09a415393 | ||
|
|
8208df1ade | ||
|
|
15af8975f0 | ||
|
|
040cbfc588 | ||
|
|
e796741dd8 | ||
|
|
9e7d196443 | ||
|
|
3e9a0c93eb | ||
|
|
1d533d1d28 |
224
.claude/commands/push-tickets-lesstime.md
Normal file
224
.claude/commands/push-tickets-lesstime.md
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
---
|
||||||
|
name: push-tickets-lesstime
|
||||||
|
description: Use after full-project-review to push TICKETS.md tickets into Lesstime project management via MCP. Triggers on "push tickets", "envoyer tickets", "creer les tickets dans lesstime", "sync tickets lesstime", "pousser les tickets".
|
||||||
|
---
|
||||||
|
|
||||||
|
# Push Tickets to Lesstime
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Prend le fichier `TICKETS.md` genere par le skill `full-project-review` et cree les taches correspondantes dans Lesstime via son MCP server. Chaque ticket devient une tache avec la bonne priorite, le bon groupe, et la description complete.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- Apres un `full-project-review` qui a genere un `TICKETS.md`
|
||||||
|
- L'utilisateur demande de "pousser", "sync", "envoyer" les tickets dans Lesstime
|
||||||
|
- L'utilisateur veut creer les taches dans son gestionnaire de projet
|
||||||
|
|
||||||
|
## Prerequis
|
||||||
|
|
||||||
|
- Un fichier `TICKETS.md` doit exister dans le repertoire courant (genere par `full-project-review`)
|
||||||
|
- L'API Lesstime doit etre accessible via HTTP
|
||||||
|
|
||||||
|
## Connexion a Lesstime
|
||||||
|
|
||||||
|
Lesstime est accessible via un serveur MCP HTTP (JSON-RPC 2.0). Il n'y a PAS de MCP natif configure dans Claude Code — il faut appeler l'API directement via `curl` dans le Bash tool.
|
||||||
|
|
||||||
|
### Parametres de connexion
|
||||||
|
|
||||||
|
```
|
||||||
|
URL: http://project.malio-dev.fr/_mcp
|
||||||
|
TOKEN: 7e8b410a5b79b5c0432951dcee3a3a81e0731e86d9f70d8784ec079a2b759c64
|
||||||
|
```
|
||||||
|
|
||||||
|
### Procedure de connexion (3 etapes)
|
||||||
|
|
||||||
|
**Etape 1 — Initialiser la session** (SANS header Mcp-Session-Id) :
|
||||||
|
```bash
|
||||||
|
curl -s -D /tmp/mcp_headers -X POST http://project.malio-dev.fr/_mcp \
|
||||||
|
-H "Authorization: Bearer 7e8b410a5b79b5c0432951dcee3a3a81e0731e86d9f70d8784ec079a2b759c64" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"claude","version":"1.0"}}}' > /dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
**Etape 2 — Extraire le Session ID** depuis les headers de reponse :
|
||||||
|
```bash
|
||||||
|
SID=$(grep -i "mcp-session-id" /tmp/mcp_headers | awk '{print $2}' | tr -d '\r\n')
|
||||||
|
```
|
||||||
|
|
||||||
|
**Etape 3 — Appeler les outils** avec le Session ID :
|
||||||
|
```bash
|
||||||
|
curl -s -X POST http://project.malio-dev.fr/_mcp \
|
||||||
|
-H "Authorization: Bearer 7e8b410a5b79b5c0432951dcee3a3a81e0731e86d9f70d8784ec079a2b759c64" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Mcp-Session-Id: $SID" \
|
||||||
|
-d '{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"list-projects","arguments":{}}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Les reponses sont au format `{"jsonrpc":"2.0","id":X,"result":{"content":[{"type":"text","text":"[JSON_DATA]"}]}}`.
|
||||||
|
Extraire les donnees avec : `python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(json.loads(d['result']['content'][0]['text']))"`
|
||||||
|
|
||||||
|
### Approche recommandee : script Python
|
||||||
|
|
||||||
|
Pour pousser plusieurs tickets, generer un script Python temporaire qui :
|
||||||
|
1. Initialise la session via curl subprocess
|
||||||
|
2. Extrait le SID
|
||||||
|
3. Boucle sur les tickets et appelle create-task pour chacun
|
||||||
|
4. Affiche le resultat
|
||||||
|
|
||||||
|
Voir la memoire `reference_lesstime.md` pour les IDs connus (projets, users, statuts, priorites).
|
||||||
|
|
||||||
|
### IDs frequemment utilises
|
||||||
|
|
||||||
|
| Type | Label | ID |
|
||||||
|
|------|-------|----|
|
||||||
|
| Statut | A faire | 1 |
|
||||||
|
| Statut | En cours | 2 |
|
||||||
|
| Statut | Termine | 5 |
|
||||||
|
| Priorite | Basse | 1 |
|
||||||
|
| Priorite | Moyen | 2 |
|
||||||
|
| Priorite | Haute | 3 |
|
||||||
|
| User | matteo | 6 |
|
||||||
|
| User | Matthieu | 5 |
|
||||||
|
| Projet | Infrastructure | 13 |
|
||||||
|
| Projet | Lesstime | 5 |
|
||||||
|
| Projet | Inventory | 7 |
|
||||||
|
| Projet | Ferme | 8 |
|
||||||
|
| Projet | SIRH | 12 |
|
||||||
|
|
||||||
|
**IMPORTANT :** Toujours faire un appel `list-projects` / `list-users` / `list-priorities` en phase Discovery pour verifier que les IDs sont toujours valides. Les IDs ci-dessus sont un cache pour aller plus vite, pas une source de verite.
|
||||||
|
|
||||||
|
## Outils MCP Lesstime disponibles
|
||||||
|
|
||||||
|
Le MCP Lesstime expose 22 outils. Voici ceux utilises par ce skill :
|
||||||
|
|
||||||
|
### Discovery (appeler en premier pour mapper les IDs)
|
||||||
|
|
||||||
|
| Outil | Usage |
|
||||||
|
|-------|-------|
|
||||||
|
| `list-projects` | Trouver le projectId cible |
|
||||||
|
| `list-statuses` | Recuperer les statuts disponibles (label, id, color) |
|
||||||
|
| `list-priorities` | Recuperer les priorites disponibles (label, id, color) |
|
||||||
|
| `list-efforts` | Recuperer les niveaux d'effort (label, id) |
|
||||||
|
| `list-groups` | Lister les groupes d'un projet (par projectId) |
|
||||||
|
| `list-tags` | Lister les tags disponibles (label, id, color) |
|
||||||
|
| `list-users` | Lister les utilisateurs pour l'assignation |
|
||||||
|
|
||||||
|
### Creation
|
||||||
|
|
||||||
|
| Outil | Usage |
|
||||||
|
|-------|-------|
|
||||||
|
| `create-task` | Creer une tache (projectId, title, description, statusId, priorityId, effortId, assigneeId, groupId, tagIds) |
|
||||||
|
| `create-group` | Creer un groupe dans un projet (projectId, title) |
|
||||||
|
|
||||||
|
### Parametres de `create-task`
|
||||||
|
|
||||||
|
```
|
||||||
|
projectId: int (required) -- ID du projet cible
|
||||||
|
title: string (required) -- Titre du ticket (ex: "T-001 -- Supprimer le webhook hardcode")
|
||||||
|
description: string (optional) -- Corps complet du ticket (Pourquoi + A faire + Fichiers)
|
||||||
|
statusId: int (optional) -- ID du statut initial
|
||||||
|
priorityId: int (optional) -- ID de la priorite
|
||||||
|
effortId: int (optional) -- ID de l'effort estime
|
||||||
|
assigneeId: int (optional) -- ID de l'utilisateur assigne
|
||||||
|
groupId: int (optional) -- ID du groupe (utilise pour regrouper par priorite)
|
||||||
|
tagIds: int[] (optional) -- IDs des tags
|
||||||
|
```
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
```dot
|
||||||
|
digraph push_flow {
|
||||||
|
rankdir=TB;
|
||||||
|
"1. Lire TICKETS.md" -> "2. Discovery MCP (parallele)";
|
||||||
|
"2. Discovery MCP (parallele)" -> "3. Demander projet cible";
|
||||||
|
"3. Demander projet cible" -> "4. Mapper priorites";
|
||||||
|
"4. Mapper priorites" -> "5. Creer groupes si besoin";
|
||||||
|
"5. Creer groupes si besoin" -> "6. Creer les taches";
|
||||||
|
"6. Creer les taches" -> "7. Resume au user";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 1 -- Lire et parser TICKETS.md
|
||||||
|
|
||||||
|
Lire le fichier `TICKETS.md` du repertoire courant. Extraire :
|
||||||
|
- La liste des tickets avec leur ID (T-001, T-002, ...)
|
||||||
|
- Le titre de chaque ticket
|
||||||
|
- La priorite (P0, P1, P2, P3) -- derivee de la section dans laquelle se trouve le ticket
|
||||||
|
- Le corps complet (Pourquoi + A faire + Fichiers) -- sera la description de la tache
|
||||||
|
|
||||||
|
**Parsing :**
|
||||||
|
- Les sections `## P0`, `## P1`, `## P2`, `## P3` delimitent les groupes de priorite
|
||||||
|
- Chaque `### T-XXX -- {Titre}` est un ticket
|
||||||
|
- Tout le contenu entre deux `### T-XXX` constitue la description du ticket
|
||||||
|
|
||||||
|
### Phase 2 -- Discovery MCP (appels paralleles)
|
||||||
|
|
||||||
|
Appeler ces outils MCP **en parallele** pour recuperer les metadonnees :
|
||||||
|
|
||||||
|
1. `list-projects` -- pour afficher les projets disponibles
|
||||||
|
2. `list-statuses` -- pour mapper le statut initial des taches
|
||||||
|
3. `list-priorities` -- pour mapper P0/P1/P2/P3 aux priorites Lesstime
|
||||||
|
4. `list-efforts` -- pour estimer l'effort
|
||||||
|
5. `list-tags` -- pour les tags disponibles
|
||||||
|
|
||||||
|
### Phase 3 -- Demander le projet cible
|
||||||
|
|
||||||
|
Presenter a l'utilisateur la liste des projets Lesstime et lui demander :
|
||||||
|
1. **Quel projet ?** -- dans quel projet creer les taches
|
||||||
|
2. **Quel statut initial ?** -- ex: "To Do", "Backlog"
|
||||||
|
3. **Creer des groupes par priorite ?** -- ex: "P0 - Urgents", "P1 - Importants"
|
||||||
|
4. **Assigner a quelqu'un ?** -- optionnel
|
||||||
|
5. **Tags a ajouter ?** -- ex: "review", "tech-debt"
|
||||||
|
|
||||||
|
### Phase 4 -- Mapper les priorites
|
||||||
|
|
||||||
|
Mapper les priorites du TICKETS.md aux priorites Lesstime :
|
||||||
|
- P0 -> priorite la plus haute disponible (ex: "Urgent", "Critical")
|
||||||
|
- P1 -> priorite haute (ex: "High")
|
||||||
|
- P2 -> priorite moyenne (ex: "Medium")
|
||||||
|
- P3 -> priorite basse (ex: "Low")
|
||||||
|
|
||||||
|
Si le mapping n'est pas evident, demander confirmation a l'utilisateur.
|
||||||
|
|
||||||
|
### Phase 5 -- Creer les groupes (si demande)
|
||||||
|
|
||||||
|
Si l'utilisateur veut des groupes par priorite :
|
||||||
|
1. Creer le groupe "P0 - Urgents (securite)" via `create-group`
|
||||||
|
2. Creer le groupe "P1 - Importants" via `create-group`
|
||||||
|
3. Creer le groupe "P2 - Documentation" via `create-group`
|
||||||
|
4. Creer le groupe "P3 - Nice to have" via `create-group`
|
||||||
|
|
||||||
|
### Phase 6 -- Creer les taches
|
||||||
|
|
||||||
|
Pour chaque ticket dans TICKETS.md :
|
||||||
|
1. Construire le titre : `"T-XXX -- {titre}"`
|
||||||
|
2. Construire la description : le corps complet du ticket (Pourquoi + A faire + Fichiers)
|
||||||
|
3. Appeler `create-task` avec tous les parametres mappes
|
||||||
|
|
||||||
|
**Optimisation :** Creer les taches en parallele par batch de 5 pour eviter de surcharger l'API.
|
||||||
|
|
||||||
|
### Phase 7 -- Resume
|
||||||
|
|
||||||
|
Afficher un resume au user :
|
||||||
|
- Nombre de taches creees
|
||||||
|
- Repartition par priorite
|
||||||
|
- Lien vers le projet Lesstime (si disponible)
|
||||||
|
- Taches echouees (si applicable) avec raison
|
||||||
|
|
||||||
|
## Mapping par defaut
|
||||||
|
|
||||||
|
| TICKETS.md | Lesstime Priority | Lesstime Group |
|
||||||
|
|------------|-------------------|----------------|
|
||||||
|
| P0 | Urgent/Critical | "P0 - Urgents (securite)" |
|
||||||
|
| P1 | High | "P1 - Importants" |
|
||||||
|
| P2 | Medium | "P2 - Documentation" |
|
||||||
|
| P3 | Low | "P3 - Nice to have" |
|
||||||
|
|
||||||
|
## Common Mistakes
|
||||||
|
|
||||||
|
- **Oublier la phase Discovery** -- les IDs de priorites/statuts varient par workspace Lesstime
|
||||||
|
- **Ne pas demander confirmation** -- toujours valider le projet cible et le mapping avant de creer
|
||||||
|
- **Creer sans groupes** -- les groupes rendent la vue Lesstime beaucoup plus lisible
|
||||||
|
- **Description trop courte** -- inclure le corps complet du ticket, pas juste le titre
|
||||||
|
- **Ne pas gerer les erreurs** -- si une tache echoue, continuer avec les suivantes et reporter a la fin
|
||||||
61
.claude/skills/ticket-executor/LEARNINGS.md
Normal file
61
.claude/skills/ticket-executor/LEARNINGS.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Ticket Executor - Learnings
|
||||||
|
|
||||||
|
## Session 2026-03-17 (26 tickets)
|
||||||
|
|
||||||
|
### T-001 — Secrets .env
|
||||||
|
- **Pattern**: Replace secrets with `change_me_in_env_local` placeholder, move real values to `.env.local`
|
||||||
|
- **Gotcha**: `.env.local` must contain ALL overridden secrets
|
||||||
|
|
||||||
|
### T-002 — Security API Gitea
|
||||||
|
- **Pattern**: Ajouter `security: "is_granted('ROLE_USER')"` sur les opérations ApiResource
|
||||||
|
- **Learning**: Vérifier d'abord les ressources déjà sécurisées pour ne pas dupliquer
|
||||||
|
|
||||||
|
### T-003 — SVG Upload
|
||||||
|
- **Pattern**: Double protection - bloquer à l'upload (retirer du MIME allowlist) + defense-in-depth (Content-Disposition: attachment au download)
|
||||||
|
- **Learning**: Toujours vérifier upload ET download controllers
|
||||||
|
|
||||||
|
### T-004 — MCP create-task / Repos numérotation
|
||||||
|
- **Gotcha critique**: PostgreSQL n'autorise PAS `FOR UPDATE` avec des fonctions d'agrégation (`MAX`)
|
||||||
|
- **Fix**: Utiliser `pg_advisory_xact_lock()` au lieu de `FOR UPDATE` pour les queries avec agrégation
|
||||||
|
- **Pattern**: Offset les lock keys (+1000000) pour éviter collisions entre Task et ClientTicket
|
||||||
|
|
||||||
|
### T-005 — Filter ROLE_CLIENT projects
|
||||||
|
- **Pattern**: Créer une Doctrine Extension (`QueryCollectionExtensionInterface` + `QueryItemExtensionInterface`) pour filtrer par relation
|
||||||
|
- **Learning**: Symfony autoconfigure enregistre l'extension automatiquement
|
||||||
|
|
||||||
|
### T-006 — Block client doc upload
|
||||||
|
- **Pattern**: Vérifier le rôle dans le Processor AVANT de résoudre l'IRI de la tâche
|
||||||
|
- **Learning**: Le portail client envoie un `clientTicket` IRI (pas de `task` IRI), donc le check sur `taskIri` non-vide suffit
|
||||||
|
|
||||||
|
### T-007 — MCP role checks
|
||||||
|
- **Pattern**: Injecter `Security` dans chaque Tool, vérifier au début de `__invoke()`
|
||||||
|
- **Learning**: 22 tools à modifier - bien séparer ROLE_ADMIN (users/clients) vs ROLE_USER (le reste)
|
||||||
|
|
||||||
|
### T-009 — Password hashing
|
||||||
|
- **Pattern**: Champ `plainPassword` non-persisté, writable uniquement, hashé dans le Processor
|
||||||
|
- **Learning**: Modifier aussi le frontend (DTO + composant) quand on renomme un champ API
|
||||||
|
|
||||||
|
### T-010 — Rate limiting
|
||||||
|
- **Gotcha**: `login_throttling` nécessite `symfony/rate-limiter` installé, pas juste dans composer.json
|
||||||
|
- **Learning**: Toujours vérifier que les packages sont installés, pas juste déclarés
|
||||||
|
|
||||||
|
### T-012 — Harmoniser repos numérotation
|
||||||
|
- **Pattern**: Aligner les contrats (retourner le max, pas le next) et mettre le +1 côté appelant
|
||||||
|
- **Learning**: Vérifier TOUS les appelants d'une méthode renommée
|
||||||
|
|
||||||
|
### T-015 — useAvatarService
|
||||||
|
- **Learning**: Quand on migre vers `useApi()`, ajouter la détection FormData pour ne pas écraser le Content-Type multipart
|
||||||
|
|
||||||
|
### T-020 — i18n
|
||||||
|
- **Pattern**: Ajouter `useI18n()` dans le setup script avant de pouvoir utiliser `t()` dans le JS
|
||||||
|
- **Learning**: Les templates peuvent utiliser `$t()` directement sans import
|
||||||
|
|
||||||
|
### T-022 — Retirer twig-bundle
|
||||||
|
- **Pattern**: Retirer de composer.json + bundles.php + supprimer config YAML + templates
|
||||||
|
- **Learning**: API Platform ne requiert PAS twig, c'est juste suggéré pour Swagger UI
|
||||||
|
|
||||||
|
## Meta-learnings
|
||||||
|
- **Parallélisation**: Les tickets touchant des fichiers indépendants peuvent tourner en parallèle sans problème
|
||||||
|
- **MCP status**: Toujours mettre "En cours" AVANT de commencer, "Terminé" APRÈS validation
|
||||||
|
- **PostgreSQL gotchas**: Tester les queries SQL avec agrégation + locking sur PostgreSQL, pas MySQL
|
||||||
|
- **Agents**: Les agents simples (1-3 fichiers) terminent en ~30s, les complexes (22 fichiers) en ~8min
|
||||||
78
.claude/skills/ticket-executor/SKILL.md
Normal file
78
.claude/skills/ticket-executor/SKILL.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
name: ticket-executor
|
||||||
|
description: Execute Lesstime project tickets systematically - updates MCP statuses, follows project conventions, and logs learnings for self-improvement
|
||||||
|
---
|
||||||
|
|
||||||
|
# Ticket Executor Skill
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Execute Lesstime project tickets end-to-end: read the ticket, implement the fix, update MCP status, and log learnings.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### 1. Receive Ticket
|
||||||
|
- Get ticket ID, title, description, tags (Backend/Frontend), priority, and current status
|
||||||
|
- Understand the scope from the title and description
|
||||||
|
|
||||||
|
### 2. Set Status to "En cours" (ID: 2)
|
||||||
|
- Use MCP `update-task` with `statusId: 2` before starting work
|
||||||
|
- MCP endpoint: `http://project.malio-dev.fr/_mcp`
|
||||||
|
- Auth: `Bearer 7e8b410a5b79b5c0432951dcee3a3a81e0731e86d9f70d8784ec079a2b759c64`
|
||||||
|
|
||||||
|
### 3. Analyze & Implement
|
||||||
|
Based on tag:
|
||||||
|
- **Backend**: Check `src/Entity/`, `src/State/`, `src/Controller/`, `src/Security/`, `config/`
|
||||||
|
- **Frontend**: Check `frontend/components/`, `frontend/composables/`, `frontend/pages/`, `frontend/services/`
|
||||||
|
|
||||||
|
Conventions to follow:
|
||||||
|
- PHP: `declare(strict_types=1)`, Symfony + PSR-12, API Platform patterns
|
||||||
|
- Frontend: TypeScript strict, `useApi()` composable, 4 spaces indent
|
||||||
|
- See CLAUDE.md for full conventions
|
||||||
|
|
||||||
|
### 4. Verify
|
||||||
|
- For Backend: `make php-cs-fixer-allow-risky` if PHP changed
|
||||||
|
- For Frontend: check TypeScript types, no `any`
|
||||||
|
- Read modified files to confirm correctness
|
||||||
|
|
||||||
|
### 5. Set Status to "Terminé" (ID: 5)
|
||||||
|
- Use MCP `update-task` with `statusId: 5` after successful implementation
|
||||||
|
|
||||||
|
### 6. Log Learnings
|
||||||
|
Append to `.claude/skills/ticket-executor/LEARNINGS.md`:
|
||||||
|
- What worked well
|
||||||
|
- Patterns discovered
|
||||||
|
- Gotchas encountered
|
||||||
|
- Time-saving shortcuts found
|
||||||
|
|
||||||
|
## MCP Session Management
|
||||||
|
The MCP HTTP transport requires a session. To call tools:
|
||||||
|
```bash
|
||||||
|
# Initialize session (get Mcp-Session-Id from response header)
|
||||||
|
curl -si -X POST http://project.malio-dev.fr/_mcp \
|
||||||
|
-H "Authorization: Bearer <token>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"claude","version":"1.0"}}}'
|
||||||
|
|
||||||
|
# Call tool (use Mcp-Session-Id from init response)
|
||||||
|
curl -s -X POST http://project.malio-dev.fr/_mcp \
|
||||||
|
-H "Authorization: Bearer <token>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Mcp-Session-Id: <session-id>" \
|
||||||
|
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"update-task","arguments":{"id":<taskId>,"statusId":<statusId>}}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Status IDs
|
||||||
|
- 1 = A faire
|
||||||
|
- 2 = En cours
|
||||||
|
- 3 = Bloqué
|
||||||
|
- 4 = En attente de validation
|
||||||
|
- 5 = Terminé
|
||||||
|
|
||||||
|
## Learnings Integration
|
||||||
|
Before each ticket, read `LEARNINGS.md` to apply previous insights.
|
||||||
|
After each ticket, append new learnings. This creates a feedback loop that improves execution quality over time.
|
||||||
|
|
||||||
|
## Parallel Execution Rules
|
||||||
|
- Independent tickets (no shared files) can run in parallel via worktree agents
|
||||||
|
- Tickets modifying the same files must run sequentially
|
||||||
|
- Always verify no merge conflicts after parallel execution
|
||||||
24
.dockerignore
Normal file
24
.dockerignore
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.env.local
|
||||||
|
.env.test
|
||||||
|
infra/dev/
|
||||||
|
infra/prod/docker-compose.yml
|
||||||
|
infra/prod/deploy.sh
|
||||||
|
infra/prod/deploy-release.sh
|
||||||
|
infra/prod/.env.example
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/.nuxt
|
||||||
|
frontend/.output
|
||||||
|
var/
|
||||||
|
vendor/
|
||||||
|
LOG/
|
||||||
|
docs/
|
||||||
|
tests/
|
||||||
|
*.sql
|
||||||
|
*.xlsx
|
||||||
|
*.png
|
||||||
|
*.md
|
||||||
|
!composer.lock
|
||||||
|
!symfony.lock
|
||||||
|
!frontend/package-lock.json
|
||||||
14
.env
14
.env
@@ -20,4 +20,16 @@ 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 ###
|
||||||
|
|||||||
@@ -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.
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
||||||
|
|||||||
30
.gitea/workflows/build-docker.yml
Normal file
30
.gitea/workflows/build-docker.yml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
name: Build & Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to Gitea Registry
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.malio.fr -u "${{ gitea.repository_owner }}" --password-stdin
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
-f infra/prod/Dockerfile \
|
||||||
|
-t gitea.malio.fr/malio-dev/lesstime:${{ gitea.ref_name }} \
|
||||||
|
-t gitea.malio.fr/malio-dev/lesstime:latest \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Push Docker image
|
||||||
|
run: |
|
||||||
|
docker push gitea.malio.fr/malio-dev/lesstime:${{ gitea.ref_name }}
|
||||||
|
docker push 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 }}
|
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -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 ###
|
||||||
|
|||||||
17
CLAUDE.md
17
CLAUDE.md
@@ -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
|
||||||
@@ -103,6 +105,10 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
|||||||
- 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.
|
||||||
|
|||||||
0
LOG/xdebug.log
Normal file
0
LOG/xdebug.log
Normal file
@@ -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)
|
||||||
|
|
||||||
@@ -73,6 +74,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 +158,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
|
||||||
|
|
||||||
|
|||||||
@@ -16,16 +16,20 @@
|
|||||||
"nelmio/cors-bundle": "^2.6",
|
"nelmio/cors-bundle": "^2.6",
|
||||||
"nyholm/psr7": "^1.8",
|
"nyholm/psr7": "^1.8",
|
||||||
"phpdocumentor/reflection-docblock": "^5.6|^6.0",
|
"phpdocumentor/reflection-docblock": "^5.6|^6.0",
|
||||||
|
"phpoffice/phpspreadsheet": "^5.5",
|
||||||
"phpstan/phpdoc-parser": "^2.3",
|
"phpstan/phpdoc-parser": "^2.3",
|
||||||
"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.*",
|
||||||
@@ -35,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": {
|
||||||
@@ -92,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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1846
composer.lock
generated
1846
composer.lock
generated
File diff suppressed because it is too large
Load Diff
2
config/packages/lock.yaml
Normal file
2
config/packages/lock.yaml
Normal file
@@ -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']
|
||||||
|
|||||||
28
config/packages/messenger.yaml
Normal file
28
config/packages/messenger.yaml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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:
|
||||||
|
'App\Message\MailSyncRequested': async
|
||||||
|
|
||||||
|
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:
|
||||||
|
|||||||
5
config/packages/translation.yaml
Normal file
5
config/packages/translation.yaml
Normal file
@@ -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,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.3.7'
|
app.version: '0.4.1'
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
364
doc/deployment-docker.md
Normal file
364
doc/deployment-docker.md
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
# Deploiement Docker — Lesstime
|
||||||
|
|
||||||
|
## Pre-requis
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ubuntu
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y ca-certificates curl gnupg
|
||||||
|
sudo install -m 0755 -d /etc/apt/keyrings
|
||||||
|
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||||
|
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
||||||
|
sudo usermod -aG docker $USER
|
||||||
|
```
|
||||||
|
|
||||||
|
Se deconnecter/reconnecter pour que le groupe `docker` prenne effet.
|
||||||
|
|
||||||
|
### Nginx
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y nginx
|
||||||
|
sudo systemctl enable nginx
|
||||||
|
sudo systemctl start nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### PostgreSQL
|
||||||
|
|
||||||
|
PostgreSQL tourne dans un conteneur Docker separe (voir le repo `infra-postgres`).
|
||||||
|
Il doit etre installe et accessible avant de deployer Lesstime.
|
||||||
|
|
||||||
|
Creer la base de donnees pour Lesstime :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/postgres
|
||||||
|
docker compose exec postgres psql -U admin
|
||||||
|
```
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Si le user n'existe pas encore
|
||||||
|
CREATE USER malio WITH PASSWORD 'motdepasse';
|
||||||
|
|
||||||
|
-- Creer la base
|
||||||
|
CREATE DATABASE lesstime_prod OWNER malio;
|
||||||
|
\q
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Premiere installation (nouvelle machine)
|
||||||
|
|
||||||
|
Guide complet pour mettre en ligne Lesstime sur une machine vierge. Inclut les pre-requis, la BDD et l'app.
|
||||||
|
|
||||||
|
### 1. Installer les pre-requis
|
||||||
|
|
||||||
|
Installer Docker, Nginx et PostgreSQL (voir section Pre-requis ci-dessus).
|
||||||
|
|
||||||
|
### 2. Creer le dossier de deploiement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /var/www/lesstime
|
||||||
|
sudo chown -R $(whoami):$(whoami) /var/www/lesstime
|
||||||
|
cd /var/www/lesstime
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Se connecter au registry Docker de Gitea
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker login gitea.malio.fr
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Username** : le nom d'utilisateur du compte organisation Gitea `MALIO`
|
||||||
|
- **Password** : le token REGISTRY_TOKEN dispo dans le bitwarden
|
||||||
|
|
||||||
|
Le login est sauvegarde dans `~/.docker/config.json`, pas besoin de le refaire a chaque deploiement.
|
||||||
|
|
||||||
|
### 4. Creer les fichiers de deploiement
|
||||||
|
|
||||||
|
Creer `docker-compose.yml` :
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: gitea.malio.fr/malio-dev/lesstime:${LESSTIME_IMAGE_TAG:-latest}
|
||||||
|
container_name: lesstime-app
|
||||||
|
env_file: .env
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
volumes:
|
||||||
|
- ./config/jwt:/var/www/html/config/jwt:ro
|
||||||
|
- ./uploads:/var/www/html/var/uploads
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
Creer `deploy.sh` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
TAG="${1:-latest}"
|
||||||
|
export LESSTIME_IMAGE_TAG="$TAG"
|
||||||
|
|
||||||
|
echo "==> Deploying lesstime:${TAG}..."
|
||||||
|
|
||||||
|
echo "==> Enabling maintenance mode..."
|
||||||
|
touch maintenance.on
|
||||||
|
|
||||||
|
echo "==> Pulling image..."
|
||||||
|
sudo docker compose pull
|
||||||
|
|
||||||
|
echo "==> Starting container..."
|
||||||
|
sudo docker compose up -d
|
||||||
|
|
||||||
|
echo "==> Waiting for container to be ready..."
|
||||||
|
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..."
|
||||||
|
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
||||||
|
|
||||||
|
echo "==> Clearing cache..."
|
||||||
|
sudo 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:warmup --env=prod
|
||||||
|
|
||||||
|
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}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Rendre executable :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Configurer l'environnement
|
||||||
|
|
||||||
|
Creer `.env` avec les variables suivantes :
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Symfony
|
||||||
|
APP_ENV=prod
|
||||||
|
APP_DEBUG=0
|
||||||
|
APP_SECRET=<generer avec: openssl rand -hex 32>
|
||||||
|
|
||||||
|
# Database (host.docker.internal = la machine hote, ou le PG tourne en Docker)
|
||||||
|
DATABASE_URL="postgresql://malio:password@host.docker.internal:5432/lesstime_prod?serverVersion=16&charset=utf8"
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
||||||
|
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
||||||
|
JWT_PASSPHRASE=<generer avec: openssl rand -hex 32>
|
||||||
|
JWT_COOKIE_SECURE=1
|
||||||
|
JWT_COOKIE_SAMESITE=lax
|
||||||
|
JWT_TOKEN_TTL=86400
|
||||||
|
JWT_COOKIE_TTL=86400
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ALLOW_ORIGIN='^https?://project\.malio-dev\.fr$'
|
||||||
|
|
||||||
|
# App
|
||||||
|
DEFAULT_URI=https://project.malio-dev.fr
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Generer les cles JWT
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p config/jwt
|
||||||
|
openssl genpkey -algorithm RSA -out config/jwt/private.pem -pkeyopt rsa_keygen_bits:4096
|
||||||
|
openssl rsa -pubout -in config/jwt/private.pem -out config/jwt/public.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
Rendre les cles lisibles par le conteneur (www-data = uid 33) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo chown 33:33 config/jwt/private.pem config/jwt/public.pem
|
||||||
|
sudo chmod 644 config/jwt/private.pem config/jwt/public.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Creer le dossier uploads
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p uploads
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Configurer Nginx systeme
|
||||||
|
|
||||||
|
Creer `/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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Activer le site :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ln -sf /etc/nginx/sites-available/lesstime.conf /etc/nginx/sites-enabled/lesstime.conf
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. Deployer
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10. Importer les donnees (optionnel)
|
||||||
|
|
||||||
|
Si tu as un dump SQL a importer :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Depuis ton PC, envoyer le dump vers le serveur
|
||||||
|
scp lesstime.sql user@serveur:/tmp/lesstime.sql
|
||||||
|
|
||||||
|
# Sur le serveur, vider la base puis importer
|
||||||
|
cd /var/www/postgres
|
||||||
|
docker compose exec -T postgres psql -U malio lesstime_prod -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
|
||||||
|
docker compose exec -T postgres psql -U malio lesstime_prod < /tmp/lesstime.sql
|
||||||
|
|
||||||
|
# Creer les tables manquantes (si le dump a des erreurs de syntaxe)
|
||||||
|
cd /var/www/lesstime
|
||||||
|
docker compose exec -u www-data app php bin/console doctrine:schema:update --force --env=prod
|
||||||
|
|
||||||
|
# Nettoyer
|
||||||
|
rm /tmp/lesstime.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### Structure finale du dossier
|
||||||
|
|
||||||
|
```
|
||||||
|
/var/www/lesstime/
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── deploy.sh
|
||||||
|
├── .env
|
||||||
|
├── config/jwt/
|
||||||
|
│ ├── private.pem
|
||||||
|
│ └── public.pem
|
||||||
|
├── public/
|
||||||
|
│ └── maintenance.html # extrait automatiquement par deploy.sh
|
||||||
|
└── uploads/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployer une nouvelle version
|
||||||
|
|
||||||
|
Quand l'app est deja installee, deployer une mise a jour :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/lesstime
|
||||||
|
./deploy.sh # deploie la derniere version (latest)
|
||||||
|
./deploy.sh v0.3.13 # deploie une version specifique
|
||||||
|
```
|
||||||
|
|
||||||
|
C'est tout. Le script pull l'image, redemarre le conteneur, lance les migrations et vide le cache.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
### Image seule (pas de changement de schema BDD)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy.sh v0.3.12
|
||||||
|
```
|
||||||
|
|
||||||
|
### Avec rollback de migration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Rollback schema (pendant que la version actuelle tourne encore)
|
||||||
|
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate prev --no-interaction
|
||||||
|
# 2. Deployer l'ancienne version
|
||||||
|
./deploy.sh v0.3.12
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
Le workflow `.gitea/workflows/build-docker.yml` se declenche automatiquement sur push de tag `v*` :
|
||||||
|
1. Build l'image multi-stage
|
||||||
|
2. Push vers `gitea.malio.fr/malio-dev/lesstime:<tag>` et `:latest`
|
||||||
|
|
||||||
|
Combine avec `auto-tag-develop.yml`, chaque push sur `develop` cree automatiquement un tag → build → image disponible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Voir les logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/lesstime
|
||||||
|
docker compose logs -f # tous les logs
|
||||||
|
docker compose logs -f --tail=100 # 100 dernieres lignes
|
||||||
|
```
|
||||||
|
|
||||||
|
Logs Symfony :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec app cat var/log/prod.log
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration depuis l'ancien deploiement (bare-metal)
|
||||||
|
|
||||||
|
Si l'application tourne deja en bare metal :
|
||||||
|
|
||||||
|
1. Installer Docker (voir pre-requis)
|
||||||
|
2. Creer le dossier `/var/www/lesstime-docker/` (ne pas ecraser l'ancien)
|
||||||
|
3. Copier les fichiers existants :
|
||||||
|
```bash
|
||||||
|
cp /var/www/lesstime/.env /var/www/lesstime-docker/.env
|
||||||
|
cp -a /var/www/lesstime/config/jwt /var/www/lesstime-docker/config/jwt
|
||||||
|
cp -a /var/www/lesstime/var/uploads /var/www/lesstime-docker/uploads
|
||||||
|
```
|
||||||
|
4. Creer `docker-compose.yml` et `deploy.sh` dans `/var/www/lesstime-docker/` (voir etape 4 ci-dessus)
|
||||||
|
5. Editer `/var/www/lesstime-docker/.env` : changer `DATABASE_URL` pour utiliser `host.docker.internal` au lieu de `127.0.0.1`
|
||||||
|
6. Se connecter au registry Gitea (voir etape 3 ci-dessus)
|
||||||
|
7. Mettre a jour Nginx systeme avec la conf reverse proxy (voir etape 8 ci-dessus)
|
||||||
|
8. Arreter l'ancien PHP-FPM : `sudo systemctl stop php8.4-fpm`
|
||||||
|
9. Deployer : `cd /var/www/lesstime-docker && ./deploy.sh`
|
||||||
|
10. Verifier que tout marche, puis renommer le dossier : `mv /var/www/lesstime-docker /var/www/lesstime`
|
||||||
153
doc/setup-maintenance-mode.md
Normal file
153
doc/setup-maintenance-mode.md
Normal file
@@ -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/
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
|||||||
87
docs/claude-time-tracking-rule.md
Normal file
87
docs/claude-time-tracking-rule.md
Normal file
@@ -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é
|
||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
111
docs/mail-cron-setup.md
Normal file
111
docs/mail-cron-setup.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## Prérequis
|
||||||
|
|
||||||
|
- Container `php-lesstime-fpm` démarré (`make start`)
|
||||||
|
- `MailConfiguration.enabled = true` (configurable depuis l'admin — Phase 7)
|
||||||
|
- `ENCRYPTION_KEY` défini dans `infra/dev/.env.docker.local` (ou production env)
|
||||||
|
|
||||||
|
## Installation du cron
|
||||||
|
|
||||||
|
Sur la **machine hôte** (pas dans le container) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
crontab -e
|
||||||
|
```
|
||||||
|
|
||||||
|
Ajouter la ligne suivante (adapter le chemin) :
|
||||||
|
|
||||||
|
```cron
|
||||||
|
*/10 * * * * cd /home/r-dev/malio-dev/Lesstime && make mail-sync >> /var/log/lesstime-mail-sync.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Ou directement via `docker exec` (sans dépendance à `make`) :
|
||||||
|
|
||||||
|
```cron
|
||||||
|
*/10 * * * * docker exec php-lesstime-fpm php bin/console app:mail:sync >> /var/log/lesstime-mail-sync.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Avec un utilisateur système dédié
|
||||||
|
|
||||||
|
Si le cron est configuré pour un utilisateur système spécifique (ex: `www-data` ou `deploy`) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo crontab -u deploy -e
|
||||||
|
```
|
||||||
|
|
||||||
|
## Variables d'environnement nécessaires
|
||||||
|
|
||||||
|
| 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é doit être la même que celle utilisée pour chiffrer le password lors de la configuration.
|
||||||
|
|
||||||
|
## 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`)
|
||||||
|
|
||||||
|
## Commandes utiles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Sync complète (toutes les boîtes)
|
||||||
|
make mail-sync
|
||||||
|
|
||||||
|
# Sync d'un seul dossier (le dossier doit déjà exister en base)
|
||||||
|
make mail-sync FOLDER=INBOX
|
||||||
|
|
||||||
|
# Simulation (dry-run, pas d'écriture BDD)
|
||||||
|
make mail-sync DRYRUN=1
|
||||||
|
|
||||||
|
# Directement dans le container
|
||||||
|
docker exec php-lesstime-fpm php bin/console app:mail:sync
|
||||||
|
docker exec php-lesstime-fpm php bin/console app:mail:sync --folder=INBOX
|
||||||
|
docker exec php-lesstime-fpm php bin/console app:mail:sync --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logs
|
||||||
|
|
||||||
|
Les logs Symfony sont dans `var/log/dev.log` (ou `prod.log` en production).
|
||||||
|
Suivre les logs en temps réel :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make logs-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Les messages loggés par `MailSyncService` sont préfixés `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`)
|
||||||
|
|
||||||
|
## 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é
|
||||||
|
|
||||||
|
## Production
|
||||||
|
|
||||||
|
En production, préférer un cron système ou un job scheduler (Kubernetes CronJob, ECS Scheduled Task, etc.).
|
||||||
|
La commande est idempotente : relancer plusieurs fois ne duplique pas les données (UIDs uniques en base).
|
||||||
147
docs/mail-integration.md
Normal file
147
docs/mail-integration.md
Normal file
@@ -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) |
|
||||||
777
docs/superpowers/plans/2026-03-24-time-entry-export.md
Normal file
777
docs/superpowers/plans/2026-03-24-time-entry-export.md
Normal file
@@ -0,0 +1,777 @@
|
|||||||
|
# Time Entry XLSX Export — Implementation Plan
|
||||||
|
|
||||||
|
> **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:** Add XLSX export of time tracking data with detail + summary sheets for CIR/JEI tax documents.
|
||||||
|
|
||||||
|
**Architecture:** Custom Symfony controller generates XLSX via PhpSpreadsheet, returns BinaryFileResponse. Frontend adds an export button on time-tracking page that triggers download with current filters.
|
||||||
|
|
||||||
|
**Tech Stack:** PHP 8.4, Symfony 8.0, PhpSpreadsheet, Nuxt 4 / Vue 3
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-03-24-time-entry-export-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Install PhpSpreadsheet
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `composer.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Install the dependency**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -t php-lesstime-fpm composer require phpoffice/phpspreadsheet
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify installation**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -t php-lesstime-fpm php -r "require 'vendor/autoload.php'; new \PhpOffice\PhpSpreadsheet\Spreadsheet(); echo 'OK';"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `OK`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add composer.json composer.lock
|
||||||
|
git commit -m "chore : add phpoffice/phpspreadsheet dependency for time entry export"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Add repository method for filtered time entries
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Repository/TimeEntryRepository.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `findForExport` method**
|
||||||
|
|
||||||
|
Add this method to `TimeEntryRepository`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
/**
|
||||||
|
* @param int[]|null $tagIds
|
||||||
|
* @return TimeEntry[]
|
||||||
|
*/
|
||||||
|
public function findForExport(
|
||||||
|
\DateTimeImmutable $after,
|
||||||
|
\DateTimeImmutable $before,
|
||||||
|
?User $user = null,
|
||||||
|
?Project $project = null,
|
||||||
|
?array $tagIds = null,
|
||||||
|
): array {
|
||||||
|
$qb = $this->createQueryBuilder('te')
|
||||||
|
->andWhere('te.startedAt >= :after')
|
||||||
|
->andWhere('te.startedAt < :before')
|
||||||
|
->setParameter('after', $after)
|
||||||
|
->setParameter('before', $before)
|
||||||
|
->orderBy('te.startedAt', 'ASC');
|
||||||
|
|
||||||
|
if (null !== $user) {
|
||||||
|
$qb->andWhere('te.user = :user')
|
||||||
|
->setParameter('user', $user);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $project) {
|
||||||
|
$qb->andWhere('te.project = :project')
|
||||||
|
->setParameter('project', $project);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $tagIds && [] !== $tagIds) {
|
||||||
|
$qb->join('te.tags', 'tag')
|
||||||
|
->andWhere('tag.id IN (:tagIds)')
|
||||||
|
->setParameter('tagIds', $tagIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $qb->getQuery()->getResult();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add missing use statements if needed**
|
||||||
|
|
||||||
|
Ensure these imports are at the top of the file:
|
||||||
|
```php
|
||||||
|
use App\Entity\Project;
|
||||||
|
use App\Entity\User;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify no syntax errors**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -t php-lesstime-fpm php -l src/Repository/TimeEntryRepository.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `No syntax errors detected`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Repository/TimeEntryRepository.php
|
||||||
|
git commit -m "feat : add findForExport repository method for time entries"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Create TimeEntryExportService
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/Service/TimeEntryExportService.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the service with all three sheets**
|
||||||
|
|
||||||
|
Create `src/Service/TimeEntryExportService.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Entity\TimeEntry;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||||
|
|
||||||
|
class TimeEntryExportService
|
||||||
|
{
|
||||||
|
private const array DETAIL_HEADERS = [
|
||||||
|
'Date', 'Utilisateur', 'Projet', 'Tâche', 'Titre',
|
||||||
|
'Tags', 'Début', 'Fin', 'Durée (h)', 'Description',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const array MONTH_NAMES = [
|
||||||
|
1 => 'Janvier', 2 => 'Février', 3 => 'Mars', 4 => 'Avril',
|
||||||
|
5 => 'Mai', 6 => 'Juin', 7 => 'Juillet', 8 => 'Août',
|
||||||
|
9 => 'Septembre', 10 => 'Octobre', 11 => 'Novembre', 12 => 'Décembre',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param TimeEntry[] $timeEntries
|
||||||
|
*
|
||||||
|
* @return string Path to the generated temp file
|
||||||
|
*/
|
||||||
|
public function generate(array $timeEntries, \DateTimeImmutable $from, \DateTimeImmutable $to): string
|
||||||
|
{
|
||||||
|
$spreadsheet = new Spreadsheet();
|
||||||
|
|
||||||
|
$this->buildDetailSheet($spreadsheet, $timeEntries);
|
||||||
|
$this->buildProjectRecapSheet($spreadsheet, $timeEntries);
|
||||||
|
$this->buildMonthRecapSheet($spreadsheet, $timeEntries, $from, $to);
|
||||||
|
|
||||||
|
$spreadsheet->setActiveSheetIndex(0);
|
||||||
|
|
||||||
|
$tempFile = tempnam(sys_get_temp_dir(), 'export_temps_') . '.xlsx';
|
||||||
|
$writer = new Xlsx($spreadsheet);
|
||||||
|
$writer->save($tempFile);
|
||||||
|
|
||||||
|
return $tempFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param TimeEntry[] $timeEntries
|
||||||
|
*/
|
||||||
|
private function buildDetailSheet(Spreadsheet $spreadsheet, array $timeEntries): void
|
||||||
|
{
|
||||||
|
$sheet = $spreadsheet->getActiveSheet();
|
||||||
|
$sheet->setTitle('Détail');
|
||||||
|
|
||||||
|
// Headers
|
||||||
|
foreach (self::DETAIL_HEADERS as $col => $header) {
|
||||||
|
$colLetter = Coordinate::stringFromColumnIndex($col + 1);
|
||||||
|
$sheet->setCellValue("{$colLetter}1", $header);
|
||||||
|
}
|
||||||
|
$this->boldRow($sheet, 1, \count(self::DETAIL_HEADERS));
|
||||||
|
|
||||||
|
// Data rows
|
||||||
|
$row = 2;
|
||||||
|
foreach ($timeEntries as $entry) {
|
||||||
|
$duration = $this->computeDuration($entry);
|
||||||
|
$task = $entry->getTask();
|
||||||
|
$taskLabel = '';
|
||||||
|
if (null !== $task) {
|
||||||
|
$project = $task->getProject();
|
||||||
|
$code = $project?->getCode() ?? '';
|
||||||
|
$taskLabel = $code . '-' . $task->getNumber() . ' - ' . $task->getTitle();
|
||||||
|
}
|
||||||
|
|
||||||
|
$tagLabels = $entry->getTags()->map(fn ($t) => $t->getLabel() ?? '')->toArray();
|
||||||
|
|
||||||
|
$sheet->setCellValue("A{$row}", $entry->getStartedAt()->format('Y-m-d'));
|
||||||
|
$sheet->setCellValue("B{$row}", $entry->getUser()?->getUsername() ?? '');
|
||||||
|
$sheet->setCellValue("C{$row}", $entry->getProject()?->getName() ?? '');
|
||||||
|
$sheet->setCellValue("D{$row}", $taskLabel);
|
||||||
|
$sheet->setCellValue("E{$row}", $entry->getTitle() ?? '');
|
||||||
|
$sheet->setCellValue("F{$row}", implode(', ', $tagLabels));
|
||||||
|
$sheet->setCellValue("G{$row}", $entry->getStartedAt()->format('H:i'));
|
||||||
|
$sheet->setCellValue("H{$row}", $entry->getStoppedAt()?->format('H:i') ?? '');
|
||||||
|
$sheet->setCellValue("I{$row}", round($duration, 2));
|
||||||
|
$sheet->setCellValue("J{$row}", $entry->getDescription() ?? '');
|
||||||
|
|
||||||
|
++$row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total row
|
||||||
|
if ($row > 2) {
|
||||||
|
$sheet->setCellValue("H{$row}", 'Total');
|
||||||
|
$sheet->getStyle("H{$row}")->getFont()->setBold(true);
|
||||||
|
$sheet->setCellValue("I{$row}", "=SUM(I2:I" . ($row - 1) . ')');
|
||||||
|
$sheet->getStyle("I{$row}")->getFont()->setBold(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-size columns
|
||||||
|
foreach (range('A', 'J') as $col) {
|
||||||
|
$sheet->getColumnDimension($col)->setAutoSize(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param TimeEntry[] $timeEntries
|
||||||
|
*/
|
||||||
|
private function buildProjectRecapSheet(Spreadsheet $spreadsheet, array $timeEntries): void
|
||||||
|
{
|
||||||
|
$sheet = $spreadsheet->createSheet();
|
||||||
|
$sheet->setTitle('Récap par projet');
|
||||||
|
|
||||||
|
// Aggregate: user → project → hours
|
||||||
|
$data = [];
|
||||||
|
$projects = [];
|
||||||
|
$users = [];
|
||||||
|
|
||||||
|
foreach ($timeEntries as $entry) {
|
||||||
|
$userName = $entry->getUser()?->getUsername() ?? 'Inconnu';
|
||||||
|
$projectName = $entry->getProject()?->getName() ?? 'Sans projet';
|
||||||
|
$duration = $this->computeDuration($entry);
|
||||||
|
|
||||||
|
$users[$userName] = true;
|
||||||
|
$projects[$projectName] = true;
|
||||||
|
$data[$userName][$projectName] = ($data[$userName][$projectName] ?? 0) + $duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($users);
|
||||||
|
ksort($projects);
|
||||||
|
$projectList = array_keys($projects);
|
||||||
|
$userList = array_keys($users);
|
||||||
|
|
||||||
|
// Headers
|
||||||
|
$sheet->setCellValue('A1', 'Utilisateur');
|
||||||
|
$col = 2;
|
||||||
|
foreach ($projectList as $project) {
|
||||||
|
$letter = Coordinate::stringFromColumnIndex($col);
|
||||||
|
$sheet->setCellValue("{$letter}1", $project);
|
||||||
|
++$col;
|
||||||
|
}
|
||||||
|
$totalLetter = Coordinate::stringFromColumnIndex($col);
|
||||||
|
$sheet->setCellValue("{$totalLetter}1", 'Total');
|
||||||
|
$this->boldRow($sheet, 1, $col);
|
||||||
|
|
||||||
|
// Data rows
|
||||||
|
$row = 2;
|
||||||
|
foreach ($userList as $user) {
|
||||||
|
$sheet->setCellValue("A{$row}", $user);
|
||||||
|
$col = 2;
|
||||||
|
$userTotal = 0;
|
||||||
|
foreach ($projectList as $project) {
|
||||||
|
$val = round($data[$user][$project] ?? 0, 2);
|
||||||
|
$letter = Coordinate::stringFromColumnIndex($col);
|
||||||
|
$sheet->setCellValue("{$letter}{$row}", $val);
|
||||||
|
$userTotal += $val;
|
||||||
|
++$col;
|
||||||
|
}
|
||||||
|
$letter = Coordinate::stringFromColumnIndex($col);
|
||||||
|
$sheet->setCellValue("{$letter}{$row}", round($userTotal, 2));
|
||||||
|
$sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);
|
||||||
|
++$row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total row
|
||||||
|
$sheet->setCellValue("A{$row}", 'Total');
|
||||||
|
$sheet->getStyle("A{$row}")->getFont()->setBold(true);
|
||||||
|
$col = 2;
|
||||||
|
foreach ($projectList as $project) {
|
||||||
|
$projectTotal = 0;
|
||||||
|
foreach ($userList as $user) {
|
||||||
|
$projectTotal += $data[$user][$project] ?? 0;
|
||||||
|
}
|
||||||
|
$letter = Coordinate::stringFromColumnIndex($col);
|
||||||
|
$sheet->setCellValue("{$letter}{$row}", round($projectTotal, 2));
|
||||||
|
$sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);
|
||||||
|
++$col;
|
||||||
|
}
|
||||||
|
// Grand total
|
||||||
|
$grandTotal = 0;
|
||||||
|
foreach ($data as $userData) {
|
||||||
|
foreach ($userData as $hours) {
|
||||||
|
$grandTotal += $hours;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$letter = Coordinate::stringFromColumnIndex($col);
|
||||||
|
$sheet->setCellValue("{$letter}{$row}", round($grandTotal, 2));
|
||||||
|
$sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);
|
||||||
|
|
||||||
|
// Auto-size
|
||||||
|
for ($c = 1; $c <= $col; ++$c) {
|
||||||
|
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($c))->setAutoSize(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param TimeEntry[] $timeEntries
|
||||||
|
*/
|
||||||
|
private function buildMonthRecapSheet(Spreadsheet $spreadsheet, array $timeEntries, \DateTimeImmutable $from, \DateTimeImmutable $to): void
|
||||||
|
{
|
||||||
|
$sheet = $spreadsheet->createSheet();
|
||||||
|
$sheet->setTitle('Récap par mois');
|
||||||
|
|
||||||
|
// Build month columns from the date range
|
||||||
|
$months = [];
|
||||||
|
$current = $from->modify('first day of this month');
|
||||||
|
$end = $to->modify('first day of this month');
|
||||||
|
while ($current <= $end) {
|
||||||
|
$key = $current->format('Y-m');
|
||||||
|
$label = self::MONTH_NAMES[(int) $current->format('n')] . ' ' . $current->format('Y');
|
||||||
|
$months[$key] = $label;
|
||||||
|
$current = $current->modify('+1 month');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate: user → month-key → hours
|
||||||
|
$data = [];
|
||||||
|
$users = [];
|
||||||
|
|
||||||
|
foreach ($timeEntries as $entry) {
|
||||||
|
$userName = $entry->getUser()?->getUsername() ?? 'Inconnu';
|
||||||
|
$monthKey = $entry->getStartedAt()->format('Y-m');
|
||||||
|
$duration = $this->computeDuration($entry);
|
||||||
|
|
||||||
|
$users[$userName] = true;
|
||||||
|
$data[$userName][$monthKey] = ($data[$userName][$monthKey] ?? 0) + $duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($users);
|
||||||
|
$userList = array_keys($users);
|
||||||
|
$monthKeys = array_keys($months);
|
||||||
|
|
||||||
|
// Headers
|
||||||
|
$sheet->setCellValue('A1', 'Utilisateur');
|
||||||
|
$col = 2;
|
||||||
|
foreach ($months as $label) {
|
||||||
|
$letter = Coordinate::stringFromColumnIndex($col);
|
||||||
|
$sheet->setCellValue("{$letter}1", $label);
|
||||||
|
++$col;
|
||||||
|
}
|
||||||
|
$totalLetter = Coordinate::stringFromColumnIndex($col);
|
||||||
|
$sheet->setCellValue("{$totalLetter}1", 'Total');
|
||||||
|
$this->boldRow($sheet, 1, $col);
|
||||||
|
|
||||||
|
// Data rows
|
||||||
|
$row = 2;
|
||||||
|
foreach ($userList as $user) {
|
||||||
|
$sheet->setCellValue("A{$row}", $user);
|
||||||
|
$col = 2;
|
||||||
|
$userTotal = 0;
|
||||||
|
foreach ($monthKeys as $monthKey) {
|
||||||
|
$val = round($data[$user][$monthKey] ?? 0, 2);
|
||||||
|
$letter = Coordinate::stringFromColumnIndex($col);
|
||||||
|
$sheet->setCellValue("{$letter}{$row}", $val);
|
||||||
|
$userTotal += $val;
|
||||||
|
++$col;
|
||||||
|
}
|
||||||
|
$letter = Coordinate::stringFromColumnIndex($col);
|
||||||
|
$sheet->setCellValue("{$letter}{$row}", round($userTotal, 2));
|
||||||
|
$sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);
|
||||||
|
++$row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total row
|
||||||
|
$sheet->setCellValue("A{$row}", 'Total');
|
||||||
|
$sheet->getStyle("A{$row}")->getFont()->setBold(true);
|
||||||
|
$col = 2;
|
||||||
|
foreach ($monthKeys as $monthKey) {
|
||||||
|
$monthTotal = 0;
|
||||||
|
foreach ($userList as $user) {
|
||||||
|
$monthTotal += $data[$user][$monthKey] ?? 0;
|
||||||
|
}
|
||||||
|
$letter = Coordinate::stringFromColumnIndex($col);
|
||||||
|
$sheet->setCellValue("{$letter}{$row}", round($monthTotal, 2));
|
||||||
|
$sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);
|
||||||
|
++$col;
|
||||||
|
}
|
||||||
|
$grandTotal = 0;
|
||||||
|
foreach ($data as $userData) {
|
||||||
|
foreach ($userData as $hours) {
|
||||||
|
$grandTotal += $hours;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$letter = Coordinate::stringFromColumnIndex($col);
|
||||||
|
$sheet->setCellValue("{$letter}{$row}", round($grandTotal, 2));
|
||||||
|
$sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);
|
||||||
|
|
||||||
|
// Auto-size
|
||||||
|
for ($c = 1; $c <= $col; ++$c) {
|
||||||
|
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($c))->setAutoSize(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function computeDuration(TimeEntry $entry): float
|
||||||
|
{
|
||||||
|
$start = $entry->getStartedAt();
|
||||||
|
$end = $entry->getStoppedAt();
|
||||||
|
|
||||||
|
if (null === $start || null === $end) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($end->getTimestamp() - $start->getTimestamp()) / 3600;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function boldRow(Worksheet $sheet, int $row, int $colCount): void
|
||||||
|
{
|
||||||
|
for ($c = 1; $c <= $colCount; ++$c) {
|
||||||
|
$letter = Coordinate::stringFromColumnIndex($c);
|
||||||
|
$sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify no syntax errors**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -t php-lesstime-fpm php -l src/Service/TimeEntryExportService.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `No syntax errors detected`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Service/TimeEntryExportService.php
|
||||||
|
git commit -m "feat : add TimeEntryExportService generating XLSX with detail and recap sheets"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Create TimeEntryExportController
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/Controller/TimeEntryExportController.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the controller**
|
||||||
|
|
||||||
|
Create `src/Controller/TimeEntryExportController.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\Project;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\TimeEntryRepository;
|
||||||
|
use App\Service\TimeEntryExportService;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
class TimeEntryExportController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly TimeEntryRepository $timeEntryRepository,
|
||||||
|
private readonly TimeEntryExportService $exportService,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
#[Route('/api/time_entries/export', name: 'time_entry_export', methods: ['GET'], priority: 1)]
|
||||||
|
#[IsGranted('ROLE_USER')]
|
||||||
|
public function __invoke(Request $request): BinaryFileResponse
|
||||||
|
{
|
||||||
|
$afterStr = $request->query->getString('after');
|
||||||
|
$beforeStr = $request->query->getString('before');
|
||||||
|
|
||||||
|
if ('' === $afterStr || '' === $beforeStr) {
|
||||||
|
throw new BadRequestHttpException('Les paramètres "after" et "before" sont obligatoires.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$after = new \DateTimeImmutable($afterStr);
|
||||||
|
$before = new \DateTimeImmutable($beforeStr);
|
||||||
|
} catch (\Exception) {
|
||||||
|
throw new BadRequestHttpException('Format de date invalide. Utilisez YYYY-MM-DD.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Max range: 12 months
|
||||||
|
if ($after->modify('+12 months') < $before) {
|
||||||
|
throw new BadRequestHttpException('La plage de dates ne peut pas dépasser 12 mois.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorization: non-admin users can only export their own data
|
||||||
|
$user = null;
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $this->security->getUser();
|
||||||
|
} else {
|
||||||
|
$userId = $request->query->getInt('user');
|
||||||
|
if ($userId > 0) {
|
||||||
|
$user = $this->entityManager->getRepository(User::class)->find($userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$project = null;
|
||||||
|
$projectId = $request->query->getInt('project');
|
||||||
|
if ($projectId > 0) {
|
||||||
|
$project = $this->entityManager->getRepository(Project::class)->find($projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var int[] $tagIds */
|
||||||
|
$tagIds = array_filter(
|
||||||
|
array_map('intval', (array) $request->query->all('tags')),
|
||||||
|
fn (int $id) => $id > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
$entries = $this->timeEntryRepository->findForExport(
|
||||||
|
$after,
|
||||||
|
$before,
|
||||||
|
$user,
|
||||||
|
$project,
|
||||||
|
$tagIds ?: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$tempFile = $this->exportService->generate($entries, $after, $before);
|
||||||
|
|
||||||
|
$filename = sprintf('export-temps-%s_%s.xlsx', $after->format('Y-m-d'), $before->format('Y-m-d'));
|
||||||
|
|
||||||
|
$response = new BinaryFileResponse($tempFile);
|
||||||
|
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename);
|
||||||
|
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||||
|
$response->deleteFileAfterSend(true);
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify no syntax errors**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -t php-lesstime-fpm php -l src/Controller/TimeEntryExportController.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `No syntax errors detected`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Clear cache and verify route is registered**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -t php-lesstime-fpm php bin/console cache:clear
|
||||||
|
docker exec -t php-lesstime-fpm php bin/console debug:router | grep time_entry_export
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: line showing `time_entry_export` route mapped to `GET /api/time_entries/export`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Controller/TimeEntryExportController.php
|
||||||
|
git commit -m "feat : add TimeEntryExportController with auth, validation, and filters"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Manual backend smoke test
|
||||||
|
|
||||||
|
- [ ] **Step 1: Test missing params returns 400**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -t php-lesstime-fpm php bin/console debug:router time_entry_export
|
||||||
|
```
|
||||||
|
|
||||||
|
Then via curl (using admin fixture token):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" -b "BEARER=$(curl -s -X POST http://localhost:8082/login_check -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin"}' | grep -o '"token":"[^"]*"' | cut -d'"' -f4)" "http://localhost:8082/api/time_entries/export"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `400`
|
||||||
|
|
||||||
|
- [ ] **Step 2: Test valid export returns XLSX**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN=$(curl -s -X POST http://localhost:8082/login_check -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
|
||||||
|
curl -s -o /tmp/test-export.xlsx -w "%{http_code}" -b "BEARER=${TOKEN}" "http://localhost:8082/api/time_entries/export?after=2025-01-01&before=2026-12-31"
|
||||||
|
echo ""
|
||||||
|
file /tmp/test-export.xlsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: HTTP `200`, file type contains `Microsoft Excel` or `Zip archive`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit (no changes — verification only)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Add frontend export method and i18n
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/services/time-entries.ts`
|
||||||
|
- Modify: `frontend/i18n/locales/fr.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `getExportUrl` method to time-entries service**
|
||||||
|
|
||||||
|
Add this function inside `useTimeEntryService()` before the `return` statement in `frontend/services/time-entries.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function getExportUrl(params: {
|
||||||
|
after: string
|
||||||
|
before: string
|
||||||
|
user?: number
|
||||||
|
project?: number
|
||||||
|
tags?: number[]
|
||||||
|
}): string {
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
query.set('after', params.after)
|
||||||
|
query.set('before', params.before)
|
||||||
|
if (params.user) query.set('user', String(params.user))
|
||||||
|
if (params.project) query.set('project', String(params.project))
|
||||||
|
if (params.tags?.length) {
|
||||||
|
params.tags.forEach(id => query.append('tags[]', String(id)))
|
||||||
|
}
|
||||||
|
return `/api/time_entries/export?${query.toString()}`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the return statement to include `getExportUrl`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
return { getByDateRange, getActive, create, update, remove, getExportUrl }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add i18n key**
|
||||||
|
|
||||||
|
In `frontend/i18n/locales/fr.json`, add `"export": "Exporter"` inside the `"timeEntries"` object.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/services/time-entries.ts frontend/i18n/locales/fr.json
|
||||||
|
git commit -m "feat : add getExportUrl to time-entries service and i18n key"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Add export button to time-tracking page
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/pages/time-tracking.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add export button in template**
|
||||||
|
|
||||||
|
In `frontend/pages/time-tracking.vue`, find the `<div>` containing the `MalioSelect` for tags (the last filter). After its closing `</div>`, add:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<button
|
||||||
|
class="flex shrink-0 items-center gap-2 rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 transition"
|
||||||
|
@click="exportTimeEntries"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:download" size="18" />
|
||||||
|
{{ $t('timeEntries.export') }}
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add export function in script**
|
||||||
|
|
||||||
|
Add this function in the `<script setup>` section, after the existing helper functions (near `loadEntries`):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function getExportDateRange(): { after: string, before: string } {
|
||||||
|
if (Array.isArray(selectedDateFilter.value) && selectedDateFilter.value.length === 2) {
|
||||||
|
return {
|
||||||
|
after: selectedDateFilter.value[0].toISOString().slice(0, 10),
|
||||||
|
before: selectedDateFilter.value[1].toISOString().slice(0, 10),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const end = new Date(startDate.value)
|
||||||
|
end.setDate(end.getDate() + (viewMode.value === 'day' ? 1 : 7))
|
||||||
|
return {
|
||||||
|
after: startDate.value.toISOString().slice(0, 10),
|
||||||
|
before: end.toISOString().slice(0, 10),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportTimeEntries() {
|
||||||
|
const { after, before } = getExportDateRange()
|
||||||
|
|
||||||
|
const url = timeEntryService.getExportUrl({
|
||||||
|
after,
|
||||||
|
before,
|
||||||
|
user: selectedUserId.value ?? undefined,
|
||||||
|
project: selectedProjectId.value ?? undefined,
|
||||||
|
tags: selectedTagId.value ? [selectedTagId.value] : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = ''
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify dev server compiles without errors**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npx nuxi typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no errors (or only pre-existing ones)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/pages/time-tracking.vue
|
||||||
|
git commit -m "feat : add export button to time-tracking page"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: End-to-end manual test
|
||||||
|
|
||||||
|
- [ ] **Step 1: Start dev server and test in browser**
|
||||||
|
|
||||||
|
1. Open `http://localhost:3002/time-tracking`
|
||||||
|
2. Verify the "Exporter" button appears in the filter bar
|
||||||
|
3. Select a date range with existing time entries
|
||||||
|
4. Click "Exporter"
|
||||||
|
5. Verify an `.xlsx` file downloads
|
||||||
|
|
||||||
|
- [ ] **Step 2: Open the XLSX and verify structure**
|
||||||
|
|
||||||
|
1. Feuille "Détail" — rows with Date, Utilisateur, Projet, etc. + total row
|
||||||
|
2. Feuille "Récap par projet" — users × projects cross-table
|
||||||
|
3. Feuille "Récap par mois" — users × months cross-table
|
||||||
|
|
||||||
|
- [ ] **Step 3: Test as non-admin user**
|
||||||
|
|
||||||
|
1. Log in as `alice` / `alice`
|
||||||
|
2. Export — verify only Alice's entries appear (even if user filter was different)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run PHP CS Fixer**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make php-cs-fixer-allow-risky
|
||||||
|
```
|
||||||
|
|
||||||
|
Fix any issues, then commit if needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A && git commit -m "style : fix code style for time entry export"
|
||||||
|
```
|
||||||
@@ -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
|
||||||
1632
docs/superpowers/plans/2026-05-19-mail-phase1-foundations.md
Normal file
1632
docs/superpowers/plans/2026-05-19-mail-phase1-foundations.md
Normal file
File diff suppressed because it is too large
Load Diff
1781
docs/superpowers/plans/2026-05-19-mail-phase2-imap-sync.md
Normal file
1781
docs/superpowers/plans/2026-05-19-mail-phase2-imap-sync.md
Normal file
File diff suppressed because it is too large
Load Diff
2410
docs/superpowers/plans/2026-05-19-mail-phase3-api.md
Normal file
2410
docs/superpowers/plans/2026-05-19-mail-phase3-api.md
Normal file
File diff suppressed because it is too large
Load Diff
1107
docs/superpowers/plans/2026-05-19-mail-phase4-frontend-services.md
Normal file
1107
docs/superpowers/plans/2026-05-19-mail-phase4-frontend-services.md
Normal file
File diff suppressed because it is too large
Load Diff
1203
docs/superpowers/plans/2026-05-19-mail-phase5-ui-main.md
Normal file
1203
docs/superpowers/plans/2026-05-19-mail-phase5-ui-main.md
Normal file
File diff suppressed because it is too large
Load Diff
1509
docs/superpowers/plans/2026-05-19-mail-phase6-task-integration.md
Normal file
1509
docs/superpowers/plans/2026-05-19-mail-phase6-task-integration.md
Normal file
File diff suppressed because it is too large
Load Diff
526
docs/superpowers/plans/2026-05-19-mail-phase7-admin-polish.md
Normal file
526
docs/superpowers/plans/2026-05-19-mail-phase7-admin-polish.md
Normal file
@@ -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
|
||||||
3036
docs/superpowers/plans/2026-05-19-project-workflows.md
Normal file
3036
docs/superpowers/plans/2026-05-19-project-workflows.md
Normal file
File diff suppressed because it is too large
Load Diff
144
docs/superpowers/specs/2026-03-24-time-entry-export-design.md
Normal file
144
docs/superpowers/specs/2026-03-24-time-entry-export-design.md
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# Export temps suivi de temps (XLSX)
|
||||||
|
|
||||||
|
**Ticket** : LST-41
|
||||||
|
**Date** : 2026-03-24
|
||||||
|
**Statut** : Approuvé
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
Les exports de suivi de temps sont nécessaires pour constituer des dossiers CIR (Crédit Impôt Recherche) et JEI (Jeune Entreprise Innovante). Ces dossiers exigent une ventilation détaillée du temps passé par collaborateur, par projet et par mois.
|
||||||
|
|
||||||
|
## Décisions
|
||||||
|
|
||||||
|
- **Format** : XLSX (via PhpSpreadsheet côté backend)
|
||||||
|
- **Déclenchement** : bouton "Exporter" sur la page time-tracking, reprenant les filtres en cours
|
||||||
|
- **Récap** : double tableau croisé (user × projet + user × mois)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Frontend Backend
|
||||||
|
───────── ───────
|
||||||
|
Bouton "Exporter"
|
||||||
|
→ GET /api/time_entries/export → TimeEntryExportController
|
||||||
|
?after=2026-01-01 → Validation params + authz
|
||||||
|
&before=2026-03-31 → TimeEntryRepository (query)
|
||||||
|
&user=5 → TimeEntryExportService (XLSX)
|
||||||
|
&project=5 → BinaryFileResponse (.xlsx)
|
||||||
|
&tags[]=2
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
### Dépendance
|
||||||
|
|
||||||
|
`phpoffice/phpspreadsheet` ajouté via Composer.
|
||||||
|
|
||||||
|
### TimeEntryExportController
|
||||||
|
|
||||||
|
- Fichier : `src/Controller/TimeEntryExportController.php`
|
||||||
|
- Route : `GET /api/time_entries/export` avec `priority: 1`
|
||||||
|
- Sécurité : `#[IsGranted('ROLE_USER')]`
|
||||||
|
- **Autorisation** : si l'utilisateur n'a pas `ROLE_ADMIN`, le filtre `user` est forcé à l'utilisateur courant (ignore toute valeur fournie). Seuls les admins peuvent exporter les données d'autres utilisateurs ou de tous les utilisateurs.
|
||||||
|
- Paramètres query (IDs numériques, pas d'IRIs — c'est un controller custom, pas API Platform) :
|
||||||
|
- `after` (obligatoire) — date YYYY-MM-DD
|
||||||
|
- `before` (obligatoire) — date YYYY-MM-DD
|
||||||
|
- `user` (optionnel) — ID numérique (ex: `5`)
|
||||||
|
- `project` (optionnel) — ID numérique (ex: `5`)
|
||||||
|
- `tags[]` (optionnel) — tableau d'IDs numériques (ex: `tags[]=2&tags[]=3`)
|
||||||
|
- **Validation** :
|
||||||
|
- `after` et `before` obligatoires, sinon 400 Bad Request
|
||||||
|
- Plage maximale : 12 mois, sinon 400 Bad Request
|
||||||
|
- Si aucune entrée trouvée : retourne un XLSX avec en-têtes uniquement (pas d'erreur)
|
||||||
|
- Construit une query Doctrine avec ces filtres
|
||||||
|
- Appelle `TimeEntryExportService::generate()`
|
||||||
|
- Retourne `BinaryFileResponse` avec header `Content-Disposition: attachment; filename="export-temps-YYYY-MM-DD_YYYY-MM-DD.xlsx"`
|
||||||
|
|
||||||
|
### TimeEntryExportService
|
||||||
|
|
||||||
|
- Fichier : `src/Service/TimeEntryExportService.php`
|
||||||
|
- Méthode : `generate(array $timeEntries, \DateTimeImmutable $from, \DateTimeImmutable $to): string` (retourne le chemin du fichier temp)
|
||||||
|
|
||||||
|
#### Feuille 1 — "Détail"
|
||||||
|
|
||||||
|
Toutes les entrées triées par date croissante.
|
||||||
|
|
||||||
|
| Colonne | Source | Format |
|
||||||
|
|---------|--------|--------|
|
||||||
|
| Date | `startedAt` | YYYY-MM-DD |
|
||||||
|
| Utilisateur | `user.username` | texte |
|
||||||
|
| Projet | `project.name` | texte (vide si null) |
|
||||||
|
| Tâche | `task` | "{code}-{number} - {title}" (vide si null) |
|
||||||
|
| Titre | `title` | texte |
|
||||||
|
| Tags | `tags` | labels séparés par ", " |
|
||||||
|
| Début | `startedAt` | HH:mm |
|
||||||
|
| Fin | `stoppedAt` | HH:mm (vide si null) |
|
||||||
|
| Durée (h) | calculée | nombre décimal (ex: 3.50) |
|
||||||
|
| Description | `description` | texte |
|
||||||
|
|
||||||
|
- En-têtes en gras
|
||||||
|
- Colonnes auto-dimensionnées
|
||||||
|
- Ligne de total en bas (somme de la colonne Durée)
|
||||||
|
|
||||||
|
#### Feuille 2 — "Récap par projet"
|
||||||
|
|
||||||
|
Tableau croisé dynamique :
|
||||||
|
- Lignes = utilisateurs (triés alphabétiquement)
|
||||||
|
- Colonnes = projets (triés alphabétiquement)
|
||||||
|
- Cellules = total heures (décimal)
|
||||||
|
- Dernière colonne = total par utilisateur
|
||||||
|
- Dernière ligne = total par projet
|
||||||
|
|
||||||
|
#### Feuille 3 — "Récap par mois"
|
||||||
|
|
||||||
|
Tableau croisé dynamique :
|
||||||
|
- Lignes = utilisateurs (triés alphabétiquement)
|
||||||
|
- Colonnes = mois de la période (format "Mars 2026")
|
||||||
|
- Cellules = total heures (décimal)
|
||||||
|
- Dernière colonne = total par utilisateur
|
||||||
|
- Dernière ligne = total par mois
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
### Page time-tracking
|
||||||
|
|
||||||
|
- Ajout d'un bouton "Exporter" dans la barre d'actions (à côté des filtres existants)
|
||||||
|
- Icône de téléchargement + label "Exporter"
|
||||||
|
- Au clic : construit l'URL `/api/time_entries/export` avec les filtres actuels (période affichée, user sélectionné, projet sélectionné, tags sélectionnés) et déclenche le téléchargement
|
||||||
|
|
||||||
|
### Service time-entries.ts
|
||||||
|
|
||||||
|
Ajout d'une méthode :
|
||||||
|
```typescript
|
||||||
|
function getExportUrl(params: {
|
||||||
|
after: string // YYYY-MM-DD
|
||||||
|
before: string // YYYY-MM-DD
|
||||||
|
user?: number // ID numérique
|
||||||
|
project?: number // ID numérique
|
||||||
|
tags?: number[] // tableau d'IDs
|
||||||
|
}): string
|
||||||
|
```
|
||||||
|
|
||||||
|
Construit l'URL complète avec query params. Le téléchargement est déclenché via un élément `<a>` temporaire avec attribut `download` (le cookie JWT est envoyé automatiquement sur une requête same-origin). En cas d'erreur, un toast est affiché.
|
||||||
|
|
||||||
|
### i18n
|
||||||
|
|
||||||
|
- `timeEntries.export` → "Exporter" (fr)
|
||||||
|
|
||||||
|
## Sécurité
|
||||||
|
|
||||||
|
- Accessible à `ROLE_USER` (même niveau que la consultation des time entries)
|
||||||
|
- **Non-admin : export limité à ses propres données** (filtre `user` forcé côté serveur)
|
||||||
|
- Le fichier XLSX est généré dans un fichier temporaire et supprimé après envoi
|
||||||
|
- Les filtres utilisent des IDs numériques (controller custom, pas d'IRI)
|
||||||
|
|
||||||
|
## Langue
|
||||||
|
|
||||||
|
Le contenu du XLSX est toujours en français (noms de feuilles, en-têtes de colonnes, noms de mois). C'est volontaire car les documents CIR/JEI sont des dossiers destinés à l'administration française.
|
||||||
|
|
||||||
|
## Hors scope
|
||||||
|
|
||||||
|
- Export PDF
|
||||||
|
- Export CSV
|
||||||
|
- Stockage des exports générés
|
||||||
|
- Planification d'exports automatiques
|
||||||
224
docs/superpowers/specs/2026-05-19-project-workflows-design.md
Normal file
224
docs/superpowers/specs/2026-05-19-project-workflows-design.md
Normal file
@@ -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.
|
||||||
@@ -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') }}
|
||||||
@@ -32,21 +28,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button
|
<MalioButton
|
||||||
type="submit"
|
:label="$t('bookstack.settings.save')"
|
||||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:opacity-50"
|
button-class="w-auto px-4"
|
||||||
:disabled="isSaving"
|
:disabled="isSaving"
|
||||||
>
|
@click="handleSave"
|
||||||
{{ $t('bookstack.settings.save') }}
|
/>
|
||||||
</button>
|
<MalioButton
|
||||||
<button
|
variant="tertiary"
|
||||||
type="button"
|
:label="$t('bookstack.settings.testConnection')"
|
||||||
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50 disabled:opacity-50"
|
button-class="w-auto px-4"
|
||||||
:disabled="isTesting"
|
:disabled="isTesting"
|
||||||
@click="handleTest"
|
@click="handleTest"
|
||||||
>
|
/>
|
||||||
{{ $t('bookstack.settings.testConnection') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'">
|
<p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'">
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="text-lg font-bold text-neutral-900">Clients</h2>
|
<h2 class="text-lg font-bold text-neutral-900">Clients</h2>
|
||||||
<button
|
<MalioButton
|
||||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
icon-name="mdi:plus"
|
||||||
|
icon-position="left"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
label="Ajouter un client"
|
||||||
@click="openCreate"
|
@click="openCreate"
|
||||||
>
|
/>
|
||||||
+ Ajouter un client
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
|
|||||||
@@ -92,19 +92,21 @@
|
|||||||
<td class="px-3 py-3 text-neutral-400">{{ formatDate(ticket.createdAt) }}</td>
|
<td class="px-3 py-3 text-neutral-400">{{ formatDate(ticket.createdAt) }}</td>
|
||||||
<td class="px-3 py-3">
|
<td class="px-3 py-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<MalioButtonIcon
|
||||||
class="rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-primary-500"
|
icon="mdi:swap-horizontal"
|
||||||
:title="$t('clientTicket.changeStatus')"
|
:aria-label="$t('clientTicket.changeStatus')"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="18"
|
||||||
@click.stop="openStatusChange(ticket)"
|
@click.stop="openStatusChange(ticket)"
|
||||||
>
|
/>
|
||||||
<Icon name="mdi:swap-horizontal" size="18" />
|
<MalioButtonIcon
|
||||||
</button>
|
icon="mdi:delete-outline"
|
||||||
<button
|
aria-label="Supprimer"
|
||||||
class="rounded p-1 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500"
|
variant="ghost"
|
||||||
|
icon-size="18"
|
||||||
|
button-class="text-neutral-400 hover:bg-red-50 hover:text-red-500"
|
||||||
@click.stop="openDeleteConfirm(ticket)"
|
@click.stop="openDeleteConfirm(ticket)"
|
||||||
>
|
/>
|
||||||
<Icon name="mdi:delete-outline" size="18" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -155,19 +157,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
<button
|
<MalioButton
|
||||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
variant="tertiary"
|
||||||
|
:label="$t('common.cancel')"
|
||||||
|
button-class="w-auto px-4"
|
||||||
@click="statusModalOpen = false"
|
@click="statusModalOpen = false"
|
||||||
>
|
/>
|
||||||
{{ $t('common.cancel') }}
|
<MalioButton
|
||||||
</button>
|
label="Confirmer"
|
||||||
<button
|
button-class="w-auto px-6"
|
||||||
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
:disabled="isUpdatingStatus"
|
:disabled="isUpdatingStatus"
|
||||||
@click="confirmStatusChange"
|
@click="confirmStatusChange"
|
||||||
>
|
/>
|
||||||
Confirmer
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -186,19 +187,19 @@
|
|||||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.confirmDelete') }}</h3>
|
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.confirmDelete') }}</h3>
|
||||||
<p class="mt-2 text-sm text-neutral-600">{{ $t('clientTicket.confirmDeleteMessage') }}</p>
|
<p class="mt-2 text-sm text-neutral-600">{{ $t('clientTicket.confirmDeleteMessage') }}</p>
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
<button
|
<MalioButton
|
||||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
variant="tertiary"
|
||||||
|
:label="$t('common.cancel')"
|
||||||
|
button-class="w-auto px-4"
|
||||||
@click="deleteModalOpen = false"
|
@click="deleteModalOpen = false"
|
||||||
>
|
/>
|
||||||
{{ $t('common.cancel') }}
|
<MalioButton
|
||||||
</button>
|
variant="danger"
|
||||||
<button
|
label="Supprimer"
|
||||||
class="rounded-lg bg-red-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50"
|
button-class="w-auto px-6"
|
||||||
:disabled="isDeleting"
|
:disabled="isDeleting"
|
||||||
@click="confirmDelete"
|
@click="confirmDelete"
|
||||||
>
|
/>
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="text-lg font-bold text-neutral-900">Efforts</h2>
|
<h2 class="text-lg font-bold text-neutral-900">Efforts</h2>
|
||||||
<button
|
<MalioButton
|
||||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
icon-name="mdi:plus"
|
||||||
|
icon-position="left"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
label="Ajouter un effort"
|
||||||
@click="openCreate"
|
@click="openCreate"
|
||||||
>
|
/>
|
||||||
+ Ajouter un effort
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
|
|||||||
@@ -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') }}
|
||||||
@@ -24,21 +22,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button
|
<MalioButton
|
||||||
type="submit"
|
:label="$t('gitea.settings.save')"
|
||||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:opacity-50"
|
button-class="w-auto px-4"
|
||||||
:disabled="isSaving"
|
:disabled="isSaving"
|
||||||
>
|
@click="handleSave"
|
||||||
{{ $t('gitea.settings.save') }}
|
/>
|
||||||
</button>
|
<MalioButton
|
||||||
<button
|
variant="tertiary"
|
||||||
type="button"
|
:label="$t('gitea.settings.testConnection')"
|
||||||
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50 disabled:opacity-50"
|
button-class="w-auto px-4"
|
||||||
:disabled="isTesting"
|
:disabled="isTesting"
|
||||||
@click="handleTest"
|
@click="handleTest"
|
||||||
>
|
/>
|
||||||
{{ $t('gitea.settings.testConnection') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'">
|
<p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'">
|
||||||
|
|||||||
231
frontend/components/admin/AdminMailTab.vue
Normal file
231
frontend/components/admin/AdminMailTab.vue
Normal file
@@ -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>
|
||||||
@@ -2,12 +2,13 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="text-lg font-bold text-neutral-900">Priorités</h2>
|
<h2 class="text-lg font-bold text-neutral-900">Priorités</h2>
|
||||||
<button
|
<MalioButton
|
||||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
icon-name="mdi:plus"
|
||||||
|
icon-position="left"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
label="Ajouter une priorité"
|
||||||
@click="openCreate"
|
@click="openCreate"
|
||||||
>
|
/>
|
||||||
+ Ajouter une priorité
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
|
|||||||
@@ -1,139 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h2 class="text-lg font-bold text-neutral-900">Statuts</h2>
|
|
||||||
<button
|
|
||||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
|
||||||
@click="openCreate"
|
|
||||||
>
|
|
||||||
+ Ajouter un statut
|
|
||||||
</button>
|
|
||||||
</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>
|
|
||||||
@@ -2,12 +2,13 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="text-lg font-bold text-neutral-900">Tags</h2>
|
<h2 class="text-lg font-bold text-neutral-900">Tags</h2>
|
||||||
<button
|
<MalioButton
|
||||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
icon-name="mdi:plus"
|
||||||
|
icon-position="left"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
label="Ajouter un tag"
|
||||||
@click="openCreate"
|
@click="openCreate"
|
||||||
>
|
/>
|
||||||
+ Ajouter un tag
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="text-lg font-bold text-neutral-900">Utilisateurs</h2>
|
<h2 class="text-lg font-bold text-neutral-900">Utilisateurs</h2>
|
||||||
<button
|
<MalioButton
|
||||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
icon-name="mdi:plus"
|
||||||
|
icon-position="left"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
label="Ajouter un utilisateur"
|
||||||
@click="openCreate"
|
@click="openCreate"
|
||||||
>
|
/>
|
||||||
+ Ajouter un utilisateur
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
|
|||||||
100
frontend/components/admin/AdminWorkflowTab.vue
Normal file
100
frontend/components/admin/AdminWorkflowTab.vue
Normal file
@@ -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') }}
|
||||||
@@ -37,21 +36,19 @@
|
|||||||
<span class="text-sm">{{ $t('zimbra.settings.enabled') }}</span>
|
<span class="text-sm">{{ $t('zimbra.settings.enabled') }}</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button
|
<MalioButton
|
||||||
type="submit"
|
:label="$t('zimbra.settings.save')"
|
||||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:opacity-50"
|
button-class="w-auto px-4"
|
||||||
:disabled="isSaving"
|
:disabled="isSaving"
|
||||||
>
|
@click="handleSave"
|
||||||
{{ $t('zimbra.settings.save') }}
|
/>
|
||||||
</button>
|
<MalioButton
|
||||||
<button
|
variant="tertiary"
|
||||||
type="button"
|
:label="$t('zimbra.settings.testConnection')"
|
||||||
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50 disabled:opacity-50"
|
button-class="w-auto px-4"
|
||||||
:disabled="isTesting"
|
:disabled="isTesting"
|
||||||
@click="handleTest"
|
@click="handleTest"
|
||||||
>
|
/>
|
||||||
{{ $t('zimbra.settings.testConnection') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'">
|
<p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'">
|
||||||
{{ testResult ? $t('zimbra.settings.testSuccess') : $t('zimbra.settings.testFailed') }}
|
{{ testResult ? $t('zimbra.settings.testSuccess') : $t('zimbra.settings.testFailed') }}
|
||||||
|
|||||||
261
frontend/components/admin/WorkflowDrawer.vue
Normal file
261
frontend/components/admin/WorkflowDrawer.vue
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
<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"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
v-model="s.category"
|
||||||
|
class="h-10 rounded border border-neutral-300 px-2 text-sm"
|
||||||
|
aria-label="Catégorie"
|
||||||
|
>
|
||||||
|
<option v-for="c in categoryOptions" :key="c.value" :value="c.value">
|
||||||
|
{{ c.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<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 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
|
||||||
|
})
|
||||||
|
|
||||||
|
function addStatus() {
|
||||||
|
form.statuses.push({
|
||||||
|
label: '',
|
||||||
|
color: '#222783',
|
||||||
|
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>
|
||||||
@@ -29,22 +29,22 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<!-- Edit button (only for open tickets submitted by current user) -->
|
<!-- Edit button (only for open tickets submitted by current user) -->
|
||||||
<button
|
<MalioButton
|
||||||
v-if="canEdit && !isEditing"
|
v-if="canEdit && !isEditing"
|
||||||
type="button"
|
variant="tertiary"
|
||||||
class="flex h-8 items-center gap-1.5 rounded-lg px-3 text-sm font-medium text-primary-500 transition-colors hover:bg-primary-50"
|
icon-name="mdi:pencil-outline"
|
||||||
|
icon-position="left"
|
||||||
|
button-class="w-auto px-3"
|
||||||
|
:label="$t('common.edit')"
|
||||||
@click="startEdit"
|
@click="startEdit"
|
||||||
>
|
/>
|
||||||
<Icon name="mdi:pencil-outline" size="16" />
|
<MalioButtonIcon
|
||||||
{{ $t('common.edit') }}
|
icon="mdi:close"
|
||||||
</button>
|
aria-label="Fermer"
|
||||||
<button
|
variant="ghost"
|
||||||
type="button"
|
icon-size="20"
|
||||||
class="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-400 transition-colors hover:bg-neutral-200/60 hover:text-neutral-600"
|
|
||||||
@click="close"
|
@click="close"
|
||||||
>
|
/>
|
||||||
<Icon name="mdi:close" size="20" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -90,21 +86,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
<button
|
<MalioButton
|
||||||
type="button"
|
variant="tertiary"
|
||||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
:label="$t('common.cancel')"
|
||||||
|
button-class="w-auto px-4"
|
||||||
@click="cancelEdit"
|
@click="cancelEdit"
|
||||||
>
|
/>
|
||||||
{{ $t('common.cancel') }}
|
<MalioButton
|
||||||
</button>
|
:label="$t('common.save')"
|
||||||
<button
|
button-class="w-auto px-6"
|
||||||
type="button"
|
|
||||||
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
:disabled="isSaving"
|
:disabled="isSaving"
|
||||||
@click="saveEdit"
|
@click="saveEdit"
|
||||||
>
|
/>
|
||||||
{{ $t('common.save') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -132,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) -->
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<!-- Trigger button -->
|
<!-- Trigger button -->
|
||||||
<button
|
<MalioButton
|
||||||
class="relative flex shrink-0 items-center gap-2 rounded-md bg-neutral-100 px-3 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-200 sm:px-4"
|
variant="tertiary"
|
||||||
|
icon-name="mdi:ticket-outline"
|
||||||
|
icon-position="left"
|
||||||
|
button-class="w-auto px-3 sm:px-4 shrink-0"
|
||||||
@click="open"
|
@click="open"
|
||||||
>
|
>
|
||||||
<Icon name="mdi:ticket-outline" class="size-4 sm:size-5" />
|
|
||||||
<span class="hidden sm:inline">{{ $t('clientTicket.adminTab') }}</span>
|
<span class="hidden sm:inline">{{ $t('clientTicket.adminTab') }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="totalCount > 0"
|
v-if="totalCount > 0"
|
||||||
@@ -13,7 +15,7 @@
|
|||||||
>
|
>
|
||||||
{{ totalCount }}
|
{{ totalCount }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</MalioButton>
|
||||||
|
|
||||||
<!-- Panel -->
|
<!-- Panel -->
|
||||||
<Teleport v-if="isOpen" to="body">
|
<Teleport v-if="isOpen" to="body">
|
||||||
@@ -33,13 +35,13 @@
|
|||||||
<h2 class="text-base font-bold text-neutral-900">{{ $t('clientTicket.adminTab') }}</h2>
|
<h2 class="text-base font-bold text-neutral-900">{{ $t('clientTicket.adminTab') }}</h2>
|
||||||
<p class="mt-0.5 text-xs text-neutral-400">{{ projectName }}</p>
|
<p class="mt-0.5 text-xs text-neutral-400">{{ projectName }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<MalioButtonIcon
|
||||||
type="button"
|
icon="mdi:close"
|
||||||
class="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600"
|
aria-label="Fermer"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="20"
|
||||||
@click="close"
|
@click="close"
|
||||||
>
|
/>
|
||||||
<Icon name="mdi:close" size="20" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
@@ -97,13 +99,13 @@
|
|||||||
<p class="mt-0.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
|
<p class="mt-0.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<button
|
<MalioButtonIcon
|
||||||
class="rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-primary-500"
|
icon="mdi:swap-horizontal"
|
||||||
:title="$t('clientTicket.changeStatus')"
|
:aria-label="$t('clientTicket.changeStatus')"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="16"
|
||||||
@click.stop="openStatusChange(ticket)"
|
@click.stop="openStatusChange(ticket)"
|
||||||
>
|
/>
|
||||||
<Icon name="mdi:swap-horizontal" size="16" />
|
|
||||||
</button>
|
|
||||||
<Icon
|
<Icon
|
||||||
:name="expandedId === ticket.id ? 'mdi:chevron-up' : 'mdi:chevron-down'"
|
:name="expandedId === ticket.id ? 'mdi:chevron-up' : 'mdi:chevron-down'"
|
||||||
size="18"
|
size="18"
|
||||||
@@ -114,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"
|
||||||
@@ -179,19 +186,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
<button
|
<MalioButton
|
||||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
variant="tertiary"
|
||||||
|
:label="$t('common.cancel')"
|
||||||
|
button-class="w-auto px-4"
|
||||||
@click="statusModalOpen = false"
|
@click="statusModalOpen = false"
|
||||||
>
|
/>
|
||||||
{{ $t('common.cancel') }}
|
<MalioButton
|
||||||
</button>
|
label="Confirmer"
|
||||||
<button
|
button-class="w-auto px-6"
|
||||||
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
:disabled="isUpdatingStatus"
|
:disabled="isUpdatingStatus"
|
||||||
@click="confirmStatusChange"
|
@click="confirmStatusChange"
|
||||||
>
|
/>
|
||||||
Confirmer
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('clients.editClient') : $t('clients.addClient')">
|
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('clients.editClient') : $t('clients.addClient')">
|
||||||
<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.name"
|
v-model="form.name"
|
||||||
@@ -35,16 +35,15 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end">
|
<div class="mt-6 flex justify-end">
|
||||||
<button
|
<MalioButton
|
||||||
type="submit"
|
label="Enregistrer"
|
||||||
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
button-class="w-auto px-6"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
>
|
@click="handleSubmit"
|
||||||
Enregistrer
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</MalioDrawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
251
frontend/components/mail/MailCreateTaskModal.vue
Normal file
251
frontend/components/mail/MailCreateTaskModal.vue
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
<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 { TaskPriority } from '~/services/dto/task-priority'
|
||||||
|
import { useMailService } from '~/services/mail'
|
||||||
|
import { useProjectService } from '~/services/projects'
|
||||||
|
import { useTaskGroupService } from '~/services/task-groups'
|
||||||
|
import { useTaskPriorityService } from '~/services/task-priorities'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** v-model: true = modal ouvert */
|
||||||
|
modelValue: boolean
|
||||||
|
/** ID BDD du message source */
|
||||||
|
messageId: number
|
||||||
|
/** Détail du message (pour afficher sujet/expéditeur en lecture seule) */
|
||||||
|
messageDetail: MailMessageDetailDto | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
/** Émis après création réussie — payload = tâche créée */
|
||||||
|
created: [task: Task]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const mailService = useMailService()
|
||||||
|
const projectService = useProjectService()
|
||||||
|
const taskGroupService = useTaskGroupService()
|
||||||
|
const priorityService = useTaskPriorityService()
|
||||||
|
|
||||||
|
// ─── État formulaire ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const projectId = ref<number | null>(null)
|
||||||
|
const taskGroupId = ref<number | null>(null)
|
||||||
|
const priorityId = ref<number | null>(null)
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
const touchedProject = ref(false)
|
||||||
|
|
||||||
|
// ─── Données de référence ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const projects = ref<Project[]>([])
|
||||||
|
const groups = ref<TaskGroup[]>([])
|
||||||
|
const priorities = ref<TaskPriority[]>([])
|
||||||
|
const 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 priorityOptions = computed(() =>
|
||||||
|
priorities.value.map(p => ({ label: p.label, value: p.id })),
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── Chargement initial ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const [projs, prios] = await Promise.all([
|
||||||
|
projectService.getAll({ archived: false }),
|
||||||
|
priorityService.getAll(),
|
||||||
|
])
|
||||||
|
projects.value = projs
|
||||||
|
priorities.value = prios
|
||||||
|
})
|
||||||
|
|
||||||
|
// Recharger les groupes quand le projet change
|
||||||
|
watch(projectId, async (pid) => {
|
||||||
|
taskGroupId.value = null
|
||||||
|
groups.value = []
|
||||||
|
if (!pid) return
|
||||||
|
loadingGroups.value = true
|
||||||
|
try {
|
||||||
|
groups.value = await taskGroupService.getByProject(pid)
|
||||||
|
} finally {
|
||||||
|
loadingGroups.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reset formulaire à l'ouverture
|
||||||
|
watch(() => props.modelValue, (open) => {
|
||||||
|
if (open) {
|
||||||
|
projectId.value = null
|
||||||
|
taskGroupId.value = null
|
||||||
|
priorityId.value = null
|
||||||
|
touchedProject.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── Actions ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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,
|
||||||
|
priority: priorityId.value ? `/api/task_priorities/${priorityId.value}` : undefined,
|
||||||
|
})
|
||||||
|
emit('created', task)
|
||||||
|
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">
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||||
|
@click="close"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Modal -->
|
||||||
|
<div
|
||||||
|
class="relative z-10 w-full max-w-lg rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 overflow-hidden"
|
||||||
|
style="max-height: min(90vh, 640px)"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between border-b border-neutral-100 bg-neutral-50/80 px-6 py-4">
|
||||||
|
<h2 class="text-base font-bold text-neutral-900">
|
||||||
|
{{ t('mail.createTaskModal.title') }}
|
||||||
|
</h2>
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:close"
|
||||||
|
aria-label="Fermer"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="20"
|
||||||
|
@click="close"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Corps -->
|
||||||
|
<div class="overflow-y-auto px-6 py-5 space-y-5">
|
||||||
|
<!-- Info mail source (lecture seule) -->
|
||||||
|
<div
|
||||||
|
v-if="messageDetail"
|
||||||
|
class="rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-3 text-sm"
|
||||||
|
>
|
||||||
|
<p class="font-medium text-neutral-800 truncate">
|
||||||
|
{{ messageDetail.header.subject ?? t('mail.noSubject') }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-0.5 text-xs text-neutral-500 truncate">
|
||||||
|
{{ messageDetail.header.fromName ?? messageDetail.header.fromEmail }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-xs text-neutral-400 italic">
|
||||||
|
{{ t('mail.createTaskModal.titleHint') }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-neutral-400 italic">
|
||||||
|
{{ t('mail.createTaskModal.descriptionHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sélection projet -->
|
||||||
|
<div>
|
||||||
|
<MalioSelect
|
||||||
|
v-model="projectId"
|
||||||
|
:options="projectOptions"
|
||||||
|
:label="t('mail.createTaskModal.projectLabel')"
|
||||||
|
:empty-option-label="t('mail.createTaskModal.projectPlaceholder')"
|
||||||
|
min-width="w-full"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="touchedProject && !projectId"
|
||||||
|
class="mt-1 text-xs text-red-500"
|
||||||
|
>
|
||||||
|
{{ t('mail.createTaskModal.projectLabel').replace(' *', '') }} requis
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sélection groupe (optionnel, chargé après projet) -->
|
||||||
|
<div v-if="projectId">
|
||||||
|
<MalioSelect
|
||||||
|
v-model="taskGroupId"
|
||||||
|
:options="groupOptions"
|
||||||
|
:label="t('mail.createTaskModal.groupLabel')"
|
||||||
|
:empty-option-label="t('mail.createTaskModal.groupPlaceholder')"
|
||||||
|
min-width="w-full"
|
||||||
|
:disabled="loadingGroups"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sélection priorité (optionnelle) — MalioSelect car les values sont number | null -->
|
||||||
|
<div>
|
||||||
|
<MalioSelect
|
||||||
|
v-model="priorityId"
|
||||||
|
:options="priorityOptions"
|
||||||
|
:label="t('mail.createTaskModal.priorityLabel')"
|
||||||
|
:empty-option-label="t('mail.createTaskModal.priorityPlaceholder')"
|
||||||
|
min-width="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="flex justify-end gap-3 border-t border-neutral-100 px-6 py-4">
|
||||||
|
<MalioButton
|
||||||
|
variant="tertiary"
|
||||||
|
label="Annuler"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
@click="close"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
:label="t('mail.createTaskModal.submit')"
|
||||||
|
button-class="w-auto px-6"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@click="handleSubmit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
123
frontend/components/mail/MailFolderTree.vue
Normal file
123
frontend/components/mail/MailFolderTree.vue
Normal file
@@ -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>
|
||||||
266
frontend/components/mail/MailLinkTaskModal.vue
Normal file
266
frontend/components/mail/MailLinkTaskModal.vue
Normal file
@@ -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>
|
||||||
151
frontend/components/mail/MailMessageList.vue
Normal file
151
frontend/components/mail/MailMessageList.vue
Normal file
@@ -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>
|
||||||
183
frontend/components/mail/MailMessageViewer.vue
Normal file
183
frontend/components/mail/MailMessageViewer.vue
Normal file
@@ -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>
|
||||||
228
frontend/components/mail/MailPickerModal.vue
Normal file
228
frontend/components/mail/MailPickerModal.vue
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { MailMessageHeaderDto } from '~/services/dto/mail'
|
||||||
|
import { useMailService } from '~/services/mail'
|
||||||
|
import { useMailStore } from '~/stores/mail'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
/** ID de la tâche cible (destinataire du lien) */
|
||||||
|
taskId: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
/** Émis après liaison réussie — payload = id du message lié */
|
||||||
|
linked: [messageId: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const mailService = useMailService()
|
||||||
|
const mailStore = useMailStore()
|
||||||
|
|
||||||
|
// ─── État ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const allMessages = ref<MailMessageHeaderDto[]>([])
|
||||||
|
const selectedMessage = ref<MailMessageHeaderDto | null>(null)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
|
||||||
|
// ─── Filtrage local (pas d'appel API par frappe — les messages sont déjà chargés) ──
|
||||||
|
|
||||||
|
const filteredMessages = computed(() => {
|
||||||
|
const q = searchQuery.value.toLowerCase().trim()
|
||||||
|
if (!q) return allMessages.value
|
||||||
|
return allMessages.value.filter(
|
||||||
|
(m) =>
|
||||||
|
(m.subject ?? '').toLowerCase().includes(q)
|
||||||
|
|| (m.fromName ?? '').toLowerCase().includes(q)
|
||||||
|
|| (m.fromEmail ?? '').toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── Chargement à l'ouverture ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
watch(() => props.modelValue, async (open) => {
|
||||||
|
if (!open) return
|
||||||
|
searchQuery.value = ''
|
||||||
|
selectedMessage.value = null
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
// Utiliser le dossier actuellement sélectionné dans le store si disponible,
|
||||||
|
// sinon fallback sur INBOX.
|
||||||
|
const folderPath = mailStore.selectedFolderPath ?? 'INBOX'
|
||||||
|
const page = await mailService.listMessages(folderPath, undefined, 50)
|
||||||
|
allMessages.value = page.items
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── Actions ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function close(): void {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectMessage(msg: MailMessageHeaderDto): void {
|
||||||
|
selectedMessage.value = msg
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(): Promise<void> {
|
||||||
|
if (!selectedMessage.value) return
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await mailService.linkTask(selectedMessage.value.id, props.taskId)
|
||||||
|
emit('linked', selectedMessage.value.id)
|
||||||
|
close()
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Formatage ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function formatDate(iso: string | null): string {
|
||||||
|
if (!iso) return ''
|
||||||
|
return new Date(iso).toLocaleDateString('fr', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport v-if="modelValue" to="body">
|
||||||
|
<Transition name="mail-modal" appear>
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||||
|
@click="close"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="relative z-10 w-full max-w-lg rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 overflow-hidden"
|
||||||
|
style="max-height: min(90vh, 640px)"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between border-b border-neutral-100 bg-neutral-50/80 px-6 py-4">
|
||||||
|
<h2 class="text-base font-bold text-neutral-900">
|
||||||
|
{{ t('mail.pickerModal.title') }}
|
||||||
|
</h2>
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:close"
|
||||||
|
aria-label="Fermer"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="20"
|
||||||
|
@click="close"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Corps -->
|
||||||
|
<div class="overflow-y-auto px-6 py-5 space-y-4">
|
||||||
|
<!-- Recherche locale -->
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
:placeholder="t('mail.pickerModal.searchPlaceholder')"
|
||||||
|
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Résultats -->
|
||||||
|
<div class="max-h-80 overflow-y-auto rounded-md border border-neutral-200 divide-y divide-neutral-100">
|
||||||
|
<!-- Chargement -->
|
||||||
|
<div
|
||||||
|
v-if="isLoading"
|
||||||
|
class="flex items-center justify-center py-8 text-sm text-neutral-400"
|
||||||
|
>
|
||||||
|
<Icon name="material-symbols:progress-activity" size="18" class="mr-2 animate-spin" />
|
||||||
|
{{ t('mail.pickerModal.loading') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vide -->
|
||||||
|
<div
|
||||||
|
v-else-if="filteredMessages.length === 0"
|
||||||
|
class="py-8 text-center text-sm text-neutral-400 italic"
|
||||||
|
>
|
||||||
|
{{ t('mail.pickerModal.empty') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Liste -->
|
||||||
|
<button
|
||||||
|
v-for="msg in filteredMessages"
|
||||||
|
:key="msg.id"
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-start gap-3 px-4 py-3 text-left text-sm transition-colors hover:bg-neutral-50"
|
||||||
|
:class="selectedMessage?.id === msg.id
|
||||||
|
? 'bg-primary-50 border-l-2 border-primary-500'
|
||||||
|
: 'border-l-2 border-transparent'"
|
||||||
|
@click="selectMessage(msg)"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
name="material-symbols:mail-outline"
|
||||||
|
size="16"
|
||||||
|
class="mt-0.5 flex-shrink-0 text-neutral-400"
|
||||||
|
/>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="truncate font-medium text-neutral-800">
|
||||||
|
{{ msg.subject ?? t('mail.noSubject') }}
|
||||||
|
</p>
|
||||||
|
<p class="flex items-center gap-2 text-xs text-neutral-500">
|
||||||
|
<span class="truncate">{{ msg.fromName ?? msg.fromEmail }}</span>
|
||||||
|
<span class="flex-shrink-0">·</span>
|
||||||
|
<span class="flex-shrink-0">{{ formatDate(msg.sentAt ?? msg.receivedAt) }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Icon
|
||||||
|
v-if="selectedMessage?.id === msg.id"
|
||||||
|
name="material-symbols:check-circle"
|
||||||
|
size="16"
|
||||||
|
class="flex-shrink-0 text-primary-500"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="flex justify-end gap-3 border-t border-neutral-100 px-6 py-4">
|
||||||
|
<MalioButton
|
||||||
|
variant="tertiary"
|
||||||
|
label="Annuler"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
@click="close"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
:label="t('mail.pickerModal.submit')"
|
||||||
|
button-class="w-auto px-6"
|
||||||
|
:disabled="!selectedMessage || isSubmitting"
|
||||||
|
@click="handleSubmit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mail-modal-enter-active,
|
||||||
|
.mail-modal-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-modal-enter-active > div:last-child,
|
||||||
|
.mail-modal-leave-active > div:last-child {
|
||||||
|
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-modal-enter-from,
|
||||||
|
.mail-modal-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-modal-enter-from > div:last-child {
|
||||||
|
transform: scale(0.95) translateY(8px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
24
frontend/components/mail/MailRefreshButton.vue
Normal file
24
frontend/components/mail/MailRefreshButton.vue
Normal file
@@ -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>
|
||||||
@@ -1,18 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="bellRef" class="relative">
|
<div ref="bellRef" class="relative">
|
||||||
<button
|
<div class="relative">
|
||||||
type="button"
|
<MalioButtonIcon
|
||||||
class="relative rounded-md p-2 text-white hover:bg-primary-600 transition-colors"
|
icon="mdi:bell-outline"
|
||||||
@click="toggleDropdown"
|
aria-label="Notifications"
|
||||||
>
|
variant="ghost"
|
||||||
<Icon name="mdi:bell-outline" size="24" />
|
icon-size="24"
|
||||||
|
button-class="text-white hover:bg-primary-600"
|
||||||
|
@click="toggleDropdown"
|
||||||
|
/>
|
||||||
<span
|
<span
|
||||||
v-if="unreadCount > 0"
|
v-if="unreadCount > 0"
|
||||||
class="absolute -right-0.5 -top-0.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
|
class="absolute -right-0.5 -top-0.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white pointer-events-none"
|
||||||
>
|
>
|
||||||
{{ unreadCount > 99 ? '99+' : unreadCount }}
|
{{ unreadCount > 99 ? '99+' : unreadCount }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
<Transition name="dropdown">
|
<Transition name="dropdown">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppDrawer 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="form.code"
|
||||||
@@ -54,41 +54,69 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end">
|
<div class="mt-6 flex justify-end">
|
||||||
<button
|
<MalioButton
|
||||||
type="submit"
|
label="Enregistrer"
|
||||||
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
button-class="w-auto px-6"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
>
|
@click="handleSubmit"
|
||||||
Enregistrer
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div v-if="isEditing && project" class="mt-6 border-t border-neutral-200 pt-4 flex items-center justify-between">
|
<div v-if="isEditing && project" class="mt-6 border-t border-neutral-200 pt-4 flex items-center justify-between">
|
||||||
<button
|
<MalioButton
|
||||||
class="flex items-center gap-2 text-sm text-neutral-500 hover:text-amber-600"
|
variant="tertiary"
|
||||||
|
:icon-name="project.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-arrow-down-outline'"
|
||||||
|
icon-position="left"
|
||||||
|
button-class="w-auto px-4"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
@click="handleArchiveToggle"
|
@click="handleArchiveToggle"
|
||||||
>
|
>
|
||||||
<Icon :name="project.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-arrow-down-outline'" size="18" />
|
|
||||||
{{ project.archived ? 'Désarchiver' : 'Archiver' }}
|
{{ project.archived ? 'Désarchiver' : 'Archiver' }}
|
||||||
</button>
|
</MalioButton>
|
||||||
<button
|
<MalioButton
|
||||||
v-if="project.taskCount === 0"
|
v-if="project.taskCount === 0"
|
||||||
class="flex items-center gap-2 text-sm text-neutral-500 hover:text-red-600"
|
variant="danger"
|
||||||
|
icon-name="mdi:delete-outline"
|
||||||
|
icon-position="left"
|
||||||
|
button-class="w-auto px-4"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
@click="confirmDeleteOpen = true"
|
@click="confirmDeleteOpen = true"
|
||||||
>
|
>
|
||||||
<Icon name="mdi:delete-outline" size="18" />
|
|
||||||
{{ $t('common.delete') }}
|
{{ $t('common.delete') }}
|
||||||
</button>
|
</MalioButton>
|
||||||
|
</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>
|
</div>
|
||||||
|
|
||||||
<ConfirmDeleteProjectModal
|
<ConfirmDeleteProjectModal
|
||||||
v-model="confirmDeleteOpen"
|
v-model="confirmDeleteOpen"
|
||||||
@confirm="handleDelete"
|
@confirm="handleDelete"
|
||||||
/>
|
/>
|
||||||
</AppDrawer>
|
|
||||||
|
<ProjectWorkflowSwitchModal
|
||||||
|
v-if="props.project"
|
||||||
|
v-model="switchModalOpen"
|
||||||
|
:project="props.project"
|
||||||
|
@switched="onWorkflowSwitched"
|
||||||
|
/>
|
||||||
|
</MalioDrawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -119,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[]>([])
|
||||||
|
|||||||
@@ -3,20 +3,20 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="text-lg font-bold text-neutral-900">Groupes</h2>
|
<h2 class="text-lg font-bold text-neutral-900">Groupes</h2>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<MalioButton
|
||||||
type="button"
|
variant="tertiary"
|
||||||
class="text-sm font-medium text-neutral-500 hover:text-neutral-700"
|
button-class="w-auto px-3"
|
||||||
|
:label="showArchived ? $t('archive.hideArchived') : $t('archive.showArchived')"
|
||||||
@click="showArchived = !showArchived"
|
@click="showArchived = !showArchived"
|
||||||
>
|
/>
|
||||||
{{ showArchived ? $t('archive.hideArchived') : $t('archive.showArchived') }}
|
<MalioButton
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="!showArchived"
|
v-if="!showArchived"
|
||||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
icon-name="mdi:plus"
|
||||||
|
icon-position="left"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
label="Ajouter un groupe"
|
||||||
@click="openCreate"
|
@click="openCreate"
|
||||||
>
|
/>
|
||||||
+ Ajouter un groupe
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -36,25 +36,23 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #cell-description="{ item }">
|
<template #cell-description="{ item }">
|
||||||
{{ item.description ?? '—' }}
|
{{ stripRichText(item.description) || '—' }}
|
||||||
</template>
|
</template>
|
||||||
<template #actions="{ item }">
|
<template #actions="{ item }">
|
||||||
<button
|
<MalioButton
|
||||||
v-if="!showArchived && canArchiveGroup(item)"
|
v-if="!showArchived && canArchiveGroup(item)"
|
||||||
type="button"
|
variant="secondary"
|
||||||
class="rounded-md bg-neutral-500 px-3 py-1 text-xs font-semibold text-white hover:bg-neutral-600"
|
:label="$t('archive.archiveButton')"
|
||||||
|
button-class="w-auto px-3"
|
||||||
@click.stop="handleArchive(item)"
|
@click.stop="handleArchive(item)"
|
||||||
>
|
/>
|
||||||
{{ $t('archive.archiveButton') }}
|
<MalioButton
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="showArchived"
|
v-if="showArchived"
|
||||||
type="button"
|
variant="secondary"
|
||||||
class="rounded-md bg-neutral-500 px-3 py-1 text-xs font-semibold text-white hover:bg-neutral-600"
|
:label="$t('archive.unarchiveButton')"
|
||||||
|
button-class="w-auto px-3"
|
||||||
@click.stop="handleUnarchive(item)"
|
@click.stop="handleUnarchive(item)"
|
||||||
>
|
/>
|
||||||
{{ $t('archive.unarchiveButton') }}
|
|
||||||
</button>
|
|
||||||
</template>
|
</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
|
|
||||||
@@ -73,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
|
||||||
|
|||||||
209
frontend/components/project/ProjectWorkflowSwitchModal.vue
Normal file
209
frontend/components/project/ProjectWorkflowSwitchModal.vue
Normal file
@@ -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>
|
||||||
@@ -57,13 +57,14 @@
|
|||||||
>
|
>
|
||||||
{{ link.title }}
|
{{ link.title }}
|
||||||
</a>
|
</a>
|
||||||
<button
|
<MalioButtonIcon
|
||||||
type="button"
|
icon="mdi:close"
|
||||||
class="ml-auto shrink-0 text-neutral-300 hover:text-red-500"
|
aria-label="Supprimer le lien"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="16"
|
||||||
|
button-class="ml-auto shrink-0 text-neutral-300 hover:text-red-500"
|
||||||
@click="handleRemove(link.id)"
|
@click="handleRemove(link.id)"
|
||||||
>
|
/>
|
||||||
<Icon name="mdi:close" size="16" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -72,25 +80,28 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Delete -->
|
<!-- Delete -->
|
||||||
<button
|
<MalioButtonIcon
|
||||||
class="flex h-9 w-9 shrink-0 items-center justify-center self-end rounded-md text-neutral-500 transition-colors hover:bg-red-50 hover:text-red-500"
|
icon="mdi:delete-outline"
|
||||||
title="Supprimer"
|
aria-label="Supprimer"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="22"
|
||||||
|
button-class="self-end text-neutral-500 hover:bg-red-50 hover:text-red-500"
|
||||||
@click="emit('bulk-delete')"
|
@click="emit('bulk-delete')"
|
||||||
>
|
/>
|
||||||
<Icon name="mdi:delete-outline" size="22" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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
|
||||||
@@ -100,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
|
||||||
@@ -109,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>
|
||||||
|
|||||||
@@ -29,16 +29,24 @@
|
|||||||
</div>
|
</div>
|
||||||
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<MalioButtonIcon
|
||||||
class="shrink-0 transition-colors"
|
:icon="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'"
|
||||||
:class="isTimerOnTask ? 'text-[#F18619] hover:text-[#d97314]' : 'text-neutral-400 hover:text-primary-500'"
|
:aria-label="isTimerOnTask ? 'Arrêter le timer' : 'Démarrer le timer'"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="20"
|
||||||
|
:button-class="isTimerOnTask ? 'shrink-0 text-[#F18619] hover:text-[#d97314]' : 'shrink-0 text-neutral-400 hover:text-primary-500'"
|
||||||
@click.stop="isTimerOnTask ? timerStore.stop() : onPlay()"
|
@click.stop="isTimerOnTask ? timerStore.stop() : onPlay()"
|
||||||
>
|
/>
|
||||||
<Icon :name="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'" size="20" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-2 flex items-center gap-1.5">
|
<div class="mt-2 flex 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"
|
||||||
@@ -77,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
|
||||||
@@ -99,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<{
|
||||||
|
|||||||
@@ -32,14 +32,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delete button -->
|
<!-- Delete button -->
|
||||||
<button
|
<MalioButtonIcon
|
||||||
v-if="isAdmin"
|
v-if="isAdmin"
|
||||||
type="button"
|
icon="heroicons:x-mark"
|
||||||
class="absolute right-1 top-1 hidden rounded p-0.5 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500 group-hover:block"
|
aria-label="Supprimer"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="16"
|
||||||
|
button-class="absolute right-1 top-1 hidden text-neutral-400 hover:bg-red-50 hover:text-red-500 group-hover:block"
|
||||||
@click.stop="$emit('delete', doc)"
|
@click.stop="$emit('delete', doc)"
|
||||||
>
|
/>
|
||||||
<Icon name="heroicons:x-mark" class="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,28 +12,34 @@
|
|||||||
ref="overlayRef"
|
ref="overlayRef"
|
||||||
>
|
>
|
||||||
<!-- Close button -->
|
<!-- Close button -->
|
||||||
<button
|
<MalioButtonIcon
|
||||||
class="absolute right-4 top-4 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
|
icon="heroicons:x-mark"
|
||||||
|
aria-label="Fermer"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="24"
|
||||||
|
button-class="absolute right-4 top-4 rounded-full bg-black/50 text-white hover:bg-black/70"
|
||||||
@click="$emit('close')"
|
@click="$emit('close')"
|
||||||
>
|
/>
|
||||||
<Icon name="heroicons:x-mark" class="h-6 w-6" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Navigation arrows -->
|
<!-- Navigation arrows -->
|
||||||
<button
|
<MalioButtonIcon
|
||||||
v-if="hasPrev"
|
v-if="hasPrev"
|
||||||
class="absolute left-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
|
icon="heroicons:chevron-left"
|
||||||
|
aria-label="Précédent"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="24"
|
||||||
|
button-class="absolute left-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 text-white hover:bg-black/70"
|
||||||
@click="$emit('prev')"
|
@click="$emit('prev')"
|
||||||
>
|
/>
|
||||||
<Icon name="heroicons:chevron-left" class="h-6 w-6" />
|
<MalioButtonIcon
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="hasNext"
|
v-if="hasNext"
|
||||||
class="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
|
icon="heroicons:chevron-right"
|
||||||
|
aria-label="Suivant"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="24"
|
||||||
|
button-class="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 text-white hover:bg-black/70"
|
||||||
@click="$emit('next')"
|
@click="$emit('next')"
|
||||||
>
|
/>
|
||||||
<Icon name="heroicons:chevron-right" class="h-6 w-6" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="flex max-h-[90vh] max-w-[90vw] flex-col items-center">
|
<div class="flex max-h-[90vh] max-w-[90vw] flex-col items-center">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskEfforts.editEffort') : $t('taskEfforts.addEffort')">
|
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskEfforts.editEffort') : $t('taskEfforts.addEffort')">
|
||||||
<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.label"
|
v-model="form.label"
|
||||||
@@ -10,16 +10,15 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end">
|
<div class="mt-6 flex justify-end">
|
||||||
<button
|
<MalioButton
|
||||||
type="submit"
|
label="Enregistrer"
|
||||||
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
button-class="w-auto px-6"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
>
|
@click="handleSubmit"
|
||||||
Enregistrer
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</MalioDrawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
@@ -38,24 +38,22 @@
|
|||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<button
|
<MalioButtonIcon
|
||||||
v-if="activeTab === 'branches'"
|
v-if="activeTab === 'branches'"
|
||||||
type="button"
|
icon="mdi:content-copy"
|
||||||
class="rounded-md px-2.5 py-1.5 text-xs font-medium text-neutral-500 transition-colors hover:bg-neutral-200/60 hover:text-neutral-700"
|
:aria-label="$t('gitea.branch.copy')"
|
||||||
:title="$t('gitea.branch.copy')"
|
variant="ghost"
|
||||||
|
icon-size="14"
|
||||||
@click="handleCopy"
|
@click="handleCopy"
|
||||||
>
|
/>
|
||||||
<Icon name="mdi:content-copy" size="14" />
|
<MalioButton
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="activeTab === 'branches'"
|
v-if="activeTab === 'branches'"
|
||||||
type="button"
|
icon-name="mdi:plus"
|
||||||
class="rounded-md bg-primary-500 px-2.5 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-secondary-500"
|
icon-position="left"
|
||||||
|
button-class="w-auto px-2.5 py-1.5 text-xs"
|
||||||
|
:label="$t('gitea.branch.create')"
|
||||||
@click="showCreateForm = !showCreateForm"
|
@click="showCreateForm = !showCreateForm"
|
||||||
>
|
/>
|
||||||
<Icon name="mdi:plus" size="14" class="mr-0.5 inline-block align-[-2px]" />
|
|
||||||
{{ $t('gitea.branch.create') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -79,14 +77,12 @@
|
|||||||
:label="$t('gitea.branch.baseBranch')"
|
:label="$t('gitea.branch.baseBranch')"
|
||||||
input-class="w-full"
|
input-class="w-full"
|
||||||
/>
|
/>
|
||||||
<button
|
<MalioButton
|
||||||
type="button"
|
:label="isCreating ? '...' : $t('gitea.branch.create')"
|
||||||
class="mb-[2px] rounded-md bg-primary-500 px-4 py-2 text-xs font-semibold text-white transition-colors hover:bg-secondary-500 disabled:opacity-50"
|
button-class="w-auto px-4 mb-[2px] text-xs"
|
||||||
:disabled="isCreating"
|
:disabled="isCreating"
|
||||||
@click="handleCreate"
|
@click="handleCreate"
|
||||||
>
|
/>
|
||||||
{{ isCreating ? '...' : $t('gitea.branch.create') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<code class="mt-2 block rounded bg-neutral-50 px-2 py-1 text-[11px] text-neutral-500">
|
<code class="mt-2 block rounded bg-neutral-50 px-2 py-1 text-[11px] text-neutral-500">
|
||||||
{{ branchPreview }}
|
{{ branchPreview }}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskGroups.editGroup') : $t('taskGroups.addGroup')">
|
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskGroups.editGroup') : $t('taskGroups.addGroup')">
|
||||||
<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.title"
|
v-model="form.title"
|
||||||
@@ -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" />
|
||||||
@@ -25,34 +25,31 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex items-center" :class="isEditing ? 'justify-between' : 'justify-end'">
|
<div class="mt-6 flex items-center" :class="isEditing ? 'justify-between' : 'justify-end'">
|
||||||
<button
|
<MalioButton
|
||||||
v-if="canArchive"
|
v-if="canArchive"
|
||||||
type="button"
|
variant="secondary"
|
||||||
class="rounded-md bg-neutral-500 px-4 py-2 text-sm font-semibold text-white hover:bg-neutral-600 disabled:cursor-not-allowed disabled:opacity-50"
|
:label="$t('archive.archiveButton')"
|
||||||
|
button-class="w-auto px-4"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
@click="handleArchive"
|
@click="handleArchive"
|
||||||
>
|
/>
|
||||||
{{ $t('archive.archiveButton') }}
|
<MalioButton
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="canUnarchive"
|
v-if="canUnarchive"
|
||||||
type="button"
|
variant="secondary"
|
||||||
class="rounded-md bg-neutral-500 px-4 py-2 text-sm font-semibold text-white hover:bg-neutral-600 disabled:cursor-not-allowed disabled:opacity-50"
|
:label="$t('archive.unarchiveButton')"
|
||||||
|
button-class="w-auto px-4"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
@click="handleUnarchive"
|
@click="handleUnarchive"
|
||||||
>
|
/>
|
||||||
{{ $t('archive.unarchiveButton') }}
|
<MalioButton
|
||||||
</button>
|
label="Enregistrer"
|
||||||
<button
|
button-class="w-auto px-6"
|
||||||
type="submit"
|
|
||||||
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
>
|
@click="handleSubmit"
|
||||||
Enregistrer
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</MalioDrawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
@@ -78,24 +78,33 @@
|
|||||||
|
|
||||||
<!-- Right: timer top, avatar bottom -->
|
<!-- Right: timer top, avatar bottom -->
|
||||||
<div class="flex shrink-0 flex-col items-end justify-between self-stretch gap-1">
|
<div class="flex shrink-0 flex-col items-end justify-between self-stretch gap-1">
|
||||||
<button
|
<MalioButtonIcon
|
||||||
class="shrink-0 transition-colors"
|
:icon="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'"
|
||||||
:class="isTimerOnTask ? 'text-[#F18619] hover:text-[#d97314]' : 'text-neutral-400 hover:text-primary-500'"
|
:aria-label="isTimerOnTask ? 'Arrêter le timer' : 'Démarrer le timer'"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="20"
|
||||||
|
: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)"
|
||||||
>
|
|
||||||
<Icon :name="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'" size="20" />
|
|
||||||
</button>
|
|
||||||
<UserAvatar
|
|
||||||
v-if="task.assignee"
|
|
||||||
:user="task.assignee"
|
|
||||||
size="xs"
|
|
||||||
/>
|
/>
|
||||||
<span
|
<div class="flex items-center gap-1">
|
||||||
v-else
|
<Icon
|
||||||
class="flex h-5 w-5 items-center justify-center rounded-full bg-neutral-200 text-neutral-400"
|
v-if="task.collaborators?.length"
|
||||||
>
|
name="mdi:account-group"
|
||||||
<Icon name="mdi:account-outline" size="14" />
|
class="h-4 w-4 text-neutral-400"
|
||||||
</span>
|
:title="task.collaborators.map(c => c.username).join(', ')"
|
||||||
|
/>
|
||||||
|
<UserAvatar
|
||||||
|
v-if="task.assignee"
|
||||||
|
:user="task.assignee"
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="flex h-5 w-5 items-center justify-center rounded-full bg-neutral-200 text-neutral-400"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:account-outline" size="14" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -27,13 +27,13 @@
|
|||||||
{{ isEditing ? $t('tasks.editTask') : $t('tasks.addTask') }}
|
{{ isEditing ? $t('tasks.editTask') : $t('tasks.addTask') }}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<MalioButtonIcon
|
||||||
type="button"
|
icon="mdi:close"
|
||||||
class="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-400 transition-colors hover:bg-neutral-200/60 hover:text-neutral-600"
|
aria-label="Fermer"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="20"
|
||||||
@click="close"
|
@click="close"
|
||||||
>
|
/>
|
||||||
<Icon name="mdi:close" size="20" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Client ticket link -->
|
<!-- Client ticket link -->
|
||||||
@@ -60,16 +60,16 @@
|
|||||||
<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,53 +433,118 @@
|
|||||||
</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>
|
||||||
|
|
||||||
|
<!-- Bouton lier un mail -->
|
||||||
|
<div class="pt-2">
|
||||||
|
<MalioButton
|
||||||
|
:label="$t('mail.taskTab.linkButton')"
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="material-symbols:link"
|
||||||
|
icon-position="left"
|
||||||
|
:icon-size="14"
|
||||||
|
button-class="w-auto"
|
||||||
|
@click="showMailPickerModal = true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal picker mail -->
|
||||||
|
<MailPickerModal
|
||||||
|
v-if="task"
|
||||||
|
v-model="showMailPickerModal"
|
||||||
|
:task-id="task.id"
|
||||||
|
@linked="handleMailLinked"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div
|
<div
|
||||||
class="mt-6 flex items-center border-t border-neutral-100 pt-5"
|
class="mt-6 flex items-center border-t border-neutral-100 pt-5"
|
||||||
:class="isEditing ? 'justify-between' : 'justify-end'"
|
:class="isEditing ? 'justify-between' : 'justify-end'"
|
||||||
>
|
>
|
||||||
<button
|
<MalioButton
|
||||||
v-if="isEditing"
|
v-if="isEditing"
|
||||||
type="button"
|
variant="danger"
|
||||||
class="rounded-lg bg-red-50 px-4 py-2 text-sm font-semibold text-red-600 transition-colors hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-50"
|
label="Supprimer"
|
||||||
|
button-class="w-auto px-4"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
@click="confirmDeleteOpen = true"
|
@click="confirmDeleteOpen = true"
|
||||||
>
|
/>
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button
|
<MalioButton
|
||||||
v-if="canArchive"
|
v-if="canArchive"
|
||||||
type="button"
|
variant="tertiary"
|
||||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-50"
|
:label="$t('archive.archiveButton')"
|
||||||
|
button-class="w-auto px-4"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
@click="handleArchive"
|
@click="handleArchive"
|
||||||
>
|
/>
|
||||||
{{ $t('archive.archiveButton') }}
|
<MalioButton
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="canUnarchive"
|
v-if="canUnarchive"
|
||||||
type="button"
|
variant="tertiary"
|
||||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-50"
|
:label="$t('archive.unarchiveButton')"
|
||||||
|
button-class="w-auto px-4"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
@click="handleUnarchive"
|
@click="handleUnarchive"
|
||||||
>
|
/>
|
||||||
{{ $t('archive.unarchiveButton') }}
|
<MalioButton
|
||||||
</button>
|
variant="tertiary"
|
||||||
<button
|
label="Annuler"
|
||||||
type="button"
|
button-class="w-auto px-4"
|
||||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
|
||||||
@click="close"
|
@click="close"
|
||||||
>
|
/>
|
||||||
Annuler
|
<MalioButton
|
||||||
</button>
|
label="Enregistrer"
|
||||||
<button
|
button-class="w-auto px-6"
|
||||||
type="submit"
|
|
||||||
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
>
|
@click="handleSubmit"
|
||||||
Enregistrer
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -497,6 +583,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
|
||||||
@@ -529,7 +617,14 @@ 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 showMailPickerModal = ref(false)
|
||||||
|
|
||||||
const giteaUrl = ref('')
|
const giteaUrl = ref('')
|
||||||
const { getSettings: getGiteaSettings } = useGiteaService()
|
const { getSettings: getGiteaSettings } = useGiteaService()
|
||||||
@@ -549,6 +644,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,
|
||||||
@@ -591,6 +687,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) {
|
||||||
@@ -629,6 +737,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') },
|
||||||
@@ -653,6 +767,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
|
||||||
@@ -699,6 +814,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
|
||||||
@@ -728,6 +844,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) {
|
||||||
@@ -786,6 +903,49 @@ 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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleMailLinked(): Promise<void> {
|
||||||
|
showMailPickerModal.value = false
|
||||||
|
await loadLinkedMails()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMailDate(iso: string | null): string {
|
||||||
|
if (!iso) return ''
|
||||||
|
return new Date(iso).toLocaleDateString('fr', {
|
||||||
|
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'
|
||||||
@@ -911,6 +1071,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,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskPriorities.editPriority') : $t('taskPriorities.addPriority')">
|
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskPriorities.editPriority') : $t('taskPriorities.addPriority')">
|
||||||
<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.label"
|
v-model="form.label"
|
||||||
@@ -13,16 +13,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end">
|
<div class="mt-6 flex justify-end">
|
||||||
<button
|
<MalioButton
|
||||||
type="submit"
|
label="Enregistrer"
|
||||||
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
button-class="w-auto px-6"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
>
|
@click="handleSubmit"
|
||||||
Enregistrer
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</MalioDrawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
<template>
|
|
||||||
<AppDrawer 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">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
:disabled="isSubmitting"
|
|
||||||
>
|
|
||||||
Enregistrer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</AppDrawer>
|
|
||||||
</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>
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('taskTags.editTag') : $t('taskTags.addTag')">
|
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskTags.editTag') : $t('taskTags.addTag')">
|
||||||
<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.label"
|
v-model="form.label"
|
||||||
@@ -13,16 +13,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end">
|
<div class="mt-6 flex justify-end">
|
||||||
<button
|
<MalioButton
|
||||||
type="submit"
|
label="Enregistrer"
|
||||||
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
button-class="w-auto px-6"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
>
|
@click="handleSubmit"
|
||||||
Enregistrer
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</MalioDrawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('timeEntries.editEntry') : $t('timeEntries.addEntry')">
|
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('timeEntries.editEntry') : $t('timeEntries.addEntry')">
|
||||||
<form class="space-y-4" @submit.prevent="onSubmit">
|
<form class="space-y-4" @submit.prevent="onSubmit">
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Titre</label>
|
<label class="mb-1 block text-sm font-semibold text-neutral-700">Titre</label>
|
||||||
@@ -11,14 +11,11 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<MalioInputRichText
|
||||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Description</label>
|
v-model="form.description"
|
||||||
<textarea
|
label="Description"
|
||||||
v-model="form.description"
|
min-height="120px"
|
||||||
rows="3"
|
/>
|
||||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</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>
|
||||||
@@ -97,33 +94,30 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center" :class="isEditing ? 'justify-between' : 'justify-end'">
|
<div class="flex items-center" :class="isEditing ? 'justify-between' : 'justify-end'">
|
||||||
<button
|
<MalioButton
|
||||||
v-if="isEditing"
|
v-if="isEditing"
|
||||||
type="button"
|
variant="danger"
|
||||||
class="rounded-md bg-red-500 px-4 py-2 text-sm font-semibold text-white hover:bg-red-600 transition"
|
label="Supprimer"
|
||||||
|
button-class="w-auto px-4"
|
||||||
@click="onDelete"
|
@click="onDelete"
|
||||||
>
|
/>
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button
|
<MalioButton
|
||||||
v-if="isEditing"
|
v-if="isEditing"
|
||||||
type="button"
|
variant="secondary"
|
||||||
class="rounded-md bg-blue-500 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-600 transition"
|
label="Dupliquer"
|
||||||
|
button-class="w-auto px-4"
|
||||||
@click="onDuplicate"
|
@click="onDuplicate"
|
||||||
>
|
/>
|
||||||
Dupliquer
|
<MalioButton
|
||||||
</button>
|
label="Enregistrer"
|
||||||
<button
|
button-class="w-auto px-4"
|
||||||
type="submit"
|
@click="onSubmit"
|
||||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 transition"
|
/>
|
||||||
>
|
|
||||||
Enregistrer
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</MalioDrawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -54,19 +54,21 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delete action -->
|
<!-- Delete action -->
|
||||||
<button
|
<MalioButtonIcon
|
||||||
class="shrink-0 rounded-md p-1.5 text-neutral-300 opacity-0 transition hover:bg-red-50 hover:text-red-500 group-hover:opacity-100"
|
icon="mdi:delete-outline"
|
||||||
:title="$t('common.delete')"
|
:aria-label="$t('common.delete')"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="18"
|
||||||
|
button-class="shrink-0 text-neutral-300 opacity-0 hover:bg-red-50 hover:text-red-500 group-hover:opacity-100"
|
||||||
@click.stop="emit('deleteEntry', entry)"
|
@click.stop="emit('deleteEntry', entry)"
|
||||||
>
|
/>
|
||||||
<Icon name="mdi:delete-outline" size="18" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<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[]
|
||||||
|
|||||||
205
frontend/components/time-tracking/TimeTrackingExportDrawer.vue
Normal file
205
frontend/components/time-tracking/TimeTrackingExportDrawer.vue
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
<template>
|
||||||
|
<MalioDrawer v-model="isOpen" :title="$t('timeEntries.exportTitle')" drawer-class="max-w-lg">
|
||||||
|
<div class="flex flex-col gap-6 p-4">
|
||||||
|
<!-- Period presets -->
|
||||||
|
<div>
|
||||||
|
<p class="mb-2 text-sm font-semibold text-neutral-700">Période</p>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<MalioRadioButton
|
||||||
|
v-model="periodMode"
|
||||||
|
name="exportPeriod"
|
||||||
|
value="currentMonth"
|
||||||
|
:label="$t('timeEntries.exportCurrentMonth')"
|
||||||
|
/>
|
||||||
|
<MalioRadioButton
|
||||||
|
v-model="periodMode"
|
||||||
|
name="exportPeriod"
|
||||||
|
value="lastMonth"
|
||||||
|
:label="$t('timeEntries.exportLastMonth')"
|
||||||
|
/>
|
||||||
|
<MalioRadioButton
|
||||||
|
v-model="periodMode"
|
||||||
|
name="exportPeriod"
|
||||||
|
value="custom"
|
||||||
|
:label="$t('timeEntries.exportCustomPeriod')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="periodMode === 'custom'" class="mt-3 flex items-center gap-3">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="mb-1 block text-xs text-neutral-500">{{ $t('timeEntries.exportFrom') }}</label>
|
||||||
|
<input
|
||||||
|
v-model="customFrom"
|
||||||
|
type="date"
|
||||||
|
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="mb-1 block text-xs text-neutral-500">{{ $t('timeEntries.exportTo') }}</label>
|
||||||
|
<input
|
||||||
|
v-model="customTo"
|
||||||
|
type="date"
|
||||||
|
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User filter (admin only) -->
|
||||||
|
<div v-if="isAdmin" class="[&>div]:!mt-0">
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
v-model="selectedUserIds"
|
||||||
|
:options="userOptions"
|
||||||
|
:label="$t('timeEntries.exportUsers')"
|
||||||
|
:display-tag="true"
|
||||||
|
:display-select-all="true"
|
||||||
|
min-width="!w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Client filter -->
|
||||||
|
<div class="[&>div]:!mt-0">
|
||||||
|
<MalioSelect
|
||||||
|
v-model="selectedClientId"
|
||||||
|
:options="clientOptions"
|
||||||
|
:label="$t('timeEntries.exportClient')"
|
||||||
|
:empty-option-label="$t('timeEntries.exportAllClients')"
|
||||||
|
min-width="!w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Project filter -->
|
||||||
|
<div class="[&>div]:!mt-0">
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
v-model="selectedProjectIds"
|
||||||
|
:options="filteredProjectOptions"
|
||||||
|
:label="$t('timeEntries.exportProjects')"
|
||||||
|
:display-tag="true"
|
||||||
|
:display-select-all="true"
|
||||||
|
min-width="!w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tag filter -->
|
||||||
|
<div class="[&>div]:!mt-0">
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
v-model="selectedTagIds"
|
||||||
|
:options="tagOptions"
|
||||||
|
:label="$t('timeEntries.exportTags')"
|
||||||
|
:display-tag="true"
|
||||||
|
:display-select-all="true"
|
||||||
|
min-width="!w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Export button -->
|
||||||
|
<MalioButton
|
||||||
|
:label="$t('timeEntries.export')"
|
||||||
|
icon-name="mdi:download"
|
||||||
|
icon-position="left"
|
||||||
|
button-class="w-full"
|
||||||
|
@click="doExport"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</MalioDrawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
|
import type { Project } from '~/services/dto/project'
|
||||||
|
import type { TaskTag } from '~/services/dto/task-tag'
|
||||||
|
import type { Client } from '~/services/dto/client'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
users: UserData[]
|
||||||
|
projects: Project[]
|
||||||
|
tags: TaskTag[]
|
||||||
|
clients: Client[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isOpen = defineModel<boolean>({ default: false })
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'export', params: {
|
||||||
|
after: string
|
||||||
|
before: string
|
||||||
|
users?: number[]
|
||||||
|
projects?: number[]
|
||||||
|
client?: number
|
||||||
|
tags?: number[]
|
||||||
|
}): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||||
|
|
||||||
|
const periodMode = ref<'currentMonth' | 'lastMonth' | 'custom'>('currentMonth')
|
||||||
|
const customFrom = ref('')
|
||||||
|
const customTo = ref('')
|
||||||
|
const selectedUserIds = ref<number[]>([])
|
||||||
|
const selectedClientId = ref<number | null>(null)
|
||||||
|
const selectedProjectIds = ref<number[]>([])
|
||||||
|
const selectedTagIds = ref<number[]>([])
|
||||||
|
|
||||||
|
const userOptions = computed(() =>
|
||||||
|
props.users.map(u => ({ label: u.username, value: u.id }))
|
||||||
|
)
|
||||||
|
|
||||||
|
const clientOptions = computed(() =>
|
||||||
|
props.clients.map(c => ({ label: c.name, value: c.id }))
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredProjectOptions = computed(() => {
|
||||||
|
let list = props.projects
|
||||||
|
if (selectedClientId.value) {
|
||||||
|
list = list.filter(p => p.client?.id === selectedClientId.value)
|
||||||
|
}
|
||||||
|
return list.map(p => ({ label: p.name, value: p.id }))
|
||||||
|
})
|
||||||
|
|
||||||
|
const tagOptions = computed(() =>
|
||||||
|
props.tags.map(t => ({ label: t.label, value: t.id }))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reset project selection when client changes
|
||||||
|
watch(selectedClientId, () => {
|
||||||
|
selectedProjectIds.value = []
|
||||||
|
})
|
||||||
|
|
||||||
|
function getDateRange(): { after: string; before: string } {
|
||||||
|
const now = new Date()
|
||||||
|
if (periodMode.value === 'currentMonth') {
|
||||||
|
const first = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||||
|
const last = new Date(now.getFullYear(), now.getMonth() + 1, 1)
|
||||||
|
return {
|
||||||
|
after: first.toISOString().slice(0, 10),
|
||||||
|
before: last.toISOString().slice(0, 10),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (periodMode.value === 'lastMonth') {
|
||||||
|
const first = new Date(now.getFullYear(), now.getMonth() - 1, 1)
|
||||||
|
const last = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||||
|
return {
|
||||||
|
after: first.toISOString().slice(0, 10),
|
||||||
|
before: last.toISOString().slice(0, 10),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
after: customFrom.value,
|
||||||
|
before: customTo.value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function doExport() {
|
||||||
|
const { after, before } = getDateRange()
|
||||||
|
if (!after || !before) return
|
||||||
|
|
||||||
|
emit('export', {
|
||||||
|
after,
|
||||||
|
before,
|
||||||
|
users: selectedUserIds.value.length ? selectedUserIds.value : undefined,
|
||||||
|
projects: selectedProjectIds.value.length ? selectedProjectIds.value : undefined,
|
||||||
|
client: selectedClientId.value ?? undefined,
|
||||||
|
tags: selectedTagIds.value.length ? selectedTagIds.value : undefined,
|
||||||
|
})
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -13,13 +13,13 @@
|
|||||||
>
|
>
|
||||||
<div class="flex items-center justify-between border-b border-neutral-200 px-6 py-4">
|
<div class="flex items-center justify-between border-b border-neutral-200 px-6 py-4">
|
||||||
<h2 class="text-lg font-bold text-neutral-900">{{ title }}</h2>
|
<h2 class="text-lg font-bold text-neutral-900">{{ title }}</h2>
|
||||||
<button
|
<MalioButtonIcon
|
||||||
type="button"
|
icon="mdi:close"
|
||||||
class="rounded p-1 text-neutral-400 hover:text-neutral-600"
|
aria-label="Fermer"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="24"
|
||||||
@click="close"
|
@click="close"
|
||||||
>
|
/>
|
||||||
<Icon name="mdi:close" size="24" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 overflow-y-auto px-6 py-4">
|
<div class="flex-1 overflow-y-auto px-6 py-4">
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@@ -1,32 +1,34 @@
|
|||||||
<template>
|
<template>
|
||||||
<header class="border-b border-neutral-200 bg-primary-500 px-3 py-2 text-white sm:px-5 sm:py-2 max-h-[60px]">
|
<header class="border-b border-neutral-200 bg-primary-500 px-3 py-2 text-white sm:px-5 sm:py-2 max-h-[60px]">
|
||||||
<div class="flex h-full items-center justify-between">
|
<div class="flex h-full items-center justify-between">
|
||||||
<button
|
<MalioButtonIcon
|
||||||
class="rounded-md p-2 text-white hover:bg-primary-600 transition-colors lg:hidden"
|
icon="mdi:menu"
|
||||||
|
aria-label="Menu"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="24"
|
||||||
|
button-class="lg:hidden text-white hover:bg-primary-600"
|
||||||
@click="ui.openMobileSidebar()"
|
@click="ui.openMobileSidebar()"
|
||||||
>
|
/>
|
||||||
<Icon name="mdi:menu" size="24" />
|
|
||||||
</button>
|
|
||||||
<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>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-md p-1 text-white/60 transition-colors hover:bg-primary-600 hover:text-white"
|
|
||||||
:title="appTitle === 'NeauTime' ? 'Switch to Lesstime' : 'Switch to NeauTime'"
|
|
||||||
@click="toggleTitle"
|
|
||||||
>
|
|
||||||
<Icon name="mdi:swap-horizontal" size="18" />
|
|
||||||
</button>
|
|
||||||
</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">
|
||||||
<button
|
<MalioButtonIcon
|
||||||
type="button"
|
icon="mdi:help-circle-outline"
|
||||||
class="rounded-md p-1.5 text-white/70 transition-colors hover:bg-primary-600 hover:text-white"
|
aria-label="Centre d'aide"
|
||||||
:title="ui.darkMode ? 'Mode clair' : 'Mode sombre'"
|
variant="ghost"
|
||||||
|
icon-size="22"
|
||||||
|
button-class="text-white/70 hover:bg-primary-600 hover:text-white"
|
||||||
|
@click="navigateTo('/help')"
|
||||||
|
/>
|
||||||
|
<MalioButtonIcon
|
||||||
|
:icon="ui.darkMode ? 'mdi:weather-sunny' : 'mdi:weather-night'"
|
||||||
|
:aria-label="ui.darkMode ? 'Mode clair' : 'Mode sombre'"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="22"
|
||||||
|
button-class="text-white/70 hover:bg-primary-600 hover:text-white"
|
||||||
@click="ui.toggleDarkMode()"
|
@click="ui.toggleDarkMode()"
|
||||||
>
|
/>
|
||||||
<Icon :name="ui.darkMode ? 'mdi:weather-sunny' : 'mdi:weather-night'" size="22" />
|
|
||||||
</button>
|
|
||||||
<NotificationBell />
|
<NotificationBell />
|
||||||
<div class="group relative flex gap-2 sm:gap-4">
|
<div class="group relative flex gap-2 sm:gap-4">
|
||||||
<UserAvatar v-if="user" :user="user" size="md" class="cursor-pointer" />
|
<UserAvatar v-if="user" :user="user" size="md" class="cursor-pointer" />
|
||||||
@@ -64,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')
|
||||||
|
|||||||
@@ -9,20 +9,18 @@
|
|||||||
{{ $t('taskDocuments.confirmDeleteMessage') }}
|
{{ $t('taskDocuments.confirmDeleteMessage') }}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
<button
|
<MalioButton
|
||||||
type="button"
|
variant="tertiary"
|
||||||
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
|
:label="$t('common.cancel')"
|
||||||
|
button-class="w-auto px-4"
|
||||||
@click="cancel"
|
@click="cancel"
|
||||||
>
|
/>
|
||||||
{{ $t('common.cancel') }}
|
<MalioButton
|
||||||
</button>
|
variant="danger"
|
||||||
<button
|
label="Supprimer"
|
||||||
type="button"
|
button-class="w-auto px-4"
|
||||||
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
|
|
||||||
@click="$emit('confirm')"
|
@click="$emit('confirm')"
|
||||||
>
|
/>
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,20 +9,18 @@
|
|||||||
{{ $t('projects.deleteConfirmMessage') }}
|
{{ $t('projects.deleteConfirmMessage') }}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
<button
|
<MalioButton
|
||||||
type="button"
|
variant="tertiary"
|
||||||
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
|
:label="$t('common.cancel')"
|
||||||
|
button-class="w-auto px-4"
|
||||||
@click="cancel"
|
@click="cancel"
|
||||||
>
|
/>
|
||||||
{{ $t('common.cancel') }}
|
<MalioButton
|
||||||
</button>
|
variant="danger"
|
||||||
<button
|
:label="$t('common.delete')"
|
||||||
type="button"
|
button-class="w-auto px-4"
|
||||||
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
|
|
||||||
@click="$emit('confirm')"
|
@click="$emit('confirm')"
|
||||||
>
|
/>
|
||||||
{{ $t('common.delete') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,95 +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">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
|
|
||||||
@click="cancel"
|
|
||||||
>
|
|
||||||
{{ $t('common.cancel') }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-md bg-[red-600] px-4 py-2 text-sm font-semibold text-white hover:bg-[red-700] disabled:opacity-50"
|
|
||||||
:disabled="isProcessing"
|
|
||||||
@click="confirm"
|
|
||||||
>
|
|
||||||
{{ $t('common.delete') }}
|
|
||||||
</button>
|
|
||||||
</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>
|
|
||||||
@@ -9,20 +9,18 @@
|
|||||||
{{ $t('tasks.deleteConfirmMessage') }}
|
{{ $t('tasks.deleteConfirmMessage') }}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
<button
|
<MalioButton
|
||||||
type="button"
|
variant="tertiary"
|
||||||
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
|
label="Annuler"
|
||||||
|
button-class="w-auto px-4"
|
||||||
@click="cancel"
|
@click="cancel"
|
||||||
>
|
/>
|
||||||
Annuler
|
<MalioButton
|
||||||
</button>
|
variant="danger"
|
||||||
<button
|
label="Supprimer"
|
||||||
type="button"
|
button-class="w-auto px-4"
|
||||||
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
|
|
||||||
@click="$emit('confirm')"
|
@click="$emit('confirm')"
|
||||||
>
|
/>
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,13 +35,15 @@
|
|||||||
<td v-if="deletable || $slots.actions" class="px-4 py-3">
|
<td v-if="deletable || $slots.actions" class="px-4 py-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<slot name="actions" :item="item" />
|
<slot name="actions" :item="item" />
|
||||||
<button
|
<MalioButtonIcon
|
||||||
v-if="deletable"
|
v-if="deletable"
|
||||||
class="text-neutral-400 transition-colors hover:text-red-500"
|
icon="mdi:delete-outline"
|
||||||
|
aria-label="Supprimer"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="20"
|
||||||
|
button-class="text-neutral-400 hover:text-red-500"
|
||||||
@click.stop="$emit('delete', item)"
|
@click.stop="$emit('delete', item)"
|
||||||
>
|
/>
|
||||||
<Icon name="mdi:delete-outline" size="20" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
75
frontend/components/ui/MarkdownPreviewModal.vue
Normal file
75
frontend/components/ui/MarkdownPreviewModal.vue
Normal file
@@ -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>
|
||||||
@@ -18,21 +18,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-3">
|
<div class="flex justify-end gap-3">
|
||||||
<button
|
<MalioButton
|
||||||
type="button"
|
variant="tertiary"
|
||||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
|
:label="$t('common.cancel')"
|
||||||
|
button-class="w-auto px-4"
|
||||||
@click="emit('cancel')"
|
@click="emit('cancel')"
|
||||||
>
|
/>
|
||||||
{{ $t('common.cancel') }}
|
<MalioButton
|
||||||
</button>
|
:label="$t('common.confirm')"
|
||||||
<button
|
button-class="w-auto px-4"
|
||||||
type="button"
|
|
||||||
class="rounded-lg bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
|
||||||
:disabled="cropping"
|
:disabled="cropping"
|
||||||
@click="onConfirm"
|
@click="onConfirm"
|
||||||
>
|
/>
|
||||||
{{ $t('common.confirm') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppDrawer v-model="isOpen" :title="isEditing ? $t('users.editUser') : $t('users.addUser')">
|
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('users.editUser') : $t('users.addUser')">
|
||||||
<form class="flex flex-col gap-2" @submit.prevent="handleSubmit">
|
<form class="flex flex-col gap-2" @submit.prevent="handleSubmit">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="form.username"
|
v-model="form.username"
|
||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
@@ -70,16 +69,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end">
|
<div class="mt-6 flex justify-end">
|
||||||
<button
|
<MalioButton
|
||||||
type="submit"
|
label="Enregistrer"
|
||||||
class="rounded-md bg-primary-500 px-6 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
button-class="w-auto px-6"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
>
|
@click="handleSubmit"
|
||||||
Enregistrer
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</MalioDrawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
75
frontend/composables/useSystemFolderLabel.ts
Normal file
75
frontend/composables/useSystemFolderLabel.ts
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
27
frontend/content/help/01-getting-started.md
Normal file
27
frontend/content/help/01-getting-started.md
Normal file
@@ -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.
|
||||||
58
frontend/content/help/02-projects-workflows.md
Normal file
58
frontend/content/help/02-projects-workflows.md
Normal file
@@ -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).
|
||||||
60
frontend/content/help/03-my-tasks.md
Normal file
60
frontend/content/help/03-my-tasks.md
Normal file
@@ -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).
|
||||||
59
frontend/content/help/04-time-tracking.md
Normal file
59
frontend/content/help/04-time-tracking.md
Normal file
@@ -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.
|
||||||
62
frontend/content/help/05-tasks-detail.md
Normal file
62
frontend/content/help/05-tasks-detail.md
Normal file
@@ -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
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user