Compare commits
176 Commits
| 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 |
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"
|
||||
|
||||
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)
|
||||
# ===========================================================================
|
||||
|
||||
# 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.
|
||||
# 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"
|
||||
@@ -74,10 +74,10 @@ DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_P
|
||||
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
|
||||
# pour les valeurs par defaut. Creez docker/.env.docker.local pour
|
||||
# Ces variables sont lues par Docker Compose. Voir infra/dev/.env.docker
|
||||
# pour les valeurs par defaut. Creez infra/dev/.env.docker.local pour
|
||||
# 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 ###
|
||||
|
||||
###> docker local ###
|
||||
docker/.env.docker.local
|
||||
infra/dev/.env.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.
|
||||
|
||||
> **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
|
||||
|
||||
- **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`
|
||||
- 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
|
||||
|
||||
- 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 Nginx : `nginx-lesstime`
|
||||
- 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`
|
||||
|
||||
## 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`
|
||||
- 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)
|
||||
|
||||
## 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)
|
||||
- Notifications temps réel
|
||||
- Intégration Gitea (issues, repos)
|
||||
- Intégration Mail IMAP (boîte partagée OVH, voir `docs/mail-integration.md`)
|
||||
- Serveur MCP pour assistants IA
|
||||
- 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 cache-clear # Vider le cache 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
|
||||
@@ -156,7 +158,7 @@ docker/ # Dockerfiles et config Nginx
|
||||
| `nginx-lesstime` | 8082 | Nginx reverse proxy |
|
||||
| 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
|
||||
|
||||
|
||||
@@ -21,12 +21,15 @@
|
||||
"sabre/vobject": "^4.5",
|
||||
"symfony/asset": "8.0.*",
|
||||
"symfony/console": "8.0.*",
|
||||
"symfony/doctrine-messenger": "^8.0",
|
||||
"symfony/dotenv": "8.0.*",
|
||||
"symfony/expression-language": "8.0.*",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/framework-bundle": "8.0.*",
|
||||
"symfony/http-client": "8.0.*",
|
||||
"symfony/lock": "8.0.*",
|
||||
"symfony/mcp-bundle": "^0.6.0",
|
||||
"symfony/messenger": "^8.0",
|
||||
"symfony/mime": "8.0.*",
|
||||
"symfony/monolog-bundle": "^4.0",
|
||||
"symfony/property-access": "8.0.*",
|
||||
@@ -36,7 +39,8 @@
|
||||
"symfony/security-bundle": "8.0.*",
|
||||
"symfony/serializer": "8.0.*",
|
||||
"symfony/validator": "8.0.*",
|
||||
"symfony/yaml": "8.0.*"
|
||||
"symfony/yaml": "8.0.*",
|
||||
"webklex/php-imap": "^6.2"
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
@@ -93,6 +97,8 @@
|
||||
"require-dev": {
|
||||
"doctrine/doctrine-fixtures-bundle": "^4.3",
|
||||
"friendsofphp/php-cs-fixer": "^3.94",
|
||||
"phpunit/phpunit": "^13.0"
|
||||
"phpunit/phpunit": "^13.0",
|
||||
"symfony/browser-kit": "^8.0",
|
||||
"symfony/css-selector": "^8.0"
|
||||
}
|
||||
}
|
||||
|
||||
1343
composer.lock
generated
1343
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
|
||||
directory: '%kernel.project_dir%/var/mcp-sessions'
|
||||
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: ^/_mcp, roles: PUBLIC_ACCESS, methods: [ GET ] }
|
||||
- { 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 }
|
||||
|
||||
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
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* fallbacks?: list<scalar|Param|null>,
|
||||
* logging?: bool|Param, // Default: false
|
||||
* 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
|
||||
* },
|
||||
* lock?: bool|string|array{ // Lock configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* resources?: array<string, string|list<scalar|Param|null>>,
|
||||
* },
|
||||
* semaphore?: bool|string|array{ // Semaphore configuration
|
||||
@@ -421,7 +421,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* resources?: array<string, scalar|Param|null>,
|
||||
* },
|
||||
* messenger?: bool|array{ // Messenger configuration
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* routing?: array<string, string|array{ // Default: []
|
||||
* 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
|
||||
* },
|
||||
* messenger?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* },
|
||||
* elasticsearch?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.3.9'
|
||||
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:
|
||||
container_name: php-${DOCKER_APP_NAME}-fpm
|
||||
build:
|
||||
context: ./docker/php
|
||||
context: ./infra/dev
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
DOCKER_PHP_VERSION: ${DOCKER_PHP_VERSION}
|
||||
@@ -21,8 +21,8 @@ services:
|
||||
- ~/.cache:/var/www/.cache # Pour la cache de composer
|
||||
- ~/.config:/var/www/.config # Pour la config de yarn
|
||||
- ~/.composer:/var/www/.composer # Pour la config de composer
|
||||
- ./docker/php/config/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/php.ini:/usr/local/etc/php/php.ini
|
||||
- ./infra/dev/xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
|
||||
- ./LOG:/var/www/html/LOG
|
||||
- uploads_data:/var/www/html/var/uploads
|
||||
extra_hosts:
|
||||
@@ -41,7 +41,7 @@ services:
|
||||
- "8082:80"
|
||||
volumes:
|
||||
- ./:/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
|
||||
db:
|
||||
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
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
@@ -89,7 +89,7 @@ sudo -u www-data php bin/console lexik:jwt:generate-keypair --skip-if-exists --e
|
||||
## 7. Configurer Nginx
|
||||
|
||||
```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 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) |
|
||||
@@ -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
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"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
<MalioInputPassword
|
||||
v-model="form.tokenId"
|
||||
:label="$t('bookstack.settings.tokenId')"
|
||||
:placeholder="$t('bookstack.settings.tokenIdPlaceholder')"
|
||||
input-class="w-full"
|
||||
type="password"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<MalioInputText
|
||||
<MalioInputPassword
|
||||
v-model="form.tokenSecret"
|
||||
:label="$t('bookstack.settings.tokenSecret')"
|
||||
:placeholder="$t('bookstack.settings.tokenSecretPlaceholder')"
|
||||
input-class="w-full"
|
||||
type="password"
|
||||
/>
|
||||
<p v-if="hasToken && !form.tokenId && !form.tokenSecret" class="mt-1 text-xs text-green-600">
|
||||
{{ $t('bookstack.settings.tokenConfigured') }}
|
||||
@@ -32,21 +28,19 @@
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:opacity-50"
|
||||
<MalioButton
|
||||
:label="$t('bookstack.settings.save')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
{{ $t('bookstack.settings.save') }}
|
||||
</button>
|
||||
<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 disabled:opacity-50"
|
||||
@click="handleSave"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('bookstack.settings.testConnection')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isTesting"
|
||||
@click="handleTest"
|
||||
>
|
||||
{{ $t('bookstack.settings.testConnection') }}
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'">
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">Clients</h2>
|
||||
<button
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
label="Ajouter un client"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ Ajouter un client
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
|
||||
@@ -92,19 +92,21 @@
|
||||
<td class="px-3 py-3 text-neutral-400">{{ formatDate(ticket.createdAt) }}</td>
|
||||
<td class="px-3 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-primary-500"
|
||||
:title="$t('clientTicket.changeStatus')"
|
||||
<MalioButtonIcon
|
||||
icon="mdi:swap-horizontal"
|
||||
:aria-label="$t('clientTicket.changeStatus')"
|
||||
variant="ghost"
|
||||
icon-size="18"
|
||||
@click.stop="openStatusChange(ticket)"
|
||||
>
|
||||
<Icon name="mdi:swap-horizontal" size="18" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded p-1 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500"
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:delete-outline"
|
||||
aria-label="Supprimer"
|
||||
variant="ghost"
|
||||
icon-size="18"
|
||||
button-class="text-neutral-400 hover:bg-red-50 hover:text-red-500"
|
||||
@click.stop="openDeleteConfirm(ticket)"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="18" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -155,19 +157,18 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('common.cancel')"
|
||||
button-class="w-auto px-4"
|
||||
@click="statusModalOpen = false"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
<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"
|
||||
/>
|
||||
<MalioButton
|
||||
label="Confirmer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isUpdatingStatus"
|
||||
@click="confirmStatusChange"
|
||||
>
|
||||
Confirmer
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -186,19 +187,19 @@
|
||||
<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>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('common.cancel')"
|
||||
button-class="w-auto px-4"
|
||||
@click="deleteModalOpen = false"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
label="Supprimer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isDeleting"
|
||||
@click="confirmDelete"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">Efforts</h2>
|
||||
<button
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
label="Ajouter un effort"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ Ajouter un effort
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
|
||||
@@ -11,12 +11,10 @@
|
||||
/>
|
||||
|
||||
<div>
|
||||
<MalioInputText
|
||||
<MalioInputPassword
|
||||
v-model="form.token"
|
||||
:label="$t('gitea.settings.token')"
|
||||
:placeholder="$t('gitea.settings.tokenPlaceholder')"
|
||||
input-class="w-full"
|
||||
type="password"
|
||||
/>
|
||||
<p v-if="hasToken && !form.token" class="mt-1 text-xs text-green-600">
|
||||
{{ $t('gitea.settings.tokenConfigured') }}
|
||||
@@ -24,21 +22,19 @@
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:opacity-50"
|
||||
<MalioButton
|
||||
:label="$t('gitea.settings.save')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
{{ $t('gitea.settings.save') }}
|
||||
</button>
|
||||
<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 disabled:opacity-50"
|
||||
@click="handleSave"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('gitea.settings.testConnection')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isTesting"
|
||||
@click="handleTest"
|
||||
>
|
||||
{{ $t('gitea.settings.testConnection') }}
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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 class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">Priorités</h2>
|
||||
<button
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
label="Ajouter une priorité"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ Ajouter une priorité
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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 class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">Tags</h2>
|
||||
<button
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
label="Ajouter un tag"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ Ajouter un tag
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">Utilisateurs</h2>
|
||||
<button
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
label="Ajouter un utilisateur"
|
||||
@click="openCreate"
|
||||
>
|
||||
+ Ajouter un utilisateur
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
/>
|
||||
<div>
|
||||
<MalioInputText
|
||||
<MalioInputPassword
|
||||
v-model="form.password"
|
||||
:label="$t('zimbra.settings.password')"
|
||||
input-class="w-full"
|
||||
type="password"
|
||||
/>
|
||||
<p v-if="hasPassword && !form.password" class="mt-1 text-xs text-green-600">
|
||||
{{ $t('zimbra.settings.passwordConfigured') }}
|
||||
@@ -37,21 +36,19 @@
|
||||
<span class="text-sm">{{ $t('zimbra.settings.enabled') }}</span>
|
||||
</label>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:opacity-50"
|
||||
<MalioButton
|
||||
:label="$t('zimbra.settings.save')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
{{ $t('zimbra.settings.save') }}
|
||||
</button>
|
||||
<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 disabled:opacity-50"
|
||||
@click="handleSave"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('zimbra.settings.testConnection')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isTesting"
|
||||
@click="handleTest"
|
||||
>
|
||||
{{ $t('zimbra.settings.testConnection') }}
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
<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') }}
|
||||
|
||||
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 class="flex items-center gap-2">
|
||||
<!-- Edit button (only for open tickets submitted by current user) -->
|
||||
<button
|
||||
<MalioButton
|
||||
v-if="canEdit && !isEditing"
|
||||
type="button"
|
||||
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"
|
||||
variant="tertiary"
|
||||
icon-name="mdi:pencil-outline"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-3"
|
||||
:label="$t('common.edit')"
|
||||
@click="startEdit"
|
||||
>
|
||||
<Icon name="mdi:pencil-outline" size="16" />
|
||||
{{ $t('common.edit') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:close"
|
||||
aria-label="Fermer"
|
||||
variant="ghost"
|
||||
icon-size="20"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="mdi:close" size="20" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,14 +66,10 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
||||
{{ $t('clientTicket.description') }}
|
||||
</label>
|
||||
<textarea
|
||||
<MalioInputRichText
|
||||
v-model="editForm.description"
|
||||
rows="5"
|
||||
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"
|
||||
style="resize: vertical; min-height: 140px; max-height: 500px"
|
||||
:label="$t('clientTicket.description')"
|
||||
min-height="180px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -90,21 +86,18 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('common.cancel')"
|
||||
button-class="w-auto px-4"
|
||||
@click="cancelEdit"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="$t('common.save')"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSaving"
|
||||
@click="saveEdit"
|
||||
>
|
||||
{{ $t('common.save') }}
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -132,7 +125,13 @@
|
||||
<!-- Description -->
|
||||
<div class="mt-4">
|
||||
<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>
|
||||
|
||||
<!-- URL (if bug) -->
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Trigger button -->
|
||||
<button
|
||||
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"
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
icon-name="mdi:ticket-outline"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-3 sm:px-4 shrink-0"
|
||||
@click="open"
|
||||
>
|
||||
<Icon name="mdi:ticket-outline" class="size-4 sm:size-5" />
|
||||
<span class="hidden sm:inline">{{ $t('clientTicket.adminTab') }}</span>
|
||||
<span
|
||||
v-if="totalCount > 0"
|
||||
@@ -13,7 +15,7 @@
|
||||
>
|
||||
{{ totalCount }}
|
||||
</span>
|
||||
</button>
|
||||
</MalioButton>
|
||||
|
||||
<!-- Panel -->
|
||||
<Teleport v-if="isOpen" to="body">
|
||||
@@ -33,13 +35,13 @@
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
<MalioButtonIcon
|
||||
icon="mdi:close"
|
||||
aria-label="Fermer"
|
||||
variant="ghost"
|
||||
icon-size="20"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="mdi:close" size="20" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
@@ -97,13 +99,13 @@
|
||||
<p class="mt-0.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
class="rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-primary-500"
|
||||
:title="$t('clientTicket.changeStatus')"
|
||||
<MalioButtonIcon
|
||||
icon="mdi:swap-horizontal"
|
||||
:aria-label="$t('clientTicket.changeStatus')"
|
||||
variant="ghost"
|
||||
icon-size="16"
|
||||
@click.stop="openStatusChange(ticket)"
|
||||
>
|
||||
<Icon name="mdi:swap-horizontal" size="16" />
|
||||
</button>
|
||||
/>
|
||||
<Icon
|
||||
:name="expandedId === ticket.id ? 'mdi:chevron-up' : 'mdi:chevron-down'"
|
||||
size="18"
|
||||
@@ -114,7 +116,12 @@
|
||||
|
||||
<!-- Expanded details -->
|
||||
<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">
|
||||
<a
|
||||
:href="ticket.url"
|
||||
@@ -179,19 +186,18 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('common.cancel')"
|
||||
button-class="w-auto px-4"
|
||||
@click="statusModalOpen = false"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
<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"
|
||||
/>
|
||||
<MalioButton
|
||||
label="Confirmer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isUpdatingStatus"
|
||||
@click="confirmStatusChange"
|
||||
>
|
||||
Confirmer
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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">
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
@@ -35,16 +35,15 @@
|
||||
/>
|
||||
|
||||
<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"
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
<div ref="bellRef" class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="relative rounded-md p-2 text-white hover:bg-primary-600 transition-colors"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<Icon name="mdi:bell-outline" size="24" />
|
||||
<div class="relative">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:bell-outline"
|
||||
aria-label="Notifications"
|
||||
variant="ghost"
|
||||
icon-size="24"
|
||||
button-class="text-white hover:bg-primary-600"
|
||||
@click="toggleDropdown"
|
||||
/>
|
||||
<span
|
||||
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 }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Transition name="dropdown">
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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">
|
||||
<MalioInputText
|
||||
v-model="form.code"
|
||||
@@ -54,41 +54,69 @@
|
||||
</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"
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="isEditing && project" class="mt-6 border-t border-neutral-200 pt-4 flex items-center justify-between">
|
||||
<button
|
||||
class="flex items-center gap-2 text-sm text-neutral-500 hover:text-amber-600"
|
||||
<MalioButton
|
||||
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"
|
||||
@click="handleArchiveToggle"
|
||||
>
|
||||
<Icon :name="project.archived ? 'mdi:archive-arrow-up-outline' : 'mdi:archive-arrow-down-outline'" size="18" />
|
||||
{{ project.archived ? 'Désarchiver' : 'Archiver' }}
|
||||
</button>
|
||||
<button
|
||||
</MalioButton>
|
||||
<MalioButton
|
||||
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"
|
||||
@click="confirmDeleteOpen = true"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="18" />
|
||||
{{ $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>
|
||||
|
||||
<ConfirmDeleteProjectModal
|
||||
v-model="confirmDeleteOpen"
|
||||
@confirm="handleDelete"
|
||||
/>
|
||||
</AppDrawer>
|
||||
|
||||
<ProjectWorkflowSwitchModal
|
||||
v-if="props.project"
|
||||
v-model="switchModalOpen"
|
||||
:project="props.project"
|
||||
@switched="onWorkflowSwitched"
|
||||
/>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -119,6 +147,15 @@ const isOpen = computed({
|
||||
const isEditing = computed(() => !!props.project)
|
||||
const isSubmitting = 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 giteaRepos = ref<GiteaRepository[]>([])
|
||||
|
||||
@@ -3,20 +3,20 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">Groupes</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm font-medium text-neutral-500 hover:text-neutral-700"
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
button-class="w-auto px-3"
|
||||
:label="showArchived ? $t('archive.hideArchived') : $t('archive.showArchived')"
|
||||
@click="showArchived = !showArchived"
|
||||
>
|
||||
{{ showArchived ? $t('archive.hideArchived') : $t('archive.showArchived') }}
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<MalioButton
|
||||
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"
|
||||
>
|
||||
+ Ajouter un groupe
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -36,25 +36,23 @@
|
||||
/>
|
||||
</template>
|
||||
<template #cell-description="{ item }">
|
||||
{{ item.description ?? '—' }}
|
||||
{{ stripRichText(item.description) || '—' }}
|
||||
</template>
|
||||
<template #actions="{ item }">
|
||||
<button
|
||||
<MalioButton
|
||||
v-if="!showArchived && canArchiveGroup(item)"
|
||||
type="button"
|
||||
class="rounded-md bg-neutral-500 px-3 py-1 text-xs font-semibold text-white hover:bg-neutral-600"
|
||||
variant="secondary"
|
||||
:label="$t('archive.archiveButton')"
|
||||
button-class="w-auto px-3"
|
||||
@click.stop="handleArchive(item)"
|
||||
>
|
||||
{{ $t('archive.archiveButton') }}
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="showArchived"
|
||||
type="button"
|
||||
class="rounded-md bg-neutral-500 px-3 py-1 text-xs font-semibold text-white hover:bg-neutral-600"
|
||||
variant="secondary"
|
||||
:label="$t('archive.unarchiveButton')"
|
||||
button-class="w-auto px-3"
|
||||
@click.stop="handleUnarchive(item)"
|
||||
>
|
||||
{{ $t('archive.unarchiveButton') }}
|
||||
</button>
|
||||
/>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
@@ -73,6 +71,7 @@ import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import { useTaskGroupService } from '~/services/task-groups'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
import { stripRichText } from '~/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
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 }}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-auto shrink-0 text-neutral-300 hover:text-red-500"
|
||||
<MalioButtonIcon
|
||||
icon="mdi:close"
|
||||
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)"
|
||||
>
|
||||
<Icon name="mdi:close" size="16" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -14,8 +14,9 @@
|
||||
</span>
|
||||
|
||||
<div v-if="selectedCount > 0" class="ml-2 flex items-center gap-1">
|
||||
<!-- Bulk status -->
|
||||
<!-- Bulk status (scoped to single project's workflow) -->
|
||||
<MalioSelect
|
||||
v-if="!isMultiProject"
|
||||
:model-value="null"
|
||||
:options="statusOptions"
|
||||
label="Status"
|
||||
@@ -25,6 +26,13 @@
|
||||
text-value="text-xs"
|
||||
@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 -->
|
||||
<MalioSelect
|
||||
:model-value="null"
|
||||
@@ -72,25 +80,28 @@
|
||||
/>
|
||||
|
||||
<!-- Delete -->
|
||||
<button
|
||||
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"
|
||||
title="Supprimer"
|
||||
<MalioButtonIcon
|
||||
icon="mdi:delete-outline"
|
||||
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')"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="22" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
selectedCount: number
|
||||
totalCount: number
|
||||
allSelected: boolean
|
||||
@@ -100,7 +111,12 @@ const props = defineProps<{
|
||||
priorities: TaskPriority[]
|
||||
efforts: TaskEffort[]
|
||||
groups: TaskGroup[]
|
||||
}>()
|
||||
selectedTasks?: Task[]
|
||||
projects?: Project[]
|
||||
}>(), {
|
||||
selectedTasks: () => [],
|
||||
projects: () => [],
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle-all'): void
|
||||
@@ -109,23 +125,42 @@ const emit = defineEmits<{
|
||||
(e: 'bulk-delete'): void
|
||||
}>()
|
||||
|
||||
const statusOptions = computed(() =>
|
||||
props.statuses.map(s => ({ label: s.label, value: s.id }))
|
||||
)
|
||||
const distinctProjectIds = computed(() => {
|
||||
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(() =>
|
||||
props.users.map(u => ({ label: u.username, value: u.id }))
|
||||
props.users.map(u => ({ label: u.username, value: u.id })),
|
||||
)
|
||||
|
||||
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(() =>
|
||||
props.efforts.map(e => ({ label: e.label, value: e.id }))
|
||||
props.efforts.map(e => ({ label: e.label, value: e.id })),
|
||||
)
|
||||
|
||||
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>
|
||||
|
||||
@@ -29,16 +29,24 @@
|
||||
</div>
|
||||
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||
</div>
|
||||
<button
|
||||
class="shrink-0 transition-colors"
|
||||
:class="isTimerOnTask ? 'text-[#F18619] hover:text-[#d97314]' : 'text-neutral-400 hover:text-primary-500'"
|
||||
<MalioButtonIcon
|
||||
:icon="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'"
|
||||
: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()"
|
||||
>
|
||||
<Icon :name="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'" size="20" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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
|
||||
v-if="task.priority"
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
@@ -77,11 +85,17 @@
|
||||
class="text-blue-500"
|
||||
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
|
||||
v-if="task.assignee"
|
||||
:user="task.assignee"
|
||||
size="xs"
|
||||
class="ml-auto"
|
||||
:class="task.collaborators?.length ? '' : 'ml-auto'"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
@@ -99,8 +113,10 @@ import type { Task } from '~/services/dto/task'
|
||||
const props = withDefaults(defineProps<{
|
||||
task: Task
|
||||
showProjectColor?: boolean
|
||||
showStatusBadge?: boolean
|
||||
}>(), {
|
||||
showProjectColor: false,
|
||||
showStatusBadge: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -32,14 +32,15 @@
|
||||
</div>
|
||||
|
||||
<!-- Delete button -->
|
||||
<button
|
||||
<MalioButtonIcon
|
||||
v-if="isAdmin"
|
||||
type="button"
|
||||
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"
|
||||
icon="heroicons:x-mark"
|
||||
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)"
|
||||
>
|
||||
<Icon name="heroicons:x-mark" class="h-4 w-4" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,28 +12,34 @@
|
||||
ref="overlayRef"
|
||||
>
|
||||
<!-- Close button -->
|
||||
<button
|
||||
class="absolute right-4 top-4 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
|
||||
<MalioButtonIcon
|
||||
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')"
|
||||
>
|
||||
<Icon name="heroicons:x-mark" class="h-6 w-6" />
|
||||
</button>
|
||||
/>
|
||||
|
||||
<!-- Navigation arrows -->
|
||||
<button
|
||||
<MalioButtonIcon
|
||||
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')"
|
||||
>
|
||||
<Icon name="heroicons:chevron-left" class="h-6 w-6" />
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
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')"
|
||||
>
|
||||
<Icon name="heroicons:chevron-right" class="h-6 w-6" />
|
||||
</button>
|
||||
/>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex max-h-[90vh] max-w-[90vw] flex-col items-center">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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">
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
@@ -10,16 +10,15 @@
|
||||
/>
|
||||
|
||||
<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"
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -38,24 +38,22 @@
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
<MalioButtonIcon
|
||||
v-if="activeTab === 'branches'"
|
||||
type="button"
|
||||
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"
|
||||
:title="$t('gitea.branch.copy')"
|
||||
icon="mdi:content-copy"
|
||||
:aria-label="$t('gitea.branch.copy')"
|
||||
variant="ghost"
|
||||
icon-size="14"
|
||||
@click="handleCopy"
|
||||
>
|
||||
<Icon name="mdi:content-copy" size="14" />
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="activeTab === 'branches'"
|
||||
type="button"
|
||||
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-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-2.5 py-1.5 text-xs"
|
||||
:label="$t('gitea.branch.create')"
|
||||
@click="showCreateForm = !showCreateForm"
|
||||
>
|
||||
<Icon name="mdi:plus" size="14" class="mr-0.5 inline-block align-[-2px]" />
|
||||
{{ $t('gitea.branch.create') }}
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -79,14 +77,12 @@
|
||||
:label="$t('gitea.branch.baseBranch')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
<MalioButton
|
||||
:label="isCreating ? '...' : $t('gitea.branch.create')"
|
||||
button-class="w-auto px-4 mb-[2px] text-xs"
|
||||
:disabled="isCreating"
|
||||
@click="handleCreate"
|
||||
>
|
||||
{{ isCreating ? '...' : $t('gitea.branch.create') }}
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
<code class="mt-2 block rounded bg-neutral-50 px-2 py-1 text-[11px] text-neutral-500">
|
||||
{{ branchPreview }}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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">
|
||||
<MalioInputText
|
||||
v-model="form.title"
|
||||
@@ -8,10 +8,10 @@
|
||||
:error="touched.title && !form.title.trim() ? 'Le titre est requis' : ''"
|
||||
@blur="touched.title = true"
|
||||
/>
|
||||
<MalioInputTextArea
|
||||
<MalioInputRichText
|
||||
v-model="form.description"
|
||||
label="Description"
|
||||
:size="3"
|
||||
min-height="120px"
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<ColorPicker v-model="form.color" />
|
||||
@@ -25,34 +25,31 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center" :class="isEditing ? 'justify-between' : 'justify-end'">
|
||||
<button
|
||||
<MalioButton
|
||||
v-if="canArchive"
|
||||
type="button"
|
||||
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"
|
||||
variant="secondary"
|
||||
:label="$t('archive.archiveButton')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleArchive"
|
||||
>
|
||||
{{ $t('archive.archiveButton') }}
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="canUnarchive"
|
||||
type="button"
|
||||
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"
|
||||
variant="secondary"
|
||||
:label="$t('archive.unarchiveButton')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleUnarchive"
|
||||
>
|
||||
{{ $t('archive.unarchiveButton') }}
|
||||
</button>
|
||||
<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"
|
||||
/>
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -78,24 +78,33 @@
|
||||
|
||||
<!-- Right: timer top, avatar bottom -->
|
||||
<div class="flex shrink-0 flex-col items-end justify-between self-stretch gap-1">
|
||||
<button
|
||||
class="shrink-0 transition-colors"
|
||||
:class="isTimerOnTask ? 'text-[#F18619] hover:text-[#d97314]' : 'text-neutral-400 hover:text-primary-500'"
|
||||
<MalioButtonIcon
|
||||
:icon="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'"
|
||||
: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)"
|
||||
>
|
||||
<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
|
||||
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 class="flex items-center gap-1">
|
||||
<Icon
|
||||
v-if="task.collaborators?.length"
|
||||
name="mdi:account-group"
|
||||
class="h-4 w-4 text-neutral-400"
|
||||
:title="task.collaborators.map(c => c.username).join(', ')"
|
||||
/>
|
||||
<UserAvatar
|
||||
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>
|
||||
</template>
|
||||
|
||||
@@ -27,13 +27,13 @@
|
||||
{{ isEditing ? $t('tasks.editTask') : $t('tasks.addTask') }}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
<MalioButtonIcon
|
||||
icon="mdi:close"
|
||||
aria-label="Fermer"
|
||||
variant="ghost"
|
||||
icon-size="20"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="mdi:close" size="20" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<nav class="flex gap-6">
|
||||
<button
|
||||
v-for="tab in ['details', 'planning']"
|
||||
v-for="tab in availableTabs"
|
||||
:key="tab"
|
||||
type="button"
|
||||
class="px-1 pb-3 text-sm font-semibold transition"
|
||||
:class="activeTab === tab
|
||||
? 'border-b-2 border-primary-500 text-primary-500'
|
||||
: '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>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -170,15 +170,36 @@
|
||||
</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 -->
|
||||
<div class="mt-5">
|
||||
<MalioInputTextArea
|
||||
<MalioInputRichText
|
||||
v-model="form.description"
|
||||
label="Description"
|
||||
:size="5"
|
||||
resize="vertical"
|
||||
:min-resize-height="140"
|
||||
:max-resize-height="500"
|
||||
min-height="180px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -412,53 +433,118 @@
|
||||
</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 -->
|
||||
<div
|
||||
class="mt-6 flex items-center border-t border-neutral-100 pt-5"
|
||||
:class="isEditing ? 'justify-between' : 'justify-end'"
|
||||
>
|
||||
<button
|
||||
<MalioButton
|
||||
v-if="isEditing"
|
||||
type="button"
|
||||
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"
|
||||
variant="danger"
|
||||
label="Supprimer"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSubmitting"
|
||||
@click="confirmDeleteOpen = true"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
/>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
<MalioButton
|
||||
v-if="canArchive"
|
||||
type="button"
|
||||
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"
|
||||
variant="tertiary"
|
||||
:label="$t('archive.archiveButton')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleArchive"
|
||||
>
|
||||
{{ $t('archive.archiveButton') }}
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="canUnarchive"
|
||||
type="button"
|
||||
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"
|
||||
variant="tertiary"
|
||||
:label="$t('archive.unarchiveButton')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleUnarchive"
|
||||
>
|
||||
{{ $t('archive.unarchiveButton') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
label="Annuler"
|
||||
button-class="w-auto px-4"
|
||||
@click="close"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
/>
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -497,6 +583,8 @@ import { useTaskService } from '~/services/tasks'
|
||||
import { useTaskRecurrenceService } from '~/services/task-recurrences'
|
||||
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import { useMailService } from '~/services/mail'
|
||||
import type { MailMessageHeaderDto } from '~/services/dto/mail'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -529,7 +617,14 @@ function close() {
|
||||
const isEditing = computed(() => !!props.task)
|
||||
const isSubmitting = 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 { getSettings: getGiteaSettings } = useGiteaService()
|
||||
@@ -549,6 +644,7 @@ const form = reactive({
|
||||
effortId: null as number | null,
|
||||
priorityId: null as number | null,
|
||||
assigneeId: null as number | null,
|
||||
collaboratorIds: [] as number[],
|
||||
groupId: null as number | null,
|
||||
tagIds: [] as number[],
|
||||
clientTicketId: null as number | null,
|
||||
@@ -591,6 +687,18 @@ const userOptions = computed(() =>
|
||||
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(() => {
|
||||
let filtered = props.groups.filter(g => !g.archived)
|
||||
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(() => [
|
||||
{ value: 'monday', label: t('tasks.planning.days.mon') },
|
||||
{ value: 'tuesday', label: t('tasks.planning.days.tue') },
|
||||
@@ -653,6 +767,7 @@ function populateForm(task: Task | null) {
|
||||
form.effortId = task.effort?.id ?? null
|
||||
form.priorityId = task.priority?.id ?? null
|
||||
form.assigneeId = task.assignee?.id ?? null
|
||||
form.collaboratorIds = task.collaborators?.map(c => c.id) ?? []
|
||||
form.groupId = task.group?.id ?? null
|
||||
form.tagIds = task.tags.map(t => t.id)
|
||||
form.clientTicketId = task.clientTicket?.id ?? null
|
||||
@@ -699,6 +814,7 @@ function populateForm(task: Task | null) {
|
||||
form.effortId = null
|
||||
form.priorityId = null
|
||||
form.assigneeId = null
|
||||
form.collaboratorIds = []
|
||||
form.groupId = null
|
||||
form.tagIds = []
|
||||
form.clientTicketId = null
|
||||
@@ -728,6 +844,7 @@ watch(() => props.modelValue, async (open) => {
|
||||
activeTab.value = 'details'
|
||||
confirmDeleteDocOpen.value = false
|
||||
documentToDelete.value = null
|
||||
linkedMails.value = []
|
||||
populateForm(props.task)
|
||||
const pid = resolvedProjectId.value
|
||||
if (pid) {
|
||||
@@ -786,6 +903,49 @@ watch(() => form.projectId, async (pid) => {
|
||||
const authStore = useAuthStore()
|
||||
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 {
|
||||
switch (status) {
|
||||
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,
|
||||
priority: form.priorityId ? `/api/task_priorities/${form.priorityId}` : 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,
|
||||
project: `/api/projects/${resolvedProjectId.value}`,
|
||||
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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">
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
@@ -13,16 +13,15 @@
|
||||
</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"
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
<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">
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
@@ -13,16 +13,15 @@
|
||||
</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"
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Titre</label>
|
||||
@@ -11,14 +11,11 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Description</label>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
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>
|
||||
<MalioInputRichText
|
||||
v-model="form.description"
|
||||
label="Description"
|
||||
min-height="120px"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Date</label>
|
||||
@@ -97,33 +94,30 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center" :class="isEditing ? 'justify-between' : 'justify-end'">
|
||||
<button
|
||||
<MalioButton
|
||||
v-if="isEditing"
|
||||
type="button"
|
||||
class="rounded-md bg-red-500 px-4 py-2 text-sm font-semibold text-white hover:bg-red-600 transition"
|
||||
variant="danger"
|
||||
label="Supprimer"
|
||||
button-class="w-auto px-4"
|
||||
@click="onDelete"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
<MalioButton
|
||||
v-if="isEditing"
|
||||
type="button"
|
||||
class="rounded-md bg-blue-500 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-600 transition"
|
||||
variant="secondary"
|
||||
label="Dupliquer"
|
||||
button-class="w-auto px-4"
|
||||
@click="onDuplicate"
|
||||
>
|
||||
Dupliquer
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 transition"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
/>
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-4"
|
||||
@click="onSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
</div>
|
||||
<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.description" class="text-neutral-300">·</span>
|
||||
<span v-if="entry.description" class="truncate">{{ entry.description }}</span>
|
||||
<span v-if="entry.project && stripRichText(entry.description)" class="text-neutral-300">·</span>
|
||||
<span v-if="stripRichText(entry.description)" class="truncate">{{ stripRichText(entry.description) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -54,19 +54,21 @@
|
||||
</div>
|
||||
|
||||
<!-- Delete action -->
|
||||
<button
|
||||
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"
|
||||
:title="$t('common.delete')"
|
||||
<MalioButtonIcon
|
||||
icon="mdi:delete-outline"
|
||||
: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)"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="18" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||
import { stripRichText } from '~/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
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">
|
||||
<h2 class="text-lg font-bold text-neutral-900">{{ title }}</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1 text-neutral-400 hover:text-neutral-600"
|
||||
<MalioButtonIcon
|
||||
icon="mdi:close"
|
||||
aria-label="Fermer"
|
||||
variant="ghost"
|
||||
icon-size="24"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="mdi:close" size="24" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4">
|
||||
<slot />
|
||||
|
||||
@@ -1,32 +1,34 @@
|
||||
<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]">
|
||||
<div class="flex h-full items-center justify-between">
|
||||
<button
|
||||
class="rounded-md p-2 text-white hover:bg-primary-600 transition-colors lg:hidden"
|
||||
<MalioButtonIcon
|
||||
icon="mdi:menu"
|
||||
aria-label="Menu"
|
||||
variant="ghost"
|
||||
icon-size="24"
|
||||
button-class="lg:hidden text-white hover:bg-primary-600"
|
||||
@click="ui.openMobileSidebar()"
|
||||
>
|
||||
<Icon name="mdi:menu" size="24" />
|
||||
</button>
|
||||
/>
|
||||
<div class="hidden items-center gap-2 lg:flex">
|
||||
<h1 class="text-lg font-bold tracking-tight">{{ appTitle }}</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>
|
||||
<h1 class="text-lg font-bold tracking-tight">Lesstime</h1>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md p-1.5 text-white/70 transition-colors hover:bg-primary-600 hover:text-white"
|
||||
:title="ui.darkMode ? 'Mode clair' : 'Mode sombre'"
|
||||
<MalioButtonIcon
|
||||
icon="mdi:help-circle-outline"
|
||||
aria-label="Centre d'aide"
|
||||
variant="ghost"
|
||||
icon-size="22"
|
||||
button-class="text-white/70 hover:bg-primary-600 hover:text-white"
|
||||
@click="navigateTo('/help')"
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
: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()"
|
||||
>
|
||||
<Icon :name="ui.darkMode ? 'mdi:weather-sunny' : 'mdi:weather-night'" size="22" />
|
||||
</button>
|
||||
/>
|
||||
<NotificationBell />
|
||||
<div class="group relative flex gap-2 sm:gap-4">
|
||||
<UserAvatar v-if="user" :user="user" size="md" class="cursor-pointer" />
|
||||
@@ -64,13 +66,6 @@ defineProps<{
|
||||
const auth = useAuthStore()
|
||||
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() {
|
||||
await auth.logout()
|
||||
await navigateTo('/login')
|
||||
|
||||
@@ -9,20 +9,18 @@
|
||||
{{ $t('taskDocuments.confirmDeleteMessage') }}
|
||||
</p>
|
||||
<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"
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('common.cancel')"
|
||||
button-class="w-auto px-4"
|
||||
@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"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
label="Supprimer"
|
||||
button-class="w-auto px-4"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,20 +9,18 @@
|
||||
{{ $t('projects.deleteConfirmMessage') }}
|
||||
</p>
|
||||
<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"
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('common.cancel')"
|
||||
button-class="w-auto px-4"
|
||||
@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"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
:label="$t('common.delete')"
|
||||
button-class="w-auto px-4"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
{{ $t('common.delete') }}
|
||||
</button>
|
||||
/>
|
||||
</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') }}
|
||||
</p>
|
||||
<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"
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
label="Annuler"
|
||||
button-class="w-auto px-4"
|
||||
@click="cancel"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
label="Supprimer"
|
||||
button-class="w-auto px-4"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -35,13 +35,15 @@
|
||||
<td v-if="deletable || $slots.actions" class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<slot name="actions" :item="item" />
|
||||
<button
|
||||
<MalioButtonIcon
|
||||
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)"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="20" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</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 class="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('common.cancel')"
|
||||
button-class="w-auto px-4"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="$t('common.confirm')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="cropping"
|
||||
@click="onConfirm"
|
||||
>
|
||||
{{ $t('common.confirm') }}
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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">
|
||||
<MalioInputText
|
||||
v-model="form.username"
|
||||
@@ -8,12 +8,11 @@
|
||||
:error="touched.username && !form.username.trim() ? 'Le nom est requis' : ''"
|
||||
@blur="touched.username = true"
|
||||
/>
|
||||
<MalioInputText
|
||||
<MalioInputPassword
|
||||
v-model="form.password"
|
||||
label="Mot de passe"
|
||||
input-class="w-full"
|
||||
type="password"
|
||||
:placeholder="isEditing ? 'Laisser vide pour ne pas changer' : ''"
|
||||
:hint="isEditing ? 'Laisser vide pour ne pas changer' : ''"
|
||||
:error="touched.password && !isEditing && !form.password ? 'Le mot de passe est requis' : ''"
|
||||
@blur="touched.password = true"
|
||||
/>
|
||||
@@ -70,16 +69,15 @@
|
||||
</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"
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<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
|
||||
43
frontend/content/help/06-client-portal.md
Normal file
43
frontend/content/help/06-client-portal.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Portal client
|
||||
|
||||
> 🎫 Section dédiée aux utilisateurs avec le rôle **ROLE_CLIENT**.
|
||||
|
||||
## Accès
|
||||
|
||||
Les utilisateurs *client* sont **automatiquement redirigés vers `/portal`** après login. Ils ne voient pas les vues internes (projets, time tracking, admin).
|
||||
|
||||
## Ce que voit un client
|
||||
|
||||
- 📋 La liste de ses **projets autorisés** (définis par l'admin dans le user)
|
||||
- 🎫 Sur chaque projet, la liste de ses **tickets** (ses créations uniquement)
|
||||
- ➕ Le bouton **Nouveau ticket** sur chaque projet
|
||||
|
||||
## Soumettre un ticket
|
||||
|
||||
Depuis `/portal/projects/<id>/new-ticket` :
|
||||
|
||||
| Champ | Description |
|
||||
|---|---|
|
||||
| **Type** | `bug` / `improvement` / `other` |
|
||||
| **Titre** | Court et descriptif |
|
||||
| **Description** | Détails — markdown supporté |
|
||||
| **URL** | Optionnel — page où le problème se manifeste |
|
||||
|
||||
Le ticket est automatiquement numéroté **par projet** (ex: `CT-001`).
|
||||
|
||||
## Statuts d'un ticket
|
||||
|
||||
| Statut | Visible côté client | Signification |
|
||||
|---|---|---|
|
||||
| `new` | Oui | Reçu, pas encore traité |
|
||||
| `in_progress` | Oui | Une tâche interne y est liée |
|
||||
| `done` | Oui | Résolu et clôturé |
|
||||
| `rejected` | Oui | Non retenu (avec commentaire explicatif) |
|
||||
|
||||
Le `statusComment` est visible par le client quand fourni.
|
||||
|
||||
## Côté équipe interne
|
||||
|
||||
- Les tickets apparaissent dans **Admin → Tickets client**
|
||||
- On peut **transformer un ticket en tâche** (la tâche garde une référence au ticket — icône 👤 bleue sur la card)
|
||||
- Le client voit l'avancement passer en `in_progress` automatiquement quand une tâche est liée
|
||||
66
frontend/content/help/07-admin.md
Normal file
66
frontend/content/help/07-admin.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Administration
|
||||
|
||||
> 🛡️ Section visible uniquement par les utilisateurs **ROLE_ADMIN**.
|
||||
|
||||
L'admin (`/admin`) est divisé en plusieurs onglets, chacun gérant une ressource globale ou une intégration.
|
||||
|
||||
## Onglet *Clients*
|
||||
|
||||
- Liste des clients (entreprise / organisation)
|
||||
- Champs : nom, email, téléphone, adresse
|
||||
- Lier un client à des projets
|
||||
|
||||
## Onglet *Workflows*
|
||||
|
||||
⭐ **Nouveau** — remplace l'ancien onglet *Statuts*.
|
||||
|
||||
- Lister les workflows existants
|
||||
- **Créer un workflow** : nom, isDefault (un seul à la fois), liste de statuts éditables inline
|
||||
- Chaque statut : libellé, couleur, position, **catégorie** (5 valeurs canoniques), isFinal
|
||||
- **Éditer** un workflow modifie les statuts (sync intelligent : create / update / delete par diff)
|
||||
|
||||
> ⚠️ Supprimer un workflow lié à un projet renvoie une erreur **409**. Réassigne d'abord les projets.
|
||||
|
||||
## Onglet *Efforts*
|
||||
|
||||
- Tailles d'effort (S, M, L, XL, XXL)
|
||||
- Globales (partagées entre tous les projets)
|
||||
|
||||
## Onglet *Priorités*
|
||||
|
||||
- Niveaux de priorité (Basse, Moyenne, Haute) + couleur
|
||||
- Une priorité "Haute" affiche un drapeau rouge `mdi:flag-variant` sur la card
|
||||
|
||||
## Onglet *Tags*
|
||||
|
||||
- Tags globaux (tâches **et** time entries)
|
||||
- Couleur personnalisable
|
||||
- Pas de hiérarchie (flat list)
|
||||
|
||||
## Onglet *Utilisateurs*
|
||||
|
||||
- Créer / éditer / désactiver
|
||||
- Rôles : `ROLE_ADMIN`, `ROLE_USER`, `ROLE_CLIENT`
|
||||
- **ROLE_CLIENT** : associer un *client* et une liste de *projets autorisés*
|
||||
- Reset password depuis l'admin
|
||||
|
||||
> 🔐 Un user *admin+client* (les deux rôles) **n'est pas bloqué** par le middleware portal — le check est sur `ROLE_CLIENT && !ROLE_ADMIN`.
|
||||
|
||||
## Onglet *Gitea*
|
||||
|
||||
- URL serveur + token API
|
||||
- Lier un projet à un repo : `giteaOwner` + `giteaRepo`
|
||||
- Active les fonctionnalités branches / PRs sur les tâches
|
||||
|
||||
## Onglet *BookStack*
|
||||
|
||||
- URL + token API
|
||||
- Lier un projet à un **shelf** BookStack (`bookstackShelfId`)
|
||||
- Les tâches peuvent être liées à des pages BookStack (cf. `TaskBookStackLink`)
|
||||
|
||||
## Onglet *Zimbra*
|
||||
|
||||
- URL serveur + credentials (chiffrés via libsodium)
|
||||
- Configure le calendrier CalDav par défaut
|
||||
- Test de connexion intégré
|
||||
- Active la **sync calendrier** sur les tâches planifiées
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user