Compare commits
21 Commits
v0.4.1
...
feat/absen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65df36dd1a | ||
|
|
f9773b3a5e | ||
|
|
e9aaccc62c | ||
|
|
4858f73d07 | ||
|
|
11fdf8d1bf | ||
|
|
2b148fa65a | ||
|
|
2a0b202d32 | ||
|
|
de98924fd3 | ||
|
|
325a7b07f9 | ||
|
|
bcbc04325e | ||
|
|
8f2a688740 | ||
|
|
6491943930 | ||
|
|
a9f05fd819 | ||
|
|
925be5d181 | ||
|
|
5da165f739 | ||
|
|
2bffff9b83 | ||
| d7af8ee138 | |||
|
|
eb2adc9fdc | ||
|
|
4775cbf184 | ||
|
|
8be96bce0c | ||
|
|
fb97b8d4e3 |
25
CLAUDE.md
25
CLAUDE.md
@@ -14,10 +14,10 @@ Application de gestion de projet. Monorepo Symfony 8 (API Platform 4) + Nuxt 4.
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration, ClientTicket, Notification, TaskDocument, BookStackConfiguration, TaskBookStackLink, TaskRecurrence, ZimbraConfiguration)
|
||||
src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration, Notification, TaskDocument, BookStackConfiguration, TaskBookStackLink, TaskRecurrence, ZimbraConfiguration)
|
||||
src/ApiResource/ # Ressources API Platform (si découplées des entités) (ZimbraSettings, ZimbraTestConnection)
|
||||
src/Enum/ # PHP enums (RecurrenceType)
|
||||
src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor, ClientTicket*Provider/Processor, NotificationProvider, Gitea*Provider, Gitea*Processor, ZimbraSettingsProvider/Processor, ZimbraTestConnectionProvider, TaskCalendarProcessor, RecurrenceHandler)
|
||||
src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor, NotificationProvider, Gitea*Provider, Gitea*Processor, ZimbraSettingsProvider/Processor, ZimbraTestConnectionProvider, TaskCalendarProcessor, RecurrenceHandler)
|
||||
src/Service/ # Services métier (NotificationService, CalDavService, RecurrenceCalculator)
|
||||
src/Controller/ # Controllers custom Symfony (NotificationUnreadCountController, MarkAllReadController, UserAvatarController, TaskDocumentDownloadController)
|
||||
src/Mcp/Tool/ # MCP tools organisés par domaine (Project/, Task/, TaskMeta/, TimeEntry/, Reference/)
|
||||
@@ -31,12 +31,12 @@ migrations/ # Migrations Doctrine
|
||||
docs/plans/ # Plans d'implémentation
|
||||
docs/superpowers/ # Plans et specs superpowers
|
||||
frontend/ # App Nuxt 4
|
||||
frontend/pages/ # Pages (index, login, my-tasks, profile, projects, projects/[id], projects/[id]/groups, projects/[id]/archives, time-tracking, admin, portal/, portal/projects/[id], portal/projects/[id]/new-ticket)
|
||||
frontend/layouts/ # Layouts (default, portal)
|
||||
frontend/components/ # Composants Vue organisés en sous-dossiers (ui/, client/, project/, task/, user/, admin/, time-tracking/, client-ticket/, notification/) — inclut admin/AdminZimbraTab
|
||||
frontend/composables/# Composables (useApi, useAppVersion, useNotifications, useClientTicketHelpers, useAvatarService)
|
||||
frontend/pages/ # Pages (index, login, my-tasks, profile, projects, projects/[id], projects/[id]/groups, projects/[id]/archives, time-tracking, admin)
|
||||
frontend/layouts/ # Layouts (default)
|
||||
frontend/components/ # Composants Vue organisés en sous-dossiers (ui/, client/, project/, task/, user/, admin/, time-tracking/, notification/) — inclut admin/AdminZimbraTab
|
||||
frontend/composables/# Composables (useApi, useAppVersion, useNotifications, useAvatarService)
|
||||
frontend/stores/ # Stores Pinia (auth, ui, timer)
|
||||
frontend/services/ # Services API (auth, clients, gitea, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries, client-tickets, notifications, task-documents, zimbra, task-recurrences)
|
||||
frontend/services/ # Services API (auth, clients, gitea, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries, notifications, task-documents, zimbra, task-recurrences)
|
||||
frontend/services/dto/ # Types TypeScript
|
||||
frontend/i18n/locales/ # Fichiers de traduction (langDir résolu depuis i18n/)
|
||||
```
|
||||
@@ -85,13 +85,13 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
||||
- Routes API préfixées `/api` (via `config/routes/api_platform.yaml`)
|
||||
- Le login (`/login_check`) est hors prefix `/api`, nginx réécrit `REQUEST_URI` vers `/login_check`
|
||||
- PHP CS Fixer : règles Symfony + PSR-12 + strict types
|
||||
- Rôles : `ROLE_ADMIN`, `ROLE_USER`, `ROLE_CLIENT` — hiérarchie dans `security.yaml`
|
||||
- `User::getRoles()` n'ajoute PAS `ROLE_USER` si l'user a `ROLE_CLIENT` (isolation)
|
||||
- Rôles : `ROLE_ADMIN`, `ROLE_USER` — hiérarchie dans `security.yaml`
|
||||
- `User::getRoles()` ajoute toujours `ROLE_USER`
|
||||
- PostgreSQL : `LIKE` sur colonne JSON ne marche pas → utiliser `roles::text LIKE` via native SQL
|
||||
- Controllers custom sous `/api/` : ajouter `priority: 1` sur `#[Route]` pour éviter le conflit avec API Platform `{id}`
|
||||
- Serialization : pour embarquer une relation (pas IRI), ajouter le groupe du parent aux propriétés de l'entité cible
|
||||
- Upload fichiers : utiliser `$file->getMimeType()` (pas `getClientMimeType()`) pour valider côté serveur — nécessite `symfony/mime`
|
||||
- Auth endpoints mixtes (ROLE_USER + ROLE_CLIENT) : utiliser `#[IsGranted('IS_AUTHENTICATED_FULLY')]` au lieu d'un rôle spécifique
|
||||
- Endpoints ouverts à tout utilisateur authentifié : utiliser `#[IsGranted('IS_AUTHENTICATED_FULLY')]` au lieu d'un rôle spécifique
|
||||
|
||||
### Frontend
|
||||
|
||||
@@ -101,9 +101,7 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
||||
- Middleware global `auth.global.ts` protège les routes
|
||||
- Traductions dans `frontend/i18n/locales/` (le module résout `langDir` depuis `i18n/`)
|
||||
- 4 espaces d'indentation
|
||||
- MalioSelect : options `{ label: string, value: number | null }` uniquement — pas de string values, utiliser `<select>` natif pour les enums string
|
||||
- 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
|
||||
- MalioSelect : options `{ label: string, value: string | number | null }` — accepte les valeurs **string** (enums string OK, ex `category`/`StatusCategory`), pas seulement `number` (vérifié dans la source `Select.vue` : `modelValue: string | number | null`). L'option vide `null` n'est ajoutée que si `empty-option-label` est passé (ne pas le passer pour un champ requis). Largeur via `group-class` (pas de prop `minWidth`/`min-width`). ⚠️ Le `COMPONENTS.md` de la lib est inexact sur ce composant (il indique une clé `text` et une prop `minWidth` inexistantes) : la clé d'affichage réelle est `label`. Ne jamais modifier la lib `malio-layer-ui` depuis ce projet.
|
||||
|
||||
### Composants UI
|
||||
|
||||
@@ -138,7 +136,6 @@ La librairie `@malio/layer-ui` fournit les composants de formulaire et d'action.
|
||||
|
||||
- User admin : `admin` / `admin` (ROLE_ADMIN)
|
||||
- Users internes : `alice` / `alice`, `bob` / `bob`, `charlie` / `charlie` (ROLE_USER)
|
||||
- Users client : `client-liot` / `client` (ROLE_CLIENT, client LIOT → SIRH), `client-acme` / `client` (ROLE_CLIENT, client ACME → CRM)
|
||||
- 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)
|
||||
|
||||
43
README.md
43
README.md
@@ -45,6 +45,10 @@ make install
|
||||
|
||||
L'application est accessible sur **http://localhost:8082**.
|
||||
|
||||
Les valeurs par défaut du `.env` committé suffisent pour démarrer en local. Pour la prod
|
||||
(et pour activer la messagerie), surcharger les variables sensibles dans `.env.local` —
|
||||
voir « Variables d'environnement » ci-dessous.
|
||||
|
||||
### Comptes de test (fixtures)
|
||||
|
||||
| Utilisateur | Mot de passe | Rôle | Détails |
|
||||
@@ -56,6 +60,25 @@ L'application est accessible sur **http://localhost:8082**.
|
||||
| `client-liot` | `client` | ROLE_CLIENT | Client LIOT (projet SIRH) |
|
||||
| `client-acme` | `client` | ROLE_CLIENT | Client ACME (projet CRM) |
|
||||
|
||||
## Variables d'environnement
|
||||
|
||||
Les variables sont définies dans `.env` (committé, valeurs par défaut pour le dev) et
|
||||
peuvent être surchargées dans `.env.local` (jamais committé). En prod, elles vont dans le
|
||||
`.env` du serveur (`/var/www/lesstime/.env`, voir `infra/prod/.env.example`).
|
||||
|
||||
| Variable | Rôle | Défaut dev | À fixer en prod |
|
||||
|----------|------|-----------|-----------------|
|
||||
| `APP_SECRET` | Secret Symfony | placeholder | ✅ (hex 32) |
|
||||
| `JWT_PASSPHRASE` | Passphrase des clés JWT | placeholder | ✅ |
|
||||
| `DATABASE_URL` | Connexion PostgreSQL | container `db` | ✅ (`host.docker.internal`) |
|
||||
| `CORS_ALLOW_ORIGIN` | Origines CORS autorisées | localhost | ✅ (domaine prod) |
|
||||
| **`ENCRYPTION_KEY`** | **Clé hex 32 bytes chiffrant les credentials IMAP/SMTP (feature mail)** | placeholder | ✅ — doit rester **stable**, sinon les credentials mail stockés deviennent illisibles |
|
||||
| **`LOCK_DSN`** | **Store de verrous Symfony pour la sync mail (anti-chevauchement)** | `flock` | `flock` suffit |
|
||||
|
||||
> **Messagerie** : `ENCRYPTION_KEY` et `LOCK_DSN` sont introduites par l'intégration mail.
|
||||
> Détails de config et cron de synchronisation : `docs/mail-integration.md` et `docs/mail-cron-setup.md`.
|
||||
> Générer une clé : `php -r "echo bin2hex(random_bytes(32));"`.
|
||||
|
||||
## Commandes
|
||||
|
||||
### Docker
|
||||
@@ -218,13 +241,19 @@ docker exec -u www-data php-lesstime-fpm php bin/console app:generate-api-token
|
||||
|
||||
## Déploiement
|
||||
|
||||
1. Déployer le code sur le serveur
|
||||
2. `composer install --no-dev --optimize-autoloader`
|
||||
3. `php bin/console doctrine:migrations:migrate --no-interaction`
|
||||
4. `php bin/console cache:clear --env=prod`
|
||||
5. `cd frontend && npm install && npm run build:dist`
|
||||
6. `docker restart nginx-lesstime`
|
||||
7. Ouvrir le port 8082 sur le firewall (LAN uniquement)
|
||||
La prod tourne en **Docker** : l'image est buildée par la CI Gitea sur push de tag `v*`
|
||||
(`gitea.malio.fr/malio-dev/lesstime:<tag>`), puis déployée par le script `deploy.sh` sur
|
||||
le serveur (dossier `/var/www/lesstime`, container `lesstime-app`).
|
||||
|
||||
```bash
|
||||
# Sur le serveur, depuis /var/www/lesstime
|
||||
sudo ./deploy.sh # déploie la dernière image (latest)
|
||||
sudo ./deploy.sh v0.4.2 # déploie une version précise
|
||||
```
|
||||
|
||||
Le script active la maintenance, pull l'image, redémarre le container, lance les migrations
|
||||
et vide le cache. Guide complet (première installation, BDD, Nginx, JWT, rollback) :
|
||||
**`doc/deployment-docker.md`**.
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
@@ -6,8 +6,14 @@ mcp:
|
||||
This server provides access to the Lesstime project management system.
|
||||
You can list/create/update/delete projects, tasks, and time entries.
|
||||
Tasks belong to projects and have statuses, priorities, efforts, tags, and groups.
|
||||
Statuses, priorities, efforts, and tags are GLOBAL (shared across all projects).
|
||||
Priorities, efforts, and tags are GLOBAL (shared across all projects).
|
||||
Statuses belong to a WORKFLOW (not global) — use list-workflows and list-statuses
|
||||
to see how they group; create-status requires a workflowId and a category.
|
||||
Groups are PER-PROJECT (each group belongs to one project).
|
||||
Absences: manage employee leave with absence-request tools (list/get/create/review/
|
||||
cancel/delete), absence-policy tools (list/update) and absence-balance tools
|
||||
(list/update). create-absence-request takes an explicit userId to act on behalf of
|
||||
an employee; review/cancel keep the leave balances consistent (pending/taken).
|
||||
Time entries track work duration and can be linked to projects and tasks.
|
||||
Use list-statuses, list-priorities, list-efforts, list-tags, list-groups to discover
|
||||
available metadata before creating or updating tasks.
|
||||
|
||||
@@ -18,7 +18,12 @@ framework:
|
||||
failed: 'doctrine://default?queue_name=failed&auto_setup=0'
|
||||
|
||||
routing:
|
||||
'App\Message\MailSyncRequested': async
|
||||
# Sync à la demande (bouton « rafraîchir ») : exécutée pendant la requête HTTP
|
||||
# pour que le re-fetch du front voie immédiatement les nouveaux mails, sans worker
|
||||
# messenger:consume à maintenir. La sync de fond reste assurée par le cron OS
|
||||
# (app:mail:sync, synchrone, indépendant du bus). Repasser à `async` + worker si
|
||||
# la boîte grossit au point que la sync à la demande approche le timeout PHP.
|
||||
'App\Message\MailSyncRequested': sync
|
||||
|
||||
when@test:
|
||||
framework:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
security:
|
||||
role_hierarchy:
|
||||
ROLE_ADMIN: [ROLE_USER, ROLE_CLIENT]
|
||||
ROLE_ADMIN: [ROLE_USER]
|
||||
|
||||
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
|
||||
password_hashers:
|
||||
@@ -64,7 +64,7 @@ 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)
|
||||
# Mail : requiert authentification (le check ROLE_USER est dans MailAccessChecker)
|
||||
- { path: ^/api/mail, roles: IS_AUTHENTICATED_FULLY }
|
||||
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
|
||||
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
parameters:
|
||||
task_document_upload_dir: '%kernel.project_dir%/var/uploads/documents'
|
||||
avatar_upload_dir: '%kernel.project_dir%/var/uploads/avatars'
|
||||
absence_justification_upload_dir: '%kernel.project_dir%/var/uploads/justificatifs'
|
||||
# Reference collective agreement for the absence module's legal defaults.
|
||||
# To confirm against the company's APE/NAF code (a CCN is not derived from activity alone).
|
||||
app.absence.convention: 'Syntec (IDCC 1486)'
|
||||
|
||||
imports:
|
||||
- { resource: version.yaml }
|
||||
@@ -44,3 +48,11 @@ services:
|
||||
App\Controller\UserAvatarController:
|
||||
arguments:
|
||||
$avatarUploadDir: '%avatar_upload_dir%'
|
||||
|
||||
App\Controller\Absence\AbsenceJustificationUploadController:
|
||||
arguments:
|
||||
$uploadDir: '%absence_justification_upload_dir%'
|
||||
|
||||
App\Controller\Absence\AbsenceJustificationDownloadController:
|
||||
arguments:
|
||||
$uploadDir: '%absence_justification_upload_dir%'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.4.1'
|
||||
app.version: '0.4.7'
|
||||
|
||||
@@ -3,44 +3,24 @@
|
||||
## 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.
|
||||
Elle appelle la commande Symfony `app:mail:sync` qui s'exécute **dans le container PHP**.
|
||||
|
||||
Un Symfony Lock (`mail.sync`, TTL 10 min, store `flock` via `LOCK_DSN=flock`) empêche
|
||||
les runs de se chevaucher si une sync prend plus de 10 min.
|
||||
|
||||
> **Dev vs prod** — en dev le container s'appelle `php-lesstime-fpm` et on passe par `make`.
|
||||
> En **production** le container s'appelle `lesstime-app` (service `app` du `docker-compose.yml`
|
||||
> dans `/var/www/lesstime`), il n'y a **pas de `make`** : tout passe par `docker compose` / `docker exec`.
|
||||
|
||||
## Prérequis
|
||||
|
||||
- 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
|
||||
```
|
||||
- `MailConfiguration.enabled = true` (configurable depuis l'admin — onglet « Mail »)
|
||||
- `ENCRYPTION_KEY` (clé hex 32 bytes) défini dans l'environnement :
|
||||
- **dev** : `infra/dev/.env.docker.local`
|
||||
- **prod** : `/var/www/lesstime/.env`
|
||||
- Container démarré :
|
||||
- **dev** : `make start` (container `php-lesstime-fpm`)
|
||||
- **prod** : déployé via `sudo ./deploy.sh` (container `lesstime-app`)
|
||||
|
||||
## Variables d'environnement nécessaires
|
||||
|
||||
@@ -49,63 +29,100 @@ sudo crontab -u deploy -e
|
||||
| `ENCRYPTION_KEY` | Clé hex 32 bytes pour déchiffrer le password IMAP | `$(php -r "echo bin2hex(random_bytes(32));")` |
|
||||
| `LOCK_DSN` | DSN du store de verrous Symfony | `flock` (défaut, fichier local) |
|
||||
|
||||
La clé doit être la même que celle utilisée pour chiffrer le password lors de la configuration.
|
||||
La clé `ENCRYPTION_KEY` doit être **identique** à celle utilisée pour chiffrer le password
|
||||
lors de la configuration depuis l'admin. Si elle change, les credentials stockés deviennent illisibles.
|
||||
|
||||
## Checklist setup production
|
||||
---
|
||||
|
||||
1. [ ] Définir `ENCRYPTION_KEY` dans les variables d'environnement production
|
||||
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`)
|
||||
## Dev
|
||||
|
||||
## Commandes utiles
|
||||
### Lancer une sync à la main
|
||||
|
||||
```bash
|
||||
# Sync complète (toutes les boîtes)
|
||||
make mail-sync
|
||||
make mail-sync # sync complète (toutes les boîtes)
|
||||
make mail-sync FOLDER=INBOX # un seul dossier (doit déjà exister en base)
|
||||
make mail-sync DRYRUN=1 # simulation (dry-run, pas d'écriture BDD)
|
||||
```
|
||||
|
||||
# Sync d'un seul dossier (le dossier doit déjà exister en base)
|
||||
make mail-sync FOLDER=INBOX
|
||||
Ou directement dans le container :
|
||||
|
||||
# Simulation (dry-run, pas d'écriture BDD)
|
||||
make mail-sync DRYRUN=1
|
||||
|
||||
# Directement dans le container
|
||||
```bash
|
||||
docker exec php-lesstime-fpm php bin/console app:mail:sync
|
||||
docker exec php-lesstime-fpm php bin/console app:mail:sync --folder=INBOX
|
||||
docker exec php-lesstime-fpm php bin/console app:mail:sync --dry-run
|
||||
```
|
||||
|
||||
## Logs
|
||||
|
||||
Les logs Symfony sont dans `var/log/dev.log` (ou `prod.log` en production).
|
||||
Suivre les logs en temps réel :
|
||||
### Logs (dev)
|
||||
|
||||
```bash
|
||||
make logs-dev
|
||||
make logs-dev # tail -f var/log/dev.log
|
||||
```
|
||||
|
||||
Les messages loggés par `MailSyncService` sont préfixés `mail.sync`.
|
||||
|
||||
---
|
||||
|
||||
## Production
|
||||
|
||||
En prod, l'app tourne dans le container `lesstime-app` déployé par `sudo ./deploy.sh`
|
||||
(dossier `/var/www/lesstime`). La commande s'exécute en tant que `www-data` (uid 33),
|
||||
comme les migrations lancées par `deploy.sh`.
|
||||
|
||||
### Lancer une sync à la main
|
||||
|
||||
Depuis `/var/www/lesstime` :
|
||||
|
||||
```bash
|
||||
sudo docker compose exec -T -u www-data app php bin/console app:mail:sync
|
||||
sudo docker compose exec -T -u www-data app php bin/console app:mail:sync --folder=INBOX
|
||||
sudo docker compose exec -T -u www-data app php bin/console app:mail:sync --dry-run
|
||||
```
|
||||
|
||||
### Installer le cron
|
||||
|
||||
Sur la **machine hôte** (pas dans le container). Comme `docker` requiert `sudo` en prod,
|
||||
installer le cron sous root :
|
||||
|
||||
```bash
|
||||
sudo crontab -e
|
||||
```
|
||||
|
||||
Ajouter :
|
||||
|
||||
```cron
|
||||
*/10 * * * * cd /var/www/lesstime && docker compose exec -T -u www-data app php bin/console app:mail:sync >> /var/log/lesstime-mail-sync.log 2>&1
|
||||
```
|
||||
|
||||
> Le crontab de root exécute déjà les commandes en root → pas de `sudo` à l'intérieur de la ligne cron.
|
||||
> La commande est **idempotente** (UIDs uniques en base) : la relancer ne duplique pas les données.
|
||||
|
||||
### Logs (prod)
|
||||
|
||||
```bash
|
||||
cd /var/www/lesstime
|
||||
docker compose logs -f --tail=100 app # logs container
|
||||
docker compose exec app cat var/log/prod.log # log Symfony (volume lesstime_logs)
|
||||
```
|
||||
|
||||
### Checklist setup production
|
||||
|
||||
1. [ ] Définir `ENCRYPTION_KEY` (hex 32 bytes) et `LOCK_DSN=flock` dans `/var/www/lesstime/.env`
|
||||
2. [ ] Créer le compte mail dédié (ex: `lesstime@votre-domaine.fr`) chez OVH
|
||||
3. [ ] Accéder à `/admin` → onglet « Mail » → renseigner les credentials IMAP/SMTP
|
||||
4. [ ] Cliquer « Tester la connexion » → vérifier le succès
|
||||
5. [ ] Cocher « Activer la synchronisation » → Enregistrer
|
||||
6. [ ] Lancer une sync manuelle pour valider (commande ci-dessus)
|
||||
7. [ ] Installer le cron OS (voir « Installer le cron »)
|
||||
8. [ ] Vérifier les logs après la première sync (`docker compose logs -f app`, chercher `mail.sync`)
|
||||
|
||||
---
|
||||
|
||||
## Sécurité
|
||||
|
||||
- Le password IMAP est **toujours stocké chiffré** (libsodium secretbox)
|
||||
- Les corps de mails, passwords et pièces jointes ne sont **jamais loggés**
|
||||
- Le lock `flock` évite les runs parallèles (fichier dans `/tmp/sf.mail.sync.<hash>.lock`)
|
||||
|
||||
## 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).
|
||||
- Le sidebar « Messagerie » est masqué pour les utilisateurs `ROLE_CLIENT` sans `ROLE_USER`
|
||||
- Les corps de mails sont sanitisés via DOMPurify avant affichage (`frontend/utils/sanitizeMailHtml.ts`)
|
||||
- Les pixels de tracking distants sont remplacés par un placeholder
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
> 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)
|
||||
> docker exec -i php-lesstime-fpm php bin/console app:mail:redecode-headers [--dry-run] # re-décode les en-têtes MIME déjà en base (backfill)
|
||||
> make test # 33 tests
|
||||
> ```
|
||||
> Fixtures `make fixtures` plantent sur un état legacy `workflow_id` (hors-scope mail) — configurer la boîte via l'UI admin.
|
||||
@@ -45,6 +46,8 @@
|
||||
- Navigation par dossiers (arbre récursif avec compteurs non-lus)
|
||||
- Liste paginée des messages (infinite scroll, cursor-based)
|
||||
- Lecture des corps de mail sanitisés (DOMPurify — protection XSS + pixels tracking)
|
||||
- Décodage des en-têtes MIME encodés (RFC 2047, ex `=?UTF-8?Q?...`) sur sujet + nom d'expéditeur (`App\Mail\MimeHeaderDecoder`, appliqué dans `ImapMailProvider`)
|
||||
- Aperçu inline des pièces jointes images + PDF (visionneuse modale plein écran), téléchargement pour les autres types
|
||||
- Création d'une tâche Lesstime depuis un mail (sujet → titre, texte → description)
|
||||
- Lien mail ↔ tâche (bidirectionnel)
|
||||
- Onglet "Mails" dans le TaskDrawer pour retrouver les mails liés à une tâche
|
||||
@@ -107,7 +110,7 @@ Tous les endpoints `/api/mail/*` refusent explicitement `ROLE_CLIENT`.
|
||||
|
||||
### Frontend
|
||||
- `frontend/pages/mail.vue` — page principale 3 colonnes
|
||||
- `frontend/components/mail/` — MailFolderTree, MailMessageList, MailMessageViewer, MailRefreshButton
|
||||
- `frontend/components/mail/` — MailFolderTree, MailMessageList, MailMessageViewer, MailRefreshButton, MailAttachmentPreview (visionneuse modale image/PDF)
|
||||
- `frontend/components/admin/AdminMailTab.vue` — onglet config admin
|
||||
- `frontend/stores/mail.ts` — store Pinia (folders, messages, polling)
|
||||
- `frontend/services/mail.ts` — service API (toutes les méthodes)
|
||||
|
||||
988
docs/superpowers/plans/2026-05-21-workflow-ui-fixes.md
Normal file
988
docs/superpowers/plans/2026-05-21-workflow-ui-fixes.md
Normal file
@@ -0,0 +1,988 @@
|
||||
# Correctifs UI workflow — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommandé) ou superpowers:executing-plans pour exécuter ce plan tâche par tâche. Les étapes utilisent la syntaxe checkbox (`- [ ]`).
|
||||
|
||||
**Goal:** Corriger les régressions UI introduites par les workflows (D&D, sélecteur de statut, cartes, couleurs) et améliorer l'UX mail/modales, sur la base de `docs/superpowers/specs/2026-05-20-workflow-ui-fixes-design.md`.
|
||||
|
||||
**Architecture:** Une brique partagée (filtrage des statuts par workflow + palette de catégories + composant modale réutilisable) consommée par les autres chantiers. Backend modifié uniquement pour l'endpoint `create-task` (#6). Correction de données prod (#4) via migration Doctrine.
|
||||
|
||||
**Tech Stack:** Symfony 8 / API Platform 4 / Doctrine (backend, PHPUnit) ; Nuxt 4 / Vue 3 / Pinia / Tailwind / `@malio/layer-ui` (frontend).
|
||||
|
||||
> **Note testing (importante).** Lesstime **n'a pas de test runner frontend** (vérifié : pas de vitest/jest dans `frontend/package.json`). La discipline TDD ne s'applique donc qu'au **backend** (PHPUnit via `make test`). Pour le **frontend**, chaque tâche se vérifie par : (1) `npm run build:dist` qui doit réussir (exit 0), puis (2) contrôle navigateur via Chrome DevTools MCP sur `http://localhost:8082` (DOM/visuel). **Toujours hard-reload sans cache** après build (le navigateur cache les chunks JS hashés). Login dev avec données prod importées : `Matthieu` / `admin`.
|
||||
|
||||
> **Branche.** Créer une branche d'implémentation depuis `develop` (ex. `fix/workflow-ui-fixes`) avant de commencer. Commits fréquents, format `<type>(<scope>) : <message>`.
|
||||
|
||||
---
|
||||
|
||||
## Ordre d'exécution (dépendances)
|
||||
|
||||
1. **Task 1** — Brique front : palette de catégories + helper contraste (`#4b`, réutilisé par #1)
|
||||
2. **Task 2** — Composant `AppModal` réutilisable (`#7`)
|
||||
3. **Task 3** — Filtrage du sélecteur de statut par workflow dans TaskModal (`#2`)
|
||||
4. **Task 4** — Drag & drop dans « Mes tâches » + entêtes teintées (`#1` + `#4b`)
|
||||
5. **Task 5** — Backend : endpoint `create-task` (statut + assigné, sans priorité) (`#6` back)
|
||||
6. **Task 6** — Frontend : modale de création depuis mail (`#6` front, sur AppModal)
|
||||
7. **Task 7** — Suppression du bouton « Lier un mail » (`#5`)
|
||||
8. **Task 8** — Cartes responsive (`#3`)
|
||||
9. **Task 9** — Couleurs par défaut par catégorie + migration data prod (`#4a` + `#4c`)
|
||||
10. **Task 10** — Migration de TaskModal vers AppModal (`#7`)
|
||||
|
||||
---
|
||||
|
||||
## Task 1 : Palette de catégories + helper de contraste
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/services/dto/workflow.ts`
|
||||
|
||||
- [ ] **Step 1 : Ajouter la palette et le helper de contraste**
|
||||
|
||||
Dans `frontend/services/dto/workflow.ts`, après `STATUS_CATEGORY_LABEL` (l.5-11), ajouter :
|
||||
|
||||
```ts
|
||||
/** Palette canonique des catégories (couleurs « classiques »), indépendante des workflows. */
|
||||
export const STATUS_CATEGORY_COLOR: Record<StatusCategory, string> = {
|
||||
todo: '#222783',
|
||||
in_progress: '#4A90D9',
|
||||
blocked: '#C62828',
|
||||
review: '#FF8F00',
|
||||
done: '#26A69A',
|
||||
}
|
||||
|
||||
/** Renvoie '#1f2937' (foncé) ou '#ffffff' (blanc) selon la luminance du fond, pour rester lisible. */
|
||||
export function contrastText(hex: string): string {
|
||||
const c = hex.replace('#', '')
|
||||
const r = parseInt(c.slice(0, 2), 16)
|
||||
const g = parseInt(c.slice(2, 4), 16)
|
||||
const b = parseInt(c.slice(4, 6), 16)
|
||||
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
return lum > 0.6 ? '#1f2937' : '#ffffff'
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Vérifier le build**
|
||||
|
||||
Run: `cd frontend && npm run build:dist`
|
||||
Expected: exit 0, aucune erreur TypeScript.
|
||||
|
||||
- [ ] **Step 3 : Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/services/dto/workflow.ts
|
||||
git commit -m "feat(workflow) : palette de catégories canonique + helper de contraste"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2 : Composant modale réutilisable `AppModal` (#7)
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/components/ui/AppModal.vue`
|
||||
|
||||
- [ ] **Step 1 : Créer `AppModal.vue`**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: boolean
|
||||
title?: string
|
||||
/** Largeur max du panneau */
|
||||
width?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
}>(), {
|
||||
title: '',
|
||||
width: 'md',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const WIDTH_CLASS: Record<NonNullable<typeof props.width>, string> = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport v-if="modelValue" to="body">
|
||||
<Transition name="app-modal" appear>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm" @click="close" />
|
||||
|
||||
<div
|
||||
class="relative z-10 flex max-h-[90vh] w-full flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5"
|
||||
:class="WIDTH_CLASS[width]"
|
||||
>
|
||||
<!-- Header (fixe) -->
|
||||
<div class="flex shrink-0 items-center justify-between border-b border-neutral-100 bg-neutral-50/80 px-6 py-4">
|
||||
<h2 class="text-base font-bold text-neutral-900">
|
||||
<slot name="title">{{ title }}</slot>
|
||||
</h2>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:close"
|
||||
aria-label="Fermer"
|
||||
variant="ghost"
|
||||
icon-size="20"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Body (scrollable) -->
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- Footer (sticky) -->
|
||||
<div
|
||||
v-if="$slots.footer"
|
||||
class="flex shrink-0 justify-end gap-3 border-t border-neutral-100 bg-white px-6 py-4"
|
||||
>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-modal-enter-active,
|
||||
.app-modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.app-modal-enter-active > div:last-child,
|
||||
.app-modal-leave-active > div:last-child {
|
||||
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
|
||||
}
|
||||
.app-modal-enter-from,
|
||||
.app-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
.app-modal-enter-from > div:last-child {
|
||||
transform: scale(0.95) translateY(8px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Build**
|
||||
|
||||
Run: `cd frontend && npm run build:dist`
|
||||
Expected: exit 0.
|
||||
|
||||
- [ ] **Step 3 : Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/components/ui/AppModal.vue
|
||||
git commit -m "feat(ui) : composant AppModal réutilisable (header fixe / body scrollable / footer sticky)"
|
||||
```
|
||||
|
||||
> AppModal sera consommé par MailCreateTaskModal (Task 6) et TaskModal (Task 10).
|
||||
|
||||
---
|
||||
|
||||
## Task 3 : Filtrer le sélecteur de statut par workflow dans TaskModal (#2)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/components/task/TaskModal.vue` (statusOptions ~l.674-676)
|
||||
|
||||
**Contexte vérifié :** TaskModal reçoit déjà `:projects` (`Project[]` avec `workflow.statuses`). Le projet effectif est `showProjectSelect ? form.projectId : props.projectId` (cf. l.717). `props.statuses` (global) devient un fallback.
|
||||
|
||||
- [ ] **Step 1 : Remplacer `statusOptions`**
|
||||
|
||||
Remplacer (l.674-676) :
|
||||
|
||||
```ts
|
||||
const statusOptions = computed(() =>
|
||||
props.statuses.map(s => ({ label: s.label, value: s.id }))
|
||||
)
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```ts
|
||||
const effectiveProjectId = computed(() =>
|
||||
showProjectSelect.value ? form.projectId : props.projectId,
|
||||
)
|
||||
|
||||
const statusOptions = computed(() => {
|
||||
const project = props.projects?.find(p => p.id === effectiveProjectId.value)
|
||||
const wfStatuses = project?.workflow?.statuses ?? props.statuses
|
||||
const opts = wfStatuses.map(s => ({ label: s.label, value: s.id }))
|
||||
// Garder le statut courant s'il n'appartient pas (plus) au workflow, pour ne pas le perdre.
|
||||
const current = props.task?.status
|
||||
if (current && !wfStatuses.some(s => s.id === current.id)) {
|
||||
opts.unshift({ label: current.label, value: current.id })
|
||||
}
|
||||
return opts
|
||||
})
|
||||
```
|
||||
|
||||
> Si une variable `effectiveProjectId`/`activeProjectId` existe déjà (vérifier autour de l.717), réutiliser celle-ci au lieu d'en redéclarer une.
|
||||
|
||||
- [ ] **Step 2 : Build**
|
||||
|
||||
Run: `cd frontend && npm run build:dist`
|
||||
Expected: exit 0.
|
||||
|
||||
- [ ] **Step 3 : Vérification navigateur (Chrome MCP)**
|
||||
|
||||
1. Hard-reload `http://localhost:8082` (cache ignoré), login `Matthieu`/`admin`.
|
||||
2. Ouvrir une tâche d'un projet **Standard** (ex. `LST-49` via « Mes tâches »).
|
||||
3. Ouvrir le sélecteur « Statut ».
|
||||
Expected : **5 options** (les statuts du workflow Standard) — plus aucun statut ERP (« Prêt à dev », « En dev », « Mergé », « Validation client », « Validé prod », « Abandonné »).
|
||||
4. Ouvrir une tâche du projet **STARSEED** (workflow ERP, code `ERP-…`).
|
||||
Expected : uniquement les statuts ERP.
|
||||
|
||||
- [ ] **Step 4 : Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/components/task/TaskModal.vue
|
||||
git commit -m "fix(task) : sélecteur de statut filtré par le workflow du projet"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4 : Drag & drop « Mes tâches » + entêtes teintées (#1 + #4b)
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/components/task/StatusPickerPopover.vue`
|
||||
- Modify: `frontend/pages/my-tasks.vue` (template kanban ~l.394-424 ; script ~l.118-140)
|
||||
- Modify: `frontend/services/tasks.ts` (réutiliser `update()` existant)
|
||||
|
||||
**Contexte vérifié :** `TaskCard.vue` pose déjà `dataTransfer.setData('text/plain', task.id)` au `dragstart`. `my-tasks.vue` n'a **aucun** handler `@drop`/`@dragover`. Les colonnes itèrent sur `CATEGORIES` (l.119). `tasks.value` contient les tâches affichées. `tasks.ts` expose `update(id, payload: Partial<TaskWrite>)` → `PATCH /tasks/{id}` ; le statut s'écrit en IRI (`status: '/api/task_statuses/{id}'`, cf. TaskModal l.1070).
|
||||
|
||||
- [ ] **Step 1 : Créer le popover de désambiguïsation**
|
||||
|
||||
`frontend/components/task/StatusPickerPopover.vue` :
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
|
||||
defineProps<{
|
||||
statuses: TaskStatus[]
|
||||
x: number
|
||||
y: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
pick: [status: TaskStatus]
|
||||
cancel: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed inset-0 z-[60]" @click="emit('cancel')" />
|
||||
<div
|
||||
class="fixed z-[61] min-w-44 rounded-lg border border-neutral-200 bg-white py-1 shadow-xl"
|
||||
:style="{ left: x + 'px', top: y + 'px' }"
|
||||
>
|
||||
<button
|
||||
v-for="s in statuses"
|
||||
:key="s.id"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-neutral-50"
|
||||
@click="emit('pick', s)"
|
||||
>
|
||||
<span class="h-3 w-3 shrink-0 rounded-full" :style="{ backgroundColor: s.color }" />
|
||||
{{ s.label }}
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Ajouter la logique de drop dans `my-tasks.vue` (script)**
|
||||
|
||||
Dans `<script setup>`, ajouter les imports et l'état (près des helpers kanban, l.118+) :
|
||||
|
||||
```ts
|
||||
import { STATUS_CATEGORY_COLOR, contrastText } from '~/services/dto/workflow'
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
|
||||
const dragOverCategory = ref<StatusCategory | null>(null)
|
||||
const pendingPicker = ref<{ statuses: TaskStatus[], task: Task, x: number, y: number } | null>(null)
|
||||
|
||||
function statusesForTaskCategory(task: Task, category: StatusCategory): TaskStatus[] {
|
||||
const wf = task.project?.workflow
|
||||
if (!wf) return []
|
||||
return wf.statuses.filter(s => s.category === category)
|
||||
}
|
||||
|
||||
async function applyStatus(task: Task, status: TaskStatus): Promise<void> {
|
||||
await taskService.update(task.id, { status: `/api/task_statuses/${status.id}` })
|
||||
await loadTasks() // recharge la liste (utiliser la fonction de rechargement existante)
|
||||
}
|
||||
|
||||
function onDrop(category: StatusCategory, event: DragEvent): void {
|
||||
dragOverCategory.value = null
|
||||
const taskId = Number(event.dataTransfer?.getData('text/plain'))
|
||||
const task = tasks.value.find(t => t.id === taskId)
|
||||
if (!task) return
|
||||
|
||||
const candidates = statusesForTaskCategory(task, category)
|
||||
if (candidates.length === 0) {
|
||||
toast.error(t('myTasks.dropRefused')) // 0 statut dans cette catégorie pour ce workflow
|
||||
return
|
||||
}
|
||||
if (candidates.length === 1) {
|
||||
void applyStatus(task, candidates[0])
|
||||
return
|
||||
}
|
||||
// ≥2 : popover de choix ancré au point de drop
|
||||
pendingPicker.value = { statuses: candidates, task, x: event.clientX, y: event.clientY }
|
||||
}
|
||||
|
||||
function onPickerChoice(status: TaskStatus): void {
|
||||
if (pendingPicker.value) void applyStatus(pendingPicker.value.task, status)
|
||||
pendingPicker.value = null
|
||||
}
|
||||
```
|
||||
|
||||
> Adapter `loadTasks()` / `toast` / `t` aux noms réels du fichier (vérifier la fonction de rechargement des tâches et l'import du toast déjà utilisés dans `my-tasks.vue`).
|
||||
|
||||
- [ ] **Step 3 : Brancher le template kanban (#1) + entêtes teintées (#4b)**
|
||||
|
||||
Remplacer le bloc colonne (l.397-404) par :
|
||||
|
||||
```vue
|
||||
<div
|
||||
v-for="cat in CATEGORIES"
|
||||
:key="cat"
|
||||
class="flex min-w-40 flex-1 flex-col rounded-lg bg-neutral-50 transition"
|
||||
:class="dragOverCategory === cat ? 'ring-2 ring-primary-400' : ''"
|
||||
@dragover.prevent="dragOverCategory = cat"
|
||||
@dragleave="dragOverCategory = null"
|
||||
@drop="onDrop(cat, $event)"
|
||||
>
|
||||
<div
|
||||
class="shrink-0 rounded-t-lg px-4 py-3 text-sm font-bold"
|
||||
:style="{ backgroundColor: STATUS_CATEGORY_COLOR[cat], color: contrastText(STATUS_CATEGORY_COLOR[cat]) }"
|
||||
>
|
||||
{{ STATUS_CATEGORY_LABEL[cat] }} ({{ tasksByCategory(cat).length }})
|
||||
</div>
|
||||
```
|
||||
|
||||
Puis, juste avant la fermeture du `<template>` (à côté de la TaskModal), ajouter le popover :
|
||||
|
||||
```vue
|
||||
<StatusPickerPopover
|
||||
v-if="pendingPicker"
|
||||
:statuses="pendingPicker.statuses"
|
||||
:x="pendingPicker.x"
|
||||
:y="pendingPicker.y"
|
||||
@pick="onPickerChoice"
|
||||
@cancel="pendingPicker = null"
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Ajouter la clé i18n `myTasks.dropRefused`**
|
||||
|
||||
Dans `frontend/i18n/locales/fr.json` (et les autres locales présentes), sous `myTasks` : `"dropRefused": "Aucun statut de cette colonne dans le workflow de ce projet"`.
|
||||
|
||||
- [ ] **Step 5 : Build**
|
||||
|
||||
Run: `cd frontend && npm run build:dist`
|
||||
Expected: exit 0.
|
||||
|
||||
- [ ] **Step 6 : Vérification navigateur (Chrome MCP)**
|
||||
|
||||
1. Hard-reload, login, aller à « Mes tâches » (vue kanban).
|
||||
Expected : entêtes de colonnes **colorées** (todo indigo, in_progress bleu, blocked rouge, review ambre **texte foncé**, done sarcelle).
|
||||
2. Glisser une carte d'un projet **Standard** de « À faire » vers « En cours ».
|
||||
Expected : le statut passe à « En cours » (1 seul statut in_progress → direct), la carte se déplace.
|
||||
3. Glisser une carte du projet **STARSEED** (workflow ERP) vers « En validation » (la catégorie `review` a ≥2 statuts ERP : En review, Mergé, Validation client).
|
||||
Expected : **popover** au point de drop listant ces statuts ; le choix applique le statut.
|
||||
|
||||
- [ ] **Step 7 : Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/components/task/StatusPickerPopover.vue frontend/pages/my-tasks.vue frontend/i18n/locales/
|
||||
git commit -m "fix(my-tasks) : drag & drop par workflow (popover si ambigu) + entêtes de colonnes teintées"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5 : Backend `create-task` — statut + assigné, sans priorité (#6 back)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Controller/Mail/MailCreateTaskController.php`
|
||||
- Test: `tests/Functional/Controller/Mail/MailTaskIntegrationControllerTest.php`
|
||||
|
||||
**Contexte vérifié :** `Task::setStatus(?TaskStatus)`, `Task::setAssignee(?User)` existent. `Project::getWorkflow()` ; `Workflow::getStatuses()` est ordonné `position ASC`. Accès mail = ROLE_USER/ROLE_ADMIN (cf. `MailAccessChecker`).
|
||||
|
||||
- [ ] **Step 1 : Écrire le test fonctionnel (TDD) — assigné + statut, priorité ignorée**
|
||||
|
||||
Ajouter dans `MailTaskIntegrationControllerTest.php` (crée ses prérequis via l'EntityManager) :
|
||||
|
||||
```php
|
||||
public function testCreateTaskAppliesStatusAndAssigneeAndIgnoresPriority(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$container = static::getContainer();
|
||||
$em = $container->get('doctrine.orm.entity_manager');
|
||||
|
||||
$admin = $em->getRepository(\App\Entity\User::class)->findOneBy(['username' => 'admin']);
|
||||
$client->loginUser($admin);
|
||||
|
||||
// Projet existant (fixtures) + son workflow / premier statut + un message mail existant
|
||||
$project = $em->getRepository(\App\Entity\Project::class)->findOneBy([]);
|
||||
self::assertNotNull($project, 'Au moins un projet doit exister dans les fixtures');
|
||||
$status = $project->getWorkflow()->getStatuses()->first();
|
||||
$message = $em->getRepository(\App\Entity\MailMessage::class)->findOneBy([]);
|
||||
self::assertNotNull($message, 'Au moins un message mail doit exister (fixtures ou sync)');
|
||||
|
||||
$client->request(
|
||||
'POST',
|
||||
'/api/mail/messages/'.$message->getId().'/create-task',
|
||||
[], [], ['CONTENT_TYPE' => 'application/json'],
|
||||
json_encode([
|
||||
'projectId' => $project->getId(),
|
||||
'assigneeId' => $admin->getId(),
|
||||
'statusId' => $status->getId(),
|
||||
'priorityId' => 999, // doit être ignoré
|
||||
])
|
||||
);
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
$payload = json_decode($client->getResponse()->getContent(), true);
|
||||
|
||||
$task = $em->getRepository(\App\Entity\Task::class)->find($payload['taskId']);
|
||||
self::assertSame($status->getId(), $task->getStatus()?->getId());
|
||||
self::assertSame($admin->getId(), $task->getAssignee()?->getId());
|
||||
self::assertNull($task->getPriority(), 'priorityId ne doit plus être pris en compte');
|
||||
}
|
||||
```
|
||||
|
||||
> Si les fixtures ne contiennent pas de `MailMessage`, créer dans le test un `MailConfiguration` + `MailFolder` + `MailMessage` minimal via l'EM (adapter aux champs requis des entités), ou charger un dump mail. Le test échoue tant que le contrôleur n'est pas modifié.
|
||||
|
||||
- [ ] **Step 2 : Lancer le test (doit échouer)**
|
||||
|
||||
Run: `make test` (ou `docker exec php-lesstime-fpm php bin/phpunit --filter testCreateTaskAppliesStatusAndAssigneeAndIgnoresPriority`)
|
||||
Expected : FAIL (assignee/status non appliqués, priorityId encore lu).
|
||||
|
||||
- [ ] **Step 3 : Modifier le contrôleur**
|
||||
|
||||
Dans `MailCreateTaskController.php` :
|
||||
|
||||
a) Remplacer l'import `use App\Entity\TaskPriority;` par :
|
||||
```php
|
||||
use App\Entity\TaskStatus;
|
||||
use App\Entity\User;
|
||||
```
|
||||
|
||||
b) Dans la transaction (l.62-96), **remplacer** le bloc priorité (l.77-82) par l'assigné + le statut :
|
||||
|
||||
```php
|
||||
if (isset($body['assigneeId']) && null !== $body['assigneeId']) {
|
||||
$assignee = $this->em->getRepository(User::class)->find($body['assigneeId']);
|
||||
if (null !== $assignee) {
|
||||
$task->setAssignee($assignee);
|
||||
}
|
||||
}
|
||||
|
||||
// Statut : celui fourni, sinon le premier statut du workflow du projet (par position)
|
||||
$status = null;
|
||||
if (isset($body['statusId']) && null !== $body['statusId']) {
|
||||
$status = $this->em->getRepository(TaskStatus::class)->find($body['statusId']);
|
||||
}
|
||||
if (null === $status) {
|
||||
$status = $project->getWorkflow()?->getStatuses()->first() ?: null;
|
||||
}
|
||||
if (null !== $status) {
|
||||
$task->setStatus($status);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer le test (doit passer)**
|
||||
|
||||
Run: `make test`
|
||||
Expected : PASS. Lancer aussi `make php-cs-fixer-allow-risky`.
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/Controller/Mail/MailCreateTaskController.php tests/Functional/Controller/Mail/MailTaskIntegrationControllerTest.php
|
||||
git commit -m "feat(mail) : create-task applique statut + assigné, retire la priorité"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6 : Modale de création depuis un mail (#6 front)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/components/mail/MailCreateTaskModal.vue`
|
||||
- Modify: `frontend/services/mail.ts` (`createTaskFromMail`, ~l.184-192)
|
||||
- Modify: `frontend/i18n/locales/*.json` (libellés user/statut)
|
||||
|
||||
- [ ] **Step 1 : Adapter le service `createTaskFromMail`**
|
||||
|
||||
Dans `frontend/services/mail.ts`, modifier le payload de `createTaskFromMail` : retirer `priority`, accepter `assigneeId?: number` et `statusId?: number`. Le corps POST devient :
|
||||
|
||||
```ts
|
||||
{
|
||||
projectId,
|
||||
taskGroupId,
|
||||
assigneeId,
|
||||
statusId,
|
||||
}
|
||||
```
|
||||
|
||||
(adapter la signature TypeScript de la fonction en conséquence ; supprimer toute référence à `priority`).
|
||||
|
||||
- [ ] **Step 2 : Réécrire `MailCreateTaskModal.vue` sur AppModal + user + statut**
|
||||
|
||||
Remplacer le `<script setup>` : retirer `useTaskPriorityService`/`priorities`/`priorityId`/`priorityOptions`, ajouter le service users, le service statuts par workflow, et l'état `assigneeId` / `statusId`.
|
||||
|
||||
```ts
|
||||
import type { MailMessageDetailDto } from '~/services/dto/mail'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import { useMailService } from '~/services/mail'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
import { useTaskGroupService } from '~/services/task-groups'
|
||||
import { useUserService } from '~/services/users'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
messageId: number
|
||||
messageDetail: MailMessageDetailDto | null
|
||||
}>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; created: [task: Task] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const auth = useAuthStore()
|
||||
const mailService = useMailService()
|
||||
const projectService = useProjectService()
|
||||
const taskGroupService = useTaskGroupService()
|
||||
const userService = useUserService()
|
||||
|
||||
const projectId = ref<number | null>(null)
|
||||
const taskGroupId = ref<number | null>(null)
|
||||
const assigneeId = ref<number | null>(null)
|
||||
const statusId = ref<number | null>(null)
|
||||
const isSubmitting = ref(false)
|
||||
const touchedProject = ref(false)
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
const groups = ref<TaskGroup[]>([])
|
||||
const users = ref<{ id: number, username: string }[]>([])
|
||||
const loadingGroups = ref(false)
|
||||
|
||||
const projectOptions = computed(() => projects.value.map(p => ({ label: p.name, value: p.id })))
|
||||
const groupOptions = computed(() => groups.value.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id })))
|
||||
const userOptions = computed(() => users.value.map(u => ({ label: u.username, value: u.id })))
|
||||
|
||||
// Statuts filtrés par le workflow du projet sélectionné (#2 réutilisé)
|
||||
const selectedProject = computed(() => projects.value.find(p => p.id === projectId.value) ?? null)
|
||||
const statusOptions = computed(() =>
|
||||
(selectedProject.value?.workflow?.statuses ?? []).map(s => ({ label: s.label, value: s.id })),
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
const [projs, us] = await Promise.all([
|
||||
projectService.getAll({ archived: false }),
|
||||
userService.getAll(),
|
||||
])
|
||||
projects.value = projs
|
||||
users.value = us
|
||||
})
|
||||
|
||||
// Au changement de projet : recharger les groupes + présélectionner le 1er statut du workflow
|
||||
watch(projectId, async (pid) => {
|
||||
taskGroupId.value = null
|
||||
statusId.value = selectedProject.value?.workflow?.statuses?.[0]?.id ?? null
|
||||
groups.value = []
|
||||
if (!pid) return
|
||||
loadingGroups.value = true
|
||||
try {
|
||||
groups.value = await taskGroupService.getByProject(pid)
|
||||
} finally {
|
||||
loadingGroups.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// Reset + user par défaut = utilisateur connecté
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
projectId.value = null
|
||||
taskGroupId.value = null
|
||||
statusId.value = null
|
||||
assigneeId.value = auth.user?.id ?? null
|
||||
touchedProject.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function close(): void { emit('update:modelValue', false) }
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
touchedProject.value = true
|
||||
if (!projectId.value) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const task = await mailService.createTaskFromMail(props.messageId, {
|
||||
projectId: projectId.value,
|
||||
taskGroupId: taskGroupId.value ?? undefined,
|
||||
assigneeId: assigneeId.value ?? undefined,
|
||||
statusId: statusId.value ?? undefined,
|
||||
})
|
||||
emit('created', task)
|
||||
close()
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Puis remplacer tout le `<template>` (et le `<style>` devient inutile — AppModal gère l'animation) par :
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<AppModal :model-value="modelValue" width="lg" :title="t('mail.createTaskModal.title')" @update:model-value="emit('update:modelValue', $event)">
|
||||
<div class="space-y-5">
|
||||
<div v-if="messageDetail" class="rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-3 text-sm">
|
||||
<p class="truncate font-medium text-neutral-800">{{ messageDetail.header.subject ?? t('mail.noSubject') }}</p>
|
||||
<p class="mt-0.5 truncate text-xs text-neutral-500">{{ messageDetail.header.fromName ?? messageDetail.header.fromEmail }}</p>
|
||||
<p class="mt-2 text-xs italic text-neutral-400">{{ t('mail.createTaskModal.titleHint') }}</p>
|
||||
<p class="text-xs italic text-neutral-400">{{ t('mail.createTaskModal.descriptionHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<MalioSelect v-model="projectId" :options="projectOptions" :label="t('mail.createTaskModal.projectLabel')" :empty-option-label="t('mail.createTaskModal.projectPlaceholder')" min-width="w-full" />
|
||||
<p v-if="touchedProject && !projectId" class="mt-1 text-xs text-red-500">{{ t('mail.createTaskModal.projectLabel').replace(' *', '') }} requis</p>
|
||||
</div>
|
||||
|
||||
<div v-if="projectId">
|
||||
<MalioSelect v-model="taskGroupId" :options="groupOptions" :label="t('mail.createTaskModal.groupLabel')" :empty-option-label="t('mail.createTaskModal.groupPlaceholder')" min-width="w-full" :disabled="loadingGroups" />
|
||||
</div>
|
||||
|
||||
<div v-if="projectId">
|
||||
<MalioSelect v-model="statusId" :options="statusOptions" :label="t('mail.createTaskModal.statusLabel')" min-width="w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<MalioSelect v-model="assigneeId" :options="userOptions" :label="t('mail.createTaskModal.assigneeLabel')" :empty-option-label="t('mail.createTaskModal.assigneePlaceholder')" min-width="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<MalioButton variant="tertiary" label="Annuler" button-class="w-auto px-4" @click="close" />
|
||||
<MalioButton :label="t('mail.createTaskModal.submit')" button-class="w-auto px-6" :disabled="isSubmitting" @click="handleSubmit" />
|
||||
</template>
|
||||
</AppModal>
|
||||
</template>
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Ajouter les clés i18n**
|
||||
|
||||
Dans `mail.createTaskModal` (toutes les locales) : `statusLabel` (« Statut »), `assigneeLabel` (« Assigné à »), `assigneePlaceholder` (« Aucun »). Retirer `priorityLabel`/`priorityPlaceholder` si plus utilisées ailleurs.
|
||||
|
||||
- [ ] **Step 4 : Build**
|
||||
|
||||
Run: `cd frontend && npm run build:dist`
|
||||
Expected : exit 0.
|
||||
|
||||
- [ ] **Step 5 : Vérification navigateur (Chrome MCP)**
|
||||
|
||||
1. Hard-reload, login, Messagerie → ouvrir un message → « Créer une tâche ».
|
||||
Expected : modale **élargie**, footer **toujours visible**, champs = Projet / Groupe / **Statut** / **Assigné** (défaut = Matthieu). Plus de champ Priorité.
|
||||
2. Choisir un projet → le statut se présélectionne sur le 1er statut du workflow ; les options statut = celles du workflow du projet.
|
||||
3. Créer la tâche → succès, tâche liée au mail avec le bon statut/assigné.
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/components/mail/MailCreateTaskModal.vue frontend/services/mail.ts frontend/i18n/locales/
|
||||
git commit -m "feat(mail) : création de tâche depuis mail — sélecteur user + statut (workflow), modale agrandie"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7 : Supprimer le bouton « Lier un mail » (#5)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/components/task/TaskModal.vue` (bouton ~l.487-493 ; `<MailPickerModal>` ~l.498-503 ; état `showMailPickerModal` l.627 ; `handleMailLinked` ~l.936-938)
|
||||
- Delete: `frontend/components/mail/MailPickerModal.vue`
|
||||
- Modify: `frontend/i18n/locales/*.json` (clé `mail.taskTab.linkButton`)
|
||||
|
||||
**Contexte vérifié :** `MailPickerModal` n'est utilisé **que** par TaskModal.
|
||||
|
||||
- [ ] **Step 1 : Retirer le bouton, la modale, l'état et le handler dans TaskModal.vue**
|
||||
|
||||
- Supprimer le `<MalioButton ... :label="$t('mail.taskTab.linkButton')" ... @click="showMailPickerModal = true" />` (~l.487-493).
|
||||
- Supprimer le bloc `<MailPickerModal ... v-model="showMailPickerModal" ... @linked="handleMailLinked" />` (~l.498-503).
|
||||
- Supprimer `const showMailPickerModal = ref(false)` (l.627).
|
||||
- Supprimer la fonction `handleMailLinked` (~l.936-938).
|
||||
- Retirer l'éventuel `import MailPickerModal` (si import explicite ; sinon auto-import, rien à faire).
|
||||
|
||||
- [ ] **Step 2 : Supprimer le composant et la clé i18n**
|
||||
|
||||
```bash
|
||||
git rm frontend/components/mail/MailPickerModal.vue
|
||||
```
|
||||
Retirer la clé `mail.taskTab.linkButton` dans toutes les locales (vérifier qu'elle n'est plus référencée : `grep -rn "taskTab.linkButton" frontend/`).
|
||||
|
||||
- [ ] **Step 3 : Build**
|
||||
|
||||
Run: `cd frontend && npm run build:dist`
|
||||
Expected : exit 0, aucune référence cassée.
|
||||
|
||||
- [ ] **Step 4 : Vérification navigateur (Chrome MCP)**
|
||||
|
||||
Ouvrir une tâche → onglet « Mails ».
|
||||
Expected : plus de bouton « Lier un mail ». La liste des mails liés et le bouton de suppression de lien (s'il existe) restent fonctionnels.
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
|
||||
```bash
|
||||
git add -A frontend/components/task/TaskModal.vue frontend/i18n/locales/
|
||||
git commit -m "refactor(task) : suppression du bouton « Lier un mail » et de MailPickerModal"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8 : Cartes responsive (#3)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/components/task/TaskCard.vue` (ligne badges ~l.42-106)
|
||||
|
||||
**Contexte vérifié :** badges en `rounded-full px-2 py-0.5 ... text-white` sans contrainte ; conteneur `mt-2 flex items-center gap-1.5` sans `min-w-0` ni `flex-wrap`. Décision : **2-3 tags max + « +N »**, hauteur fixe, troncature.
|
||||
|
||||
- [ ] **Step 1 : Titre — `line-clamp-2`**
|
||||
|
||||
Ligne 30, remplacer :
|
||||
```vue
|
||||
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||
```
|
||||
par :
|
||||
```vue
|
||||
<h4 class="line-clamp-2 text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Conteneur badges — `min-w-0` + troncature des badges**
|
||||
|
||||
Sur le conteneur (l.42) ajouter `min-w-0` : `class="mt-2 flex min-w-0 items-center gap-1.5"`.
|
||||
|
||||
Sur les badges statut/priorité/tag/deadline, ajouter `max-w-[7rem] truncate shrink-0` à la classe `rounded-full ...`. Exemple pour le statut (l.45) :
|
||||
```vue
|
||||
class="shrink-0 max-w-[7rem] truncate rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Limiter les tags à 2-3 + badge « +N »**
|
||||
|
||||
Remplacer la boucle des tags (l.57-64) par :
|
||||
```vue
|
||||
<span
|
||||
v-for="tag in task.tags.slice(0, 2)"
|
||||
:key="tag.id"
|
||||
class="shrink-0 max-w-[7rem] truncate rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:style="{ backgroundColor: tag.color }"
|
||||
:title="tag.label"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="task.tags.length > 2"
|
||||
class="shrink-0 rounded-full bg-neutral-200 px-2 py-0.5 text-xs font-semibold text-neutral-600"
|
||||
:title="task.tags.slice(2).map(t => t.label).join(', ')"
|
||||
>
|
||||
+{{ task.tags.length - 2 }}
|
||||
</span>
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Build**
|
||||
|
||||
Run: `cd frontend && npm run build:dist`
|
||||
Expected : exit 0.
|
||||
|
||||
- [ ] **Step 5 : Vérification navigateur (Chrome MCP)**
|
||||
|
||||
Sur « Mes tâches » avec données prod (cartes à nombreux tags) : vérifier via le DOM qu'aucune carte ne déborde (mesurer `scrollWidth - clientWidth` ≤ 1 sur la ligne de badges) ; les cartes à >2 tags montrent un badge « +N » ; titres longs tronqués sur 2 lignes.
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/components/task/TaskCard.vue
|
||||
git commit -m "fix(task) : cartes responsive — troncature badges, max 2 tags + « +N », titre line-clamp"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9 : Couleurs par défaut par catégorie + migration data prod (#4a + #4c)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/components/admin/WorkflowDrawer.vue` (`addStatus`, l.172-180 ; `categoryOptions` l.143-151)
|
||||
- Create: `migrations/VersionYYYYMMDDHHMMSS.php`
|
||||
- Modify: `src/DataFixtures/AppFixtures.php` (déjà correct — vérifier, ne rien changer si OK)
|
||||
|
||||
- [ ] **Step 1 : Couleur par défaut par catégorie dans `addStatus` (front)**
|
||||
|
||||
Dans `WorkflowDrawer.vue`, importer la palette et l'utiliser à la création :
|
||||
|
||||
```ts
|
||||
import { STATUS_CATEGORY_COLOR } from '~/services/dto/workflow'
|
||||
```
|
||||
```ts
|
||||
function addStatus() {
|
||||
form.statuses.push({
|
||||
label: '',
|
||||
color: STATUS_CATEGORY_COLOR.todo, // défaut cohérent (catégorie initiale = todo)
|
||||
position: form.statuses.length,
|
||||
isFinal: false,
|
||||
category: 'todo',
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Et, pour aligner la couleur quand l'utilisateur change la catégorie d'un statut, ajouter un watcher dans le `<script setup>` :
|
||||
|
||||
```ts
|
||||
import type { StatusCategory } from '~/services/dto/workflow'
|
||||
// (déjà importé pour le type ; sinon ajouter)
|
||||
|
||||
watch(() => form.statuses.map(s => s.category), (cats, prev) => {
|
||||
cats.forEach((cat, i) => {
|
||||
// si la catégorie vient de changer ET que la couleur correspond encore au défaut de l'ancienne catégorie, réaligner
|
||||
if (prev && cat !== prev[i] && form.statuses[i] && form.statuses[i].color === STATUS_CATEGORY_COLOR[prev[i] as StatusCategory]) {
|
||||
form.statuses[i].color = STATUS_CATEGORY_COLOR[cat]
|
||||
}
|
||||
})
|
||||
}, { deep: false })
|
||||
```
|
||||
|
||||
> Ce watcher ne réécrase **pas** une couleur personnalisée (il n'agit que si la couleur courante = défaut de l'ancienne catégorie).
|
||||
|
||||
- [ ] **Step 2 : Build + vérif front**
|
||||
|
||||
Run: `cd frontend && npm run build:dist` (exit 0). Vérifier en navigateur : ajouter un statut → couleur par défaut indigo ; changer sa catégorie vers « En cours » alors qu'il a la couleur par défaut → la couleur passe au bleu `#4A90D9`.
|
||||
|
||||
- [ ] **Step 3 : Générer la migration de correction data**
|
||||
|
||||
Run: `make shell` puis `php bin/console make:migration` **n'est pas adapté** (pas de diff de schéma). Créer manuellement `migrations/VersionYYYYMMDDHHMMSS.php` (timestamp courant) :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class VersionYYYYMMDDHHMMSS extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Remet les couleurs classiques sur les statuts du workflow Standard (dérive data prod #4).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Cible : statuts du workflow nommé "Standard", par catégorie. Ne touche pas aux autres workflows.
|
||||
$map = [
|
||||
'todo' => '#222783',
|
||||
'in_progress' => '#4A90D9',
|
||||
'blocked' => '#C62828',
|
||||
'review' => '#FF8F00',
|
||||
'done' => '#26A69A',
|
||||
];
|
||||
foreach ($map as $category => $hex) {
|
||||
$this->addSql(
|
||||
"UPDATE task_status SET color = :hex
|
||||
WHERE category = :cat
|
||||
AND workflow_id = (SELECT id FROM workflow WHERE name = 'Standard' ORDER BY id ASC LIMIT 1)",
|
||||
['hex' => $hex, 'cat' => $category]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Pas de rollback des couleurs (correction one-shot).
|
||||
$this->throwIrreversibleMigration('Correction de couleurs non réversible.');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Vérifier la signature `addSql` avec paramètres nommés de la version Doctrine Migrations utilisée ; sinon utiliser des valeurs inline (couleurs et catégories sont des constantes sûres). Confirmer le nom de colonne `workflow_id` via `\d task_status`.
|
||||
|
||||
- [ ] **Step 4 : Tester la migration en local (sur données prod importées)**
|
||||
|
||||
Run: `make migration-migrate`
|
||||
Puis vérifier :
|
||||
```bash
|
||||
docker exec -e PGPASSWORD=root lesstime-db-1 psql -U root -p 5435 -d lesstime -c "select label,color from task_status ts join workflow w on w.id=ts.workflow_id where w.name='Standard' order by ts.position;"
|
||||
```
|
||||
Expected : `#222783 / #4A90D9 / #C62828 / #FF8F00 / #26A69A`.
|
||||
|
||||
- [ ] **Step 5 : Vérif navigateur**
|
||||
|
||||
Kanban d'un projet Standard + badges de cartes : couleurs classiques de retour.
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/components/admin/WorkflowDrawer.vue migrations/
|
||||
git commit -m "fix(workflow) : couleurs par défaut par catégorie + migration de correction du workflow Standard"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10 : Migrer TaskModal vers AppModal (#7)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/components/task/TaskModal.vue` (coque de la modale uniquement : Teleport/Transition/overlay + header + footer)
|
||||
|
||||
> À faire en dernier car TaskModal est touché par #2 et #5 ; on stabilise d'abord son contenu. La migration ne change que la **coque** (structure header/body/footer), pas la logique métier.
|
||||
|
||||
- [ ] **Step 1 : Remplacer la coque par AppModal**
|
||||
|
||||
Envelopper le contenu existant dans `<AppModal :model-value="isOpen" width="lg" @update:model-value="isOpen = $event">`, déplacer le titre dans le slot `#title` (ou prop `title`), placer le corps actuel dans le slot par défaut et la barre d'actions (Supprimer / Annuler / Enregistrer, ~l.507-549) dans `<template #footer>`. Retirer le `Teleport`/`Transition`/overlay et le `max-h`/`overflow` manuels désormais gérés par AppModal.
|
||||
|
||||
> Conserver tels quels les sous-modales internes (ConfirmDeleteTaskModal, etc.) et la logique `close()` (qui bloque la fermeture si une confirmation est ouverte) — la connecter au `@update:model-value` d'AppModal.
|
||||
|
||||
- [ ] **Step 2 : Build**
|
||||
|
||||
Run: `cd frontend && npm run build:dist`
|
||||
Expected : exit 0.
|
||||
|
||||
- [ ] **Step 3 : Vérification navigateur (Chrome MCP)**
|
||||
|
||||
Ouvrir une tâche avec beaucoup de contenu (description longue) sur un viewport normal.
|
||||
Expected : header et **footer (Supprimer/Annuler/Enregistrer) toujours visibles**, body scrollable au milieu. Mesurer que le bouton « Enregistrer » est dans le viewport (`getBoundingClientRect().bottom <= window.innerHeight`).
|
||||
|
||||
- [ ] **Step 4 : Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/components/task/TaskModal.vue
|
||||
git commit -m "refactor(task) : TaskModal migré sur AppModal (footer sticky)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review — couverture spec
|
||||
|
||||
| Chantier spec | Task(s) | Couvert |
|
||||
|---|---|---|
|
||||
| #1 D&D | Task 4 | ✅ handlers + popover + par-workflow |
|
||||
| #2 Sélecteur statut | Task 3 (+ réutilisé Task 6) | ✅ |
|
||||
| #3 Cartes responsive | Task 8 | ✅ troncature + N |
|
||||
| #4 Couleurs | Task 1 (palette), Task 4 (entêtes), Task 9 (migration + défauts) | ✅ a/b/c |
|
||||
| #5 Bouton lier mail | Task 7 | ✅ |
|
||||
| #6 Création depuis mail | Task 5 (back) + Task 6 (front) | ✅ |
|
||||
| #7 Modale réutilisable | Task 2 (composant) + Task 6/10 (migrations) | ✅ |
|
||||
| #8 MalioSelect catégorie | déjà fait (hors plan) | ✅ |
|
||||
|
||||
**Risques / points de vigilance pour l'exécutant :**
|
||||
- Noms de fonctions/variables existants dans `my-tasks.vue` (rechargement des tâches, toast, `t`) et `TaskModal.vue` (projet effectif) — à raccorder aux noms réels.
|
||||
- `MailMessage` non garanti dans les fixtures → adapter le test backend (Task 5) ou importer un dump mail.
|
||||
- Toujours **hard-reload sans cache** après chaque `build:dist`.
|
||||
571
docs/superpowers/plans/2026-05-22-employee-management-reorg.md
Normal file
571
docs/superpowers/plans/2026-05-22-employee-management-reorg.md
Normal file
@@ -0,0 +1,571 @@
|
||||
# Réorganisation gestion employés — Plan d'implémentation
|
||||
|
||||
> **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:** Sortir l'édition des informations RH du `UserDrawer` (qui ne garde que la case « Employé ») vers un onglet « Employés » dédié dans `team-absences`, avec une liste users⋈soldes et un drawer d'édition.
|
||||
|
||||
**Architecture:** Réorganisation 100 % frontend. Les champs employé existent déjà sur l'entité `User` (backend) et le DTO `UserData`/`UserWrite` ; la persistance passe par `usersService.update()` (PATCH partiel, sans écrasement). La liste de l'onglet joint `usersService.getAll()` (filtré `isEmployee`) avec `absenceService.getBalances({ type: 'cp' })`.
|
||||
|
||||
**Tech Stack:** Nuxt 4 / Vue 3 Composition API, TypeScript, composants `@malio/layer-ui` (MalioDate, MalioSelect, MalioInputText, MalioDataTable, MalioDrawer, MalioCheckbox), i18n `@nuxtjs/i18n`.
|
||||
|
||||
**Conventions projet :**
|
||||
- 4 espaces d'indentation, TypeScript strict.
|
||||
- Pas de framework de test frontend → vérification au navigateur (serveur dev sur `http://localhost:3002`, Chrome DevTools MCP) + compilation HMR sans erreur console.
|
||||
- **Commits gérés par l'utilisateur** : ne committer qu'après son feu vert explicite (règle CLAUDE.md). Les étapes « Commit » sont fournies mais à déclencher sur demande. Format : `<type>(<scope>) : <message>`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1 : Clés i18n
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/i18n/locales/fr.json` (objet `absences.admin`)
|
||||
|
||||
- [ ] **Step 1 : Ajouter l'onglet et le bloc `employees`**
|
||||
|
||||
Dans `absences.admin`, ajouter `"employees"` à `tabs`, et un nouveau bloc `employees`. Repérer le bloc existant :
|
||||
|
||||
```json
|
||||
"tabs": { "requests": "Demandes", "calendar": "Calendrier", "balances": "Soldes" },
|
||||
```
|
||||
|
||||
le remplacer par :
|
||||
|
||||
```json
|
||||
"tabs": { "requests": "Demandes", "calendar": "Calendrier", "balances": "Soldes", "employees": "Employés" },
|
||||
```
|
||||
|
||||
puis ajouter, à la suite des clés de `admin` (par ex. après `"adjust"`), le bloc :
|
||||
|
||||
```json
|
||||
"employees": {
|
||||
"columns": {
|
||||
"name": "Nom",
|
||||
"contract": "Contrat",
|
||||
"cpTaken": "CP pris",
|
||||
"cpRemaining": "CP restants"
|
||||
},
|
||||
"empty": "Aucun employé. Cochez « Employé » sur un utilisateur dans l'administration.",
|
||||
"noContract": "—",
|
||||
"drawer": {
|
||||
"title": "Informations employé",
|
||||
"save": "Enregistrer"
|
||||
},
|
||||
"fields": {
|
||||
"hireDate": "Date d'embauche",
|
||||
"endDate": "Date de sortie",
|
||||
"contractType": "Type de contrat",
|
||||
"familySituation": "Situation familiale",
|
||||
"workTimeRatio": "Temps de travail (ex : 1.0)",
|
||||
"annualLeaveDays": "CP annuels (jours)",
|
||||
"referencePeriodStart": "Début période réf. (MM-DD)",
|
||||
"initialLeaveBalance": "Solde CP initial",
|
||||
"nbChildren": "Nombre d'enfants"
|
||||
},
|
||||
"contract": {
|
||||
"cdi": "CDI",
|
||||
"cdd": "CDD",
|
||||
"stage": "Stage",
|
||||
"alternance": "Alternance",
|
||||
"autre": "Autre"
|
||||
},
|
||||
"family": {
|
||||
"celibataire": "Célibataire",
|
||||
"marie": "Marié(e)",
|
||||
"pacse": "Pacsé(e)",
|
||||
"divorce": "Divorcé(e)",
|
||||
"veuf": "Veuf(ve)"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Vérifier la validité JSON**
|
||||
|
||||
Run: `cd frontend && python3 -c "import json; json.load(open('i18n/locales/fr.json')); print('OK')"`
|
||||
Expected: `OK`
|
||||
|
||||
- [ ] **Step 3 : Commit** (sur feu vert utilisateur)
|
||||
|
||||
```bash
|
||||
git add frontend/i18n/locales/fr.json
|
||||
git commit -m "feat(absences) : clés i18n onglet et drawer employés"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2 : Composant `EmployeeDrawer.vue`
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/components/absence/EmployeeDrawer.vue`
|
||||
|
||||
- [ ] **Step 1 : Créer le composant**
|
||||
|
||||
Crée `frontend/components/absence/EmployeeDrawer.vue` avec ce contenu exact :
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<MalioDrawer v-model="open" drawer-class="max-w-lg">
|
||||
<template #header>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold">{{ $t('absences.admin.employees.drawer.title') }}</h2>
|
||||
<p v-if="user" class="text-sm text-neutral-500">{{ user.username }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<form v-if="user" class="grid grid-cols-1 gap-4 sm:grid-cols-2" @submit.prevent="save">
|
||||
<MalioDate
|
||||
v-model="form.hireDate"
|
||||
:label="$t('absences.admin.employees.fields.hireDate')"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioDate
|
||||
v-model="form.endDate"
|
||||
:label="$t('absences.admin.employees.fields.endDate')"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.contractType"
|
||||
:label="$t('absences.admin.employees.fields.contractType')"
|
||||
:options="contractOptions"
|
||||
empty-option-label="—"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.familySituation"
|
||||
:label="$t('absences.admin.employees.fields.familySituation')"
|
||||
:options="familyOptions"
|
||||
empty-option-label="—"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.workTimeRatio"
|
||||
:label="$t('absences.admin.employees.fields.workTimeRatio')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.annualLeaveDays"
|
||||
:label="$t('absences.admin.employees.fields.annualLeaveDays')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.referencePeriodStart"
|
||||
:label="$t('absences.admin.employees.fields.referencePeriodStart')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.initialLeaveBalance"
|
||||
:label="$t('absences.admin.employees.fields.initialLeaveBalance')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.nbChildren"
|
||||
:label="$t('absences.admin.employees.fields.nbChildren')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<div class="col-span-full mt-2 flex justify-end">
|
||||
<MalioButton
|
||||
:label="$t('absences.admin.employees.drawer.save')"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="submitting"
|
||||
@click="save"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ContractType, FamilySituation, UserData } from '~/services/dto/user-data'
|
||||
import { useUserService } from '~/services/users'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
user: UserData | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'saved': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { update } = useUserService()
|
||||
|
||||
const open = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const submitting = ref(false)
|
||||
|
||||
const contractOptions = [
|
||||
{ label: t('absences.admin.employees.contract.cdi'), value: 'CDI' },
|
||||
{ label: t('absences.admin.employees.contract.cdd'), value: 'CDD' },
|
||||
{ label: t('absences.admin.employees.contract.stage'), value: 'STAGE' },
|
||||
{ label: t('absences.admin.employees.contract.alternance'), value: 'ALTERNANCE' },
|
||||
{ label: t('absences.admin.employees.contract.autre'), value: 'AUTRE' },
|
||||
]
|
||||
|
||||
const familyOptions = [
|
||||
{ label: t('absences.admin.employees.family.celibataire'), value: 'CELIBATAIRE' },
|
||||
{ label: t('absences.admin.employees.family.marie'), value: 'MARIE' },
|
||||
{ label: t('absences.admin.employees.family.pacse'), value: 'PACSE' },
|
||||
{ label: t('absences.admin.employees.family.divorce'), value: 'DIVORCE' },
|
||||
{ label: t('absences.admin.employees.family.veuf'), value: 'VEUF' },
|
||||
]
|
||||
|
||||
const form = reactive({
|
||||
hireDate: null as string | null,
|
||||
endDate: null as string | null,
|
||||
contractType: null as ContractType | null,
|
||||
familySituation: null as FamilySituation | null,
|
||||
workTimeRatio: '1.0',
|
||||
annualLeaveDays: '25',
|
||||
referencePeriodStart: '06-01',
|
||||
initialLeaveBalance: '0',
|
||||
nbChildren: '0',
|
||||
})
|
||||
|
||||
function hydrate(u: UserData | null) {
|
||||
if (!u) return
|
||||
form.hireDate = u.hireDate ? u.hireDate.slice(0, 10) : null
|
||||
form.endDate = u.endDate ? u.endDate.slice(0, 10) : null
|
||||
form.contractType = u.contractType ?? null
|
||||
form.familySituation = u.familySituation ?? null
|
||||
form.workTimeRatio = String(u.workTimeRatio ?? 1)
|
||||
form.annualLeaveDays = String(u.annualLeaveDays ?? 25)
|
||||
form.referencePeriodStart = u.referencePeriodStart ?? '06-01'
|
||||
form.initialLeaveBalance = String(u.initialLeaveBalance ?? 0)
|
||||
form.nbChildren = String(u.nbChildren ?? 0)
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (isOpen) => {
|
||||
if (isOpen) hydrate(props.user)
|
||||
})
|
||||
|
||||
async function save() {
|
||||
if (!props.user) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await update(props.user.id, {
|
||||
isEmployee: true,
|
||||
hireDate: form.hireDate || null,
|
||||
endDate: form.endDate || null,
|
||||
contractType: form.contractType,
|
||||
familySituation: form.familySituation,
|
||||
workTimeRatio: Number(form.workTimeRatio) || 1,
|
||||
annualLeaveDays: Number(form.annualLeaveDays) || 0,
|
||||
referencePeriodStart: form.referencePeriodStart || '06-01',
|
||||
initialLeaveBalance: Number(form.initialLeaveBalance) || 0,
|
||||
nbChildren: Number(form.nbChildren) || 0,
|
||||
})
|
||||
emit('saved')
|
||||
open.value = false
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Vérifier la compilation**
|
||||
|
||||
Le serveur dev (`http://localhost:3002`) recompile à la sauvegarde. Vérifier qu'aucune erreur de compilation/HMR n'apparaît dans la console du terminal `make dev-nuxt` ni dans la console navigateur. (Le composant n'est pas encore monté ; cette étape ne fait que valider la syntaxe.)
|
||||
|
||||
- [ ] **Step 3 : Commit** (sur feu vert utilisateur)
|
||||
|
||||
```bash
|
||||
git add frontend/components/absence/EmployeeDrawer.vue
|
||||
git commit -m "feat(absences) : drawer d'édition des informations employé"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3 : Onglet « Employés » dans `team-absences`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/pages/team-absences.vue`
|
||||
|
||||
- [ ] **Step 1 : Ajouter l'import du service users et le type**
|
||||
|
||||
Après les imports existants (`useAbsenceHelpers`), ajouter :
|
||||
|
||||
```ts
|
||||
import { useUserService } from "~/services/users";
|
||||
import type { UserData } from "~/services/dto/user-data";
|
||||
```
|
||||
|
||||
Et après la déclaration `type BalanceRow = ...`, ajouter le type de ligne :
|
||||
|
||||
```ts
|
||||
type EmployeeRow = UserData & {
|
||||
contractText: string;
|
||||
cpTakenText: string;
|
||||
cpRemainingText: string;
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Ajouter l'onglet à `tabs`**
|
||||
|
||||
Remplacer le tableau `tabs` (qui se termine par l'onglet `balances`) en ajoutant l'entrée employés :
|
||||
|
||||
```ts
|
||||
const tabs = [
|
||||
{
|
||||
key: "requests",
|
||||
label: t("absences.admin.tabs.requests"),
|
||||
icon: "mdi:format-list-bulleted",
|
||||
},
|
||||
{
|
||||
key: "calendar",
|
||||
label: t("absences.admin.tabs.calendar"),
|
||||
icon: "mdi:calendar-month",
|
||||
},
|
||||
{
|
||||
key: "balances",
|
||||
label: t("absences.admin.tabs.balances"),
|
||||
icon: "mdi:scale-balance",
|
||||
},
|
||||
{
|
||||
key: "employees",
|
||||
label: t("absences.admin.tabs.employees"),
|
||||
icon: "mdi:account-group",
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Ajouter l'état, les colonnes et les lignes de l'onglet**
|
||||
|
||||
Après `const balances = ref<AbsenceBalance[]>([]);`, ajouter :
|
||||
|
||||
```ts
|
||||
const employees = ref<UserData[]>([]);
|
||||
const employeeDrawerOpen = ref(false);
|
||||
const selectedEmployee = ref<UserData | null>(null);
|
||||
```
|
||||
|
||||
Après `const balanceRows = computed(...)`, ajouter colonnes + lignes :
|
||||
|
||||
```ts
|
||||
const employeeColumns = [
|
||||
{ key: "username", label: t("absences.admin.employees.columns.name") },
|
||||
{ key: "contractText", label: t("absences.admin.employees.columns.contract") },
|
||||
{ key: "cpTakenText", label: t("absences.admin.employees.columns.cpTaken") },
|
||||
{ key: "cpRemainingText", label: t("absences.admin.employees.columns.cpRemaining") },
|
||||
];
|
||||
|
||||
const employeeRows = computed<EmployeeRow[]>(() => {
|
||||
// Map user.id -> solde CP de la période courante.
|
||||
const cpByUser = new Map<number, AbsenceBalance>();
|
||||
for (const b of balances.value) {
|
||||
if (b.type === "cp") cpByUser.set(b.user.id, b);
|
||||
}
|
||||
const dash = t("absences.admin.employees.noContract");
|
||||
return employees.value.map((u) => {
|
||||
const cp = cpByUser.get(u.id);
|
||||
return {
|
||||
...u,
|
||||
contractText: u.contractType ?? dash,
|
||||
cpTakenText: cp ? formatDays(cp.taken) : dash,
|
||||
cpRemainingText: cp ? formatDays(cp.available) : dash,
|
||||
};
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Ajouter le chargement et l'ouverture du drawer**
|
||||
|
||||
Après `async function loadBalances() {...}`, ajouter :
|
||||
|
||||
```ts
|
||||
async function loadEmployees() {
|
||||
const all = await useUserService().getAll();
|
||||
employees.value = all.filter((u) => u.isEmployee);
|
||||
}
|
||||
|
||||
function openEmployee(item: Record<string, unknown>) {
|
||||
selectedEmployee.value = item as EmployeeRow;
|
||||
employeeDrawerOpen.value = true;
|
||||
}
|
||||
```
|
||||
|
||||
Puis inclure `loadEmployees()` au montage. Remplacer le `onMounted` existant :
|
||||
|
||||
```ts
|
||||
onMounted(async () => {
|
||||
await Promise.all([reloadRequests(), loadBalances()]);
|
||||
});
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```ts
|
||||
onMounted(async () => {
|
||||
await Promise.all([reloadRequests(), loadBalances(), loadEmployees()]);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 5 : Ajouter le slot d'onglet dans le template**
|
||||
|
||||
Juste après la fermeture du slot `</template>` de l'onglet `#balances` (avant `</MalioTabList>`), ajouter :
|
||||
|
||||
```vue
|
||||
<!-- Employees -->
|
||||
<template #employees>
|
||||
<div class="min-h-[30rem] pt-10">
|
||||
<MalioDataTable
|
||||
:columns="employeeColumns"
|
||||
:items="employeeRows"
|
||||
:total-items="employeeRows.length"
|
||||
:empty-message="$t('absences.admin.employees.empty')"
|
||||
@row-click="openEmployee"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
- [ ] **Step 6 : Monter le drawer employé**
|
||||
|
||||
Après le composant `<AbsenceBalanceAdjustDrawer ... />` (avant `</div>` de fin de template), ajouter :
|
||||
|
||||
```vue
|
||||
<EmployeeDrawer
|
||||
v-model="employeeDrawerOpen"
|
||||
:user="selectedEmployee"
|
||||
@saved="loadEmployees"
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 7 : Vérification navigateur**
|
||||
|
||||
Aller sur `http://localhost:3002/team-absences`, onglet « Employés ». Vérifier : liste des users `isEmployee` avec Nom / Contrat / CP pris / CP restants ; clic sur une ligne ouvre le drawer ; aucune erreur console.
|
||||
|
||||
- [ ] **Step 8 : Commit** (sur feu vert utilisateur)
|
||||
|
||||
```bash
|
||||
git add frontend/pages/team-absences.vue
|
||||
git commit -m "feat(absences) : onglet Employés (liste + ouverture drawer)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4 : Allègement du `UserDrawer`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/components/user/UserDrawer.vue`
|
||||
|
||||
- [ ] **Step 1 : Réduire le bloc RH du template à la seule case**
|
||||
|
||||
Remplacer le bloc (lignes ~74-107) :
|
||||
|
||||
```vue
|
||||
<!-- RH / Absences -->
|
||||
<div class="mt-6 border-t border-neutral-200 pt-4">
|
||||
<MalioCheckbox v-model="form.isEmployee" label="Employé (soumis à la gestion des absences)" />
|
||||
|
||||
<div v-if="form.isEmployee" class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<!-- ... tous les champs détaillés ... -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```vue
|
||||
<!-- RH / Absences -->
|
||||
<div class="mt-6 border-t border-neutral-200 pt-4">
|
||||
<MalioCheckbox v-model="form.isEmployee" label="Employé (soumis à la gestion des absences)" />
|
||||
<p v-if="form.isEmployee" class="mt-2 text-xs text-neutral-500">
|
||||
Les informations RH (contrat, dates, CP…) se gèrent dans Absences équipe → onglet Employés.
|
||||
</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Nettoyer l'état du formulaire**
|
||||
|
||||
Dans `const form = reactive({...})`, supprimer les champs détaillés et ne garder que `isEmployee`. Résultat :
|
||||
|
||||
```ts
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
roles: [] as string[],
|
||||
clientId: null as number | null,
|
||||
allowedProjectIds: [] as number[],
|
||||
isEmployee: false,
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Nettoyer l'hydratation à l'ouverture**
|
||||
|
||||
Dans le `watch(() => props.modelValue, ...)`, supprimer toutes les lignes `form.hireDate = ...` → `form.nbChildren = ...` des deux branches (`props.item` et `else`). Conserver `form.isEmployee = props.item.isEmployee ?? false` (branche édition) et `form.isEmployee = false` (branche création).
|
||||
|
||||
- [ ] **Step 4 : Ne plus envoyer les champs détaillés dans le payload**
|
||||
|
||||
Dans `handleSubmit`, réduire le `payload` aux champs de compte + `isEmployee` :
|
||||
|
||||
```ts
|
||||
const payload: UserWrite = {
|
||||
username: form.username.trim(),
|
||||
roles: form.roles,
|
||||
client: form.clientId !== null ? `/api/clients/${form.clientId}` : null,
|
||||
allowedProjects: form.clientId !== null
|
||||
? form.allowedProjectIds.map((id) => `/api/projects/${id}`)
|
||||
: [],
|
||||
isEmployee: form.isEmployee,
|
||||
}
|
||||
if (form.password) {
|
||||
payload.plainPassword = form.password
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5 : Supprimer les imports/constantes devenus inutiles**
|
||||
|
||||
Dans le `<script setup>` : supprimer `contractOptions` et `familyOptions` (constantes locales) ; retirer `ContractType, FamilySituation` de l'import `~/services/dto/user-data` (garder `UserData, UserWrite`). Vérifier qu'aucune autre référence ne subsiste.
|
||||
|
||||
Run: `cd frontend && grep -n "contractOptions\|familyOptions\|ContractType\|FamilySituation\|hireDate\|nbChildren" components/user/UserDrawer.vue`
|
||||
Expected: aucune ligne (sortie vide).
|
||||
|
||||
- [ ] **Step 6 : Vérification navigateur**
|
||||
|
||||
Aller sur `http://localhost:3002/admin`, ouvrir un utilisateur. Vérifier : seule la case « Employé » + la note ; cocher/décocher et enregistrer fonctionne ; rouvrir un employé déjà renseigné depuis l'onglet Employés → ses champs RH sont intacts (non écrasés par l'enregistrement du UserDrawer). Aucune erreur console.
|
||||
|
||||
- [ ] **Step 7 : Commit** (sur feu vert utilisateur)
|
||||
|
||||
```bash
|
||||
git add frontend/components/user/UserDrawer.vue
|
||||
git commit -m "refactor(users) : UserDrawer ne gère plus que le flag Employé"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5 : Vérification de bout en bout
|
||||
|
||||
**Files:** aucun (vérification navigateur via Chrome DevTools MCP)
|
||||
|
||||
- [ ] **Step 1 : Flux complet**
|
||||
|
||||
1. `admin` → ouvrir un user non-employé → cocher « Employé » → enregistrer.
|
||||
2. `team-absences` → onglet « Employés » → l'utilisateur apparaît.
|
||||
3. Clic sur sa ligne → `EmployeeDrawer` s'ouvre → renseigner dates (JJ/MM/AAAA), contrat, CP annuels → enregistrer.
|
||||
4. La liste se recharge ; rouvrir la ligne → valeurs persistées.
|
||||
5. Retour `admin` sur le même user → seule la case « Employé » (toujours cochée), pas de champ RH.
|
||||
|
||||
- [ ] **Step 2 : Contrôle console**
|
||||
|
||||
Vérifier l'absence d'erreurs/warnings Vue sur les trois écrans (admin, onglet Employés, drawer).
|
||||
|
||||
- [ ] **Step 3 : Commit final éventuel** (sur feu vert utilisateur)
|
||||
|
||||
Si des ajustements ont été faits pendant la vérification, les committer avec un message approprié.
|
||||
|
||||
---
|
||||
|
||||
## Self-review (couverture du spec)
|
||||
|
||||
- UserDrawer réduit à la case « Employé » → Task 4. ✅
|
||||
- Onglet « Employés » admin-only (page déjà `middleware: ["admin"]`) → Task 3. ✅
|
||||
- Liste Nom · Contrat · CP pris · CP restants (users ⋈ soldes cp) → Task 3 (Steps 3-5). ✅
|
||||
- Drawer d'édition en composants Malio (MalioDate/MalioSelect/MalioInputText) → Task 2. ✅
|
||||
- Persistance via `usersService.update` (PATCH partiel) → Task 2 (Step 1, `save`). ✅
|
||||
- i18n regroupé sous `absences.admin.employees.*` + onglet → Task 1. ✅
|
||||
- Pas de placeholder, types cohérents (`EmployeeRow`, `UserData`, `ContractType`/`FamilySituation`), noms de méthodes alignés (`loadEmployees`, `openEmployee`, `save`). ✅
|
||||
2519
docs/superpowers/plans/2026-05-22-mcp-absence-crud-tools.md
Normal file
2519
docs/superpowers/plans/2026-05-22-mcp-absence-crud-tools.md
Normal file
File diff suppressed because it is too large
Load Diff
239
docs/superpowers/specs/2026-05-20-workflow-ui-fixes-design.md
Normal file
239
docs/superpowers/specs/2026-05-20-workflow-ui-fixes-design.md
Normal file
@@ -0,0 +1,239 @@
|
||||
# Specs — Correctifs UI suite au système de workflow + UX mail/modales
|
||||
|
||||
> Date : 2026-05-20
|
||||
> Contexte : suite à l'introduction des **workflows** (`docs/superpowers/specs/2026-05-19-project-workflows-design.md`),
|
||||
> plusieurs régressions UI et points UX sont apparus. Reviews faites par Lucile Schnödt et Tristan Schnödtin.
|
||||
> Ce document liste les 7 chantiers à traiter, avec problème, fichiers concernés, solution validée et points ouverts.
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
| # | Chantier | Type | Décision |
|
||||
|---|----------|------|----------|
|
||||
| 1 | Drag & drop cassé dans « mes tâches » | régression workflow | Drop = changer de statut (gérer ambiguïté multi-statuts) |
|
||||
| 2 | Sélecteur de statut mélange les workflows | régression workflow | Filtrer par le workflow du projet de la tâche |
|
||||
| 3 | Cartes de tâche non responsive (tags) | UI | Refonte responsive + troncature |
|
||||
| 4 | Couleurs de statut du workflow de base | data/UI | Remettre la palette classique |
|
||||
| 5 | Bouton « lier un mail » dans l'onglet mail d'un ticket | UX mail | Supprimer le bouton |
|
||||
| 6 | Création de ticket depuis un mail | UX mail | + sélecteur user, + sélecteur statut (remplace priorité), modale agrandie |
|
||||
| 7 | Footer collant des modales centrées | UX global | Composant modale réutilisable (header / body scrollable / footer sticky) |
|
||||
| 8 | Sélecteur de catégorie en `<select>` natif (WorkflowDrawer) | UI/dette | Migrer vers `MalioSelect` (la lib supporte les valeurs `string`) |
|
||||
|
||||
## Décisions actées (2026-05-21)
|
||||
|
||||
Suite à la reproduction des bugs sur données prod (import local) et discussion :
|
||||
|
||||
- **#1** — Désambiguïsation au drop : **popover** de choix quand la catégorie cible a ≥2 statuts (0 → drop refusé + feedback ; 1 → PATCH direct ; ≥2 → popover ancré au point de drop). Résolution **par tâche** (workflow de son projet).
|
||||
- **#2** — Source des statuts : `project.workflow.statuses` (déjà embarqué dans `GET /projects` et `task.project.workflow`). Le statut courant est ajouté en tête s'il est hors du workflow (pas de perte à l'enregistrement).
|
||||
- **#3** — Cartes : `min-w-0` partout, titre `line-clamp-2`, badges `truncate`, **2-3 tags max + badge « +N »** (tooltip), hauteur de carte fixe.
|
||||
- **#4** — Deux facettes :
|
||||
- **(a) Statuts** (badges cartes + kanban projet) : dérive **data en prod** (fixtures OK). Correction par **migration Doctrine** idempotente remettant les hex classiques sur le workflow Standard.
|
||||
- **(b) Catégories** (entêtes multi-projets de « Mes tâches », aujourd'hui grises) : nouvelle constante front **`STATUS_CATEGORY_COLOR`** (5 hex classiques) → **bandeau teinté** sur les entêtes, **texte auto noir/blanc** selon la luminance.
|
||||
- **(c)** Couleur par défaut **par catégorie** dans `addStatus()` (au lieu de `#222783` systématique) pour éviter une nouvelle dérive.
|
||||
- **#5** — Suppression du bouton « Lier un mail » + `MailPickerModal` + état/handler + clé i18n (`MailPickerModal` n'est utilisé que par TaskModal — vérifié).
|
||||
- **#6** — + sélecteur **user** (défaut pré-rempli) ; priorité **remplacée** par sélecteur **statut** (filtré workflow, rechargé au changement de projet) ; **`priority`/`priorityId` retiré** du payload et de l'endpoint backend ; statut par défaut = **1er statut du workflow par `position`** ; modale élargie via #7.
|
||||
- **#7** — Composant `frontend/components/ui/AppModal.vue` (header fixe / body `flex-1 min-h-0 overflow-y-auto` / footer `shrink-0` sticky, `max-h-[90vh]`, prop `width`). Migration d'abord : TaskModal puis MailCreateTaskModal.
|
||||
- **#8** — ✅ Fait : `<select>` catégorie → `MalioSelect` (la lib accepte `value: string | number | null` ; note CLAUDE.md corrigée).
|
||||
|
||||
---
|
||||
|
||||
## 1. Drag & drop cassé dans « mes tâches »
|
||||
|
||||
**Problème.** Le drag & drop des cartes entre colonnes ne fonctionne plus depuis l'arrivée des workflows.
|
||||
|
||||
**Fichiers.**
|
||||
- `frontend/pages/my-tasks.vue` — colonnes kanban construites sur les **catégories canoniques** fixes (`CATEGORIES = ['todo','in_progress','blocked','review','done']`, ~l.118-127), template kanban ~l.396-424.
|
||||
- `frontend/components/task/TaskCard.vue` — `@dragstart` / `@dragend` HTML5 natif (~l.154-162), `dataTransfer` = `task.id`.
|
||||
- Pas de librairie externe (HTML5 natif).
|
||||
|
||||
**Cause racine.** Les colonnes sont des **catégories canoniques** (la vue agrège plusieurs projets/workflows, donc on ne peut pas afficher une colonne par statut d'un workflow précis). Or un workflow peut désormais mapper **plusieurs statuts sur une même catégorie** (ex. deux statuts « in_progress »). Au drop dans une colonne, le statut cible devient ambigu — ce qui a cassé / rendu indéterminé le changement de statut.
|
||||
|
||||
**Solution retenue.** Drop dans une colonne ⇒ change le statut de la tâche vers un statut de cette catégorie **dans le workflow du projet de la tâche**. Gestion de l'ambiguïté :
|
||||
- Récupérer les statuts du workflow du projet de la tâche déposée, filtrés par la catégorie de la colonne cible.
|
||||
- **0 statut** dans cette catégorie pour ce workflow → drop refusé (feedback visuel, pas de changement).
|
||||
- **1 statut** → appliquer directement.
|
||||
- **≥ 2 statuts** → afficher un mini-sélecteur (popover/menu) au point de drop pour choisir le statut exact.
|
||||
|
||||
**Points ouverts.**
|
||||
- ⚠️ **À trancher demain** : confirmer la stratégie de désambiguïsation (popover au drop vs. choisir le statut de plus petite `position` dans la catégorie). Le popover est plus sûr mais plus de travail ; le « plus petite position » est transparent mais peut surprendre.
|
||||
- Ajouter les **handlers de drop** sur les colonnes (`@dragover.prevent`, `@drop`) — actuellement absents dans `my-tasks.vue`.
|
||||
- Comme la vue est multi-projets, la résolution du statut cible doit se faire **par tâche** (selon son projet → son workflow), pas globalement.
|
||||
|
||||
---
|
||||
|
||||
## 2. Sélecteur de statut mélange les statuts de tous les workflows
|
||||
|
||||
**Problème.** En ouvrant une tâche (modale), le sélecteur « Statut » liste **tous les statuts globaux**, donc ceux du workflow de base **et** ceux des autres workflows.
|
||||
|
||||
**Fichiers.**
|
||||
- `frontend/components/task/TaskModal.vue` — `<MalioSelect v-model="form.statusId" :options="statusOptions" />` (~l.103-109) ; `statusOptions = props.statuses.map(...)` (~l.674-676).
|
||||
- `frontend/pages/my-tasks.vue` — `:statuses="statuses"` (~l.489), chargés via `statusService.getAll()` (~l.132).
|
||||
- `frontend/services/task-statuses.ts` — `getAll()` → `GET /task_statuses` (tous les statuts).
|
||||
- DTO : `frontend/services/dto/task-status.ts` — le champ `workflow` existe déjà (`{ '@id', id } | string`).
|
||||
|
||||
**Solution retenue.** Le sélecteur ne doit proposer que les statuts du **workflow du projet de la tâche éditée**.
|
||||
- Filtrer `statusOptions` sur `status.workflow` correspondant au workflow du projet de la tâche.
|
||||
- Source du filtre : soit filtrer côté front la liste déjà chargée par `workflow.id`, soit charger les statuts du workflow via le service workflow (`useWorkflowService()` existe mais n'est pas utilisé ici).
|
||||
- S'assurer que le statut courant de la tâche reste affiché même si édge case (ex. statut hérité d'un ancien workflow).
|
||||
|
||||
**Points ouverts.**
|
||||
- Vérifier que la tâche/le projet expose bien l'`@id`/`id` du workflow côté payload pour faire le filtre sans appel supplémentaire.
|
||||
|
||||
---
|
||||
|
||||
## 3. Cartes de tâche non responsive (tags mal placés / trop gros)
|
||||
|
||||
**Problème.** Depuis l'ajout d'éléments dans la carte (statut, priorité, effort, tags, deadline, avatars…), les tags débordent ou se chevauchent quand les textes sont longs ; la carte n'est plus responsive.
|
||||
|
||||
**Fichiers.**
|
||||
- `frontend/components/task/TaskCard.vue` — badges statut (~l.43-49) et tags (~l.58-64) : `rounded-full px-2 py-0.5 text-xs font-semibold text-white`, **sans `truncate`, sans `max-w`, sans `line-clamp`** ; conteneur `flex` sans contrainte de largeur (~l.42-106).
|
||||
|
||||
**Solution retenue.** Refonte du layout de la carte pour rester contenue quelle que soit la longueur des textes :
|
||||
- Conteneur des badges en `flex flex-wrap gap-1` (retour à la ligne propre).
|
||||
- Tags : `max-w-[...] truncate` (ou `line-clamp-1`) + `title`/tooltip pour le texte complet au survol.
|
||||
- Hiérarchiser l'info : titre prioritaire (`line-clamp-2`), badges secondaires qui passent à la ligne ou se condensent.
|
||||
- Option : limiter le nombre de tags affichés (ex. 2-3 + « +N »).
|
||||
|
||||
**Points ouverts.**
|
||||
- ⚠️ Choix d'UX à valider : `flex-wrap` (tous les tags visibles, carte plus haute) vs. troncature « +N » (hauteur fixe). Décision visuelle à prendre demain (éventuellement via mockup).
|
||||
|
||||
---
|
||||
|
||||
## 4. Couleurs de statut du workflow de base à remettre
|
||||
|
||||
**Problème.** Les couleurs « classiques » des statuts du workflow de base ont changé ; il faut remettre les couleurs d'origine.
|
||||
|
||||
**Investigation faite.** Le commit `4775cbf` (palette élargie 9→18 teintes + couleur perso) ne touche **que** `ColorPicker.vue` et `ProjectDrawer.vue` — il n'a pas modifié les couleurs des statuts. Les couleurs d'origine du **workflow Standard** sont dans les fixtures :
|
||||
|
||||
| Catégorie | Statut | Hex classique |
|
||||
|---|---|---|
|
||||
| todo | À faire | `#222783` |
|
||||
| in_progress | En cours | `#4A90D9` |
|
||||
| blocked | Bloqué | `#C62828` |
|
||||
| review | En attente de validation | `#FF8F00` |
|
||||
| done | Terminé | `#26A69A` |
|
||||
|
||||
Source : `src/DataFixtures/AppFixtures.php:101-105` (statuts rattachés au `$standardWorkflow`, ~l.93-116).
|
||||
Migration de rattachement : `migrations/Version20260519175114.php` (attache les statuts existants au workflow Standard).
|
||||
|
||||
**Solution retenue.** Remettre ces 5 couleurs sur les statuts du **workflow Standard/de base**.
|
||||
- Vérifier en prod (et en base de dev si dérive) que les statuts du workflow Standard portent bien ces hex ; corriger ceux qui ont dérivé (via l'UI d'admin des statuts, ou un script/migration de correction des couleurs).
|
||||
- Ne **pas** toucher aux couleurs des statuts des autres workflows.
|
||||
|
||||
**Points ouverts.**
|
||||
- Confirmer où la dérive a eu lieu (prod vs. nouveaux workflows créés via l'UI avec d'autres couleurs). Si c'est un workflow Standard en prod avec des couleurs erronées → correction data ; si c'est par défaut à la création d'un workflow → ajuster les couleurs proposées par défaut.
|
||||
|
||||
---
|
||||
|
||||
## 5. Supprimer le bouton « lier un mail » dans l'onglet mail d'un ticket
|
||||
|
||||
**Problème.** En ouvrant un ticket, l'onglet mail propose un bouton « lier un mail » qui n'a pas d'utilité de ce côté.
|
||||
|
||||
**Fichiers.**
|
||||
- `frontend/components/task/TaskModal.vue` — bouton « lier un mail » (~l.486-494, `mail.taskTab.linkButton`, ouvre `showMailPickerModal`) ; `<MailPickerModal>` (~l.498-503), visible si `isEditing && isMailUser`, onglet `mails`.
|
||||
- Composant lié : `frontend/components/mail/MailPickerModal.vue`.
|
||||
|
||||
**Solution retenue.** Supprimer le bouton « lier un mail » de l'onglet mail du ticket.
|
||||
- Retirer le bouton et, si plus aucun usage, le `MailPickerModal` associé + l'état `showMailPickerModal` + `handleMailLinked`.
|
||||
- Nettoyer la clé i18n `mail.taskTab.linkButton` si elle n'est plus utilisée ailleurs.
|
||||
|
||||
**Points ouverts.**
|
||||
- Vérifier que `MailPickerModal` n'est pas réutilisé ailleurs avant de le supprimer (sinon ne retirer que le bouton).
|
||||
|
||||
---
|
||||
|
||||
## 6. Création d'un ticket depuis un mail
|
||||
|
||||
**Problème.** Le formulaire de création depuis un mail est incomplet et la modale dépasse de l'écran.
|
||||
|
||||
**Fichiers.**
|
||||
- `frontend/components/mail/MailCreateTaskModal.vue` — champs actuels (~l.119-224) : info source (lecture seule), Projet (requis), Groupe (optionnel), **Priorité** (optionnelle). Footer non sticky (~l.210-224).
|
||||
- `frontend/services/mail.ts` — `createTaskFromMail()` (~l.184-192) → `POST /mail/messages/{id}/create-task` avec `{ projectId, taskGroupId, priority }`.
|
||||
|
||||
**Solution retenue.**
|
||||
1. **Sélecteur de user (assigné).** Ajouter un sélecteur d'utilisateur. Un user par défaut est déjà appliqué ; le sélecteur permet d'en choisir un autre. (Charger la liste via le service users.)
|
||||
2. **Statut à la place de la priorité.** **Retirer** le sélecteur de priorité, le **remplacer** par un sélecteur de statut. Le statut proposé doit respecter le workflow du projet sélectionné (cf. chantier #2 — réutiliser la même logique de filtrage par workflow). Au changement de projet, recharger les statuts du workflow correspondant.
|
||||
3. **Agrandir la modale.** Élargir la largeur (et gérer la hauteur via body scrollable + footer sticky, cf. chantier #7) pour qu'elle ne dépasse plus.
|
||||
4. **Backend.** Adapter le payload / l'endpoint `create-task` : accepter `assigneeId` (ou IRI user) et `statusId` (ou IRI statut) ; retirer/garder `priority` selon décision (ici : remplacé par statut côté UI — décider si on garde la priorité par défaut côté backend ou non).
|
||||
|
||||
**Points ouverts.**
|
||||
- Confirmer le statut **par défaut** présélectionné (statut initial du workflow du projet).
|
||||
- Décider si la priorité disparaît totalement du payload ou reste à une valeur par défaut côté backend.
|
||||
|
||||
---
|
||||
|
||||
## 7. Footer collant pour les modales centrées
|
||||
|
||||
**Problème.** Les modales qui s'ouvrent au milieu de l'écran ont leurs boutons d'action en bas, qui défilent avec le contenu et finissent hors écran. On veut un footer **toujours visible**.
|
||||
|
||||
**Constat.** Il n'existe **aucun composant modale réutilisable** (`MalioModal`/`AppModal`). Chaque modale réimplémente `Teleport` + `Transition` + header/body/footer. Footers actuels en `border-t` mais **non sticky** :
|
||||
- `frontend/components/task/TaskModal.vue` (footer ~l.507-549)
|
||||
- `frontend/components/mail/MailCreateTaskModal.vue` (~l.210-224)
|
||||
- `frontend/components/mail/MailLinkTaskModal.vue` (~l.226-239)
|
||||
- `frontend/components/mail/MailPickerModal.vue` (~l.187-201)
|
||||
- `frontend/components/ui/ConfirmDeleteTaskModal.vue` (~l.11-24)
|
||||
- `frontend/components/project/ProjectWorkflowSwitchModal.vue` (~l.63-76)
|
||||
|
||||
**Solution retenue (décision : composant réutilisable).** Créer un composant modale réutilisable dans `frontend/components/ui/` (ex. `AppModal.vue` / `MalioModal.vue` local) :
|
||||
- Structure : `Teleport to="body"` + `Transition`, overlay `fixed inset-0`, conteneur avec **hauteur max** (`max-h-[90vh]`), en flex-col :
|
||||
- **header** (titre + fermeture) fixe en haut,
|
||||
- **body** `flex-1 overflow-y-auto`,
|
||||
- **footer** sticky en bas (`shrink-0 border-t`), via slot `#footer`.
|
||||
- Props : largeur (`sm`/`md`/`lg`/`xl`), titre, `v-model` ouverture ; slots `default` (body) et `footer`.
|
||||
- **Migration progressive** des modales existantes vers ce composant (commencer par celles citées par les reviews : TaskModal, MailCreateTaskModal). Ne pas tout migrer d'un coup.
|
||||
|
||||
**Points ouverts.**
|
||||
- Nom et emplacement définitifs du composant.
|
||||
- Ordre de migration (au minimum : MailCreateTaskModal #6 et TaskModal #2/#5, qui sont déjà touchées par les autres chantiers).
|
||||
|
||||
---
|
||||
|
||||
## 8. Sélecteur de catégorie en `<select>` natif (WorkflowDrawer) → MalioSelect
|
||||
|
||||
**Problème.** Dans l'éditeur de statut d'un workflow, le champ « Catégorie » est un `<select>` HTML natif, visuellement incohérent avec le reste des formulaires (qui utilisent `MalioSelect`).
|
||||
|
||||
**Investigation faite (2026-05-21).** La note de `Lesstime/CLAUDE.md` affirmait que `MalioSelect` n'accepte que des `value: number | null` et qu'il fallait un `<select>` natif pour les enums string. **C'est faux.** Vérifié dans la source `@malio/layer-ui` (v1.4.8 installée **et** repo dev `malio-layer-ui`) :
|
||||
|
||||
```ts
|
||||
// app/components/malio/select/Select.vue
|
||||
type Option = { label: string; value: string | number | null } // string supporté
|
||||
const props = withDefaults(defineProps<{ modelValue: string | number | null; ... }>(), ...)
|
||||
emit('update:modelValue', v: string | number | null)
|
||||
// normalizedOptions n'ajoute l'option vide {value:null} QUE si empty-option-label est passé
|
||||
```
|
||||
|
||||
La comparaison interne utilise `===`, donc les valeurs `string` (ex. l'enum `StatusCategory` : `todo | in_progress | blocked | review | done`) fonctionnent nativement.
|
||||
|
||||
**Fichiers.**
|
||||
- `frontend/components/admin/WorkflowDrawer.vue` — `<select v-model="s.category">` (~l.49-57), `categoryOptions: { value: StatusCategory, label }[]` (~l.145-151).
|
||||
- Note corrigée : `Lesstime/CLAUDE.md` (section « Frontend »).
|
||||
|
||||
**Solution retenue (faite).** Remplacer le `<select>` natif par :
|
||||
|
||||
```vue
|
||||
<MalioSelect
|
||||
v-model="s.category"
|
||||
:options="categoryOptions"
|
||||
label="Catégorie"
|
||||
min-width="w-44"
|
||||
group-class="shrink-0"
|
||||
/>
|
||||
```
|
||||
|
||||
- Pas d'`empty-option-label` (catégorie requise → pas d'option « Aucune » `null`).
|
||||
- `min-width="w-44"` pour rester compact dans la ligne `flex` (sinon défaut `w-96`).
|
||||
|
||||
**Points ouverts / suite possible.**
|
||||
- D'autres `<select>` natifs subsistent pour des enums string et pourraient être migrés de la même façon (candidats : `AdminClientTicketTab.vue`, `AdminMailTab.vue`, `ProjectClientTickets.vue`, `ProjectWorkflowSwitchModal.vue`, `TaskModal.vue`). À traiter au cas par cas, hors scope immédiat.
|
||||
|
||||
---
|
||||
|
||||
## Découpage suggéré pour l'implémentation
|
||||
|
||||
Regrouper par zone pour limiter les conflits :
|
||||
|
||||
- **Lot A — Workflow / statuts** : #2 (filtrage statut) → réutilisé par #1 (D&D) et #6 (statut à la création mail).
|
||||
- **Lot B — Cartes** : #3 (responsive) + #4 (couleurs classiques).
|
||||
- **Lot C — Mail** : #5 (suppr. bouton) + #6 (form création) — dépend de #2 et #7.
|
||||
- **Lot D — Modale réutilisable** : #7 (composant) puis migration de TaskModal et MailCreateTaskModal.
|
||||
|
||||
Ordre conseillé : **#7 (composant modale)** et **#2 (filtrage statut)** d'abord (briques réutilisées), puis #1, #6, #5, puis #3/#4.
|
||||
@@ -0,0 +1,119 @@
|
||||
# Refonte UX du formulaire « Nouvelle demande d'absence »
|
||||
|
||||
Date : 2026-05-21
|
||||
Composant : `frontend/components/absence/AbsenceRequestDrawer.vue`
|
||||
Branche : `feat/absence-management`
|
||||
|
||||
## Contexte & problème
|
||||
|
||||
Le formulaire actuel fonctionne mais est inutilisable côté UX :
|
||||
|
||||
- `VueDatePicker` brut **non thématisé** (couleur violette par défaut, calendrier au rendu anglo-saxon / semaine au dimanche) → aspect « cassé », rien à voir avec PayFit.
|
||||
- `<label>` et `<input type="file">` bruts au lieu des composants Malio.
|
||||
- **Aucune erreur explicite** : le bouton « Valider » est simplement grisé via `canSubmit` quand un champ manque, sans dire pourquoi.
|
||||
- `preview` qui échoue en silence (`catch { preview.value = null }`).
|
||||
- Solde insuffisant signalé par un simple warning ambre.
|
||||
|
||||
Cible : reproduire l'ergonomie PayFit (référence fournie par l'utilisateur) — apparition progressive des champs, deux champs date saisissables + popup, pills de demi-journée, lignes de solde, erreurs explicites.
|
||||
|
||||
## Objectifs
|
||||
|
||||
1. Look & ergonomie alignés sur PayFit, cohérents avec le design system Malio.
|
||||
2. Apparition **progressive** des champs au fil des choix.
|
||||
3. **Erreurs explicites** par champ, affichées au clic « Valider », qui se vident dès correction.
|
||||
4. Champs de date en français `JJ / MM / AAAA`, calendrier FR / semaine au lundi.
|
||||
5. Justificatif via `MalioInputUpload`, affiché seulement si le type l'exige.
|
||||
|
||||
## Hors périmètre
|
||||
|
||||
- Aucun changement backend : payload (`AbsenceRequestWrite`), endpoints `create` / `preview` / `uploadJustification` inchangés.
|
||||
- Aucune modification des autres composants du module (traités séparément dans les points #2 à #7 de la revue).
|
||||
|
||||
## Layout cible (drawer `max-w-xl`, 1 colonne, apparition progressive)
|
||||
|
||||
**Étape 1 — toujours visible**
|
||||
- `Type d'absence` : `MalioSelect` (options = policies actives). Erreur inline si vide au submit.
|
||||
|
||||
**Étape 2 — apparaît dès qu'un type est choisi**
|
||||
- `Date de début` : champ texte `JJ / MM / AAAA` saisissable + icône calendrier (popup) + bouton effacer. `VueDatePicker` mono-date, thématisé (voir ci-dessous).
|
||||
- Pills demi-journée début : `Journée entière` | `Matin` | `Après-midi` (défaut : Journée entière).
|
||||
- Ligne `Solde au <date début> : X jours` (valeur = `preview.available`, alignée à droite, **non repliable**).
|
||||
|
||||
**Étape 3 — apparaît dès que la date de début est renseignée**
|
||||
- `Date de fin` : même composant que la date de début.
|
||||
- Pills demi-journée fin : `Journée entière` | `Matin` (défaut : Journée entière).
|
||||
- Ligne `Durée de la demande : N jours` (valeur = `preview.countedDays`).
|
||||
- Ligne `Solde après validation : Y jours` (valeur = `preview.projectedAvailable`, non repliable). Si `< 0` → bandeau ambre **non bloquant** « solde après = … j, demande soumise pour validation ».
|
||||
|
||||
**Étape 4**
|
||||
- `Justificatif` : `MalioInputUpload`, affiché **uniquement si** `selectedPolicy.justificationRequired`. Label avec `*`. Erreur inline si requis et absent.
|
||||
- `Commentaire` (optionnel) : `MalioInputTextArea`, placeholder « Écrire un commentaire… ».
|
||||
|
||||
**Footer**
|
||||
- `[ Annuler (tertiary) ] [ Valider (primary) ]`, bouton **toujours actif**.
|
||||
|
||||
## Datepicker — thématisation
|
||||
|
||||
Reprendre le pattern de `frontend/components/ui/DateFilter.vue` :
|
||||
|
||||
- `VueDatePicker` mono-date, `:enable-time-picker="false"`, `auto-apply`, `text-input` activé (saisie clavier), `format` → `JJ/MM/AAAA`.
|
||||
- `locale="fr"` (chaîne) + semaine au lundi (`week-start: 1`).
|
||||
- Variables CSS `--dp-primary-color: #222783`, `--dp-border-color`, `--dp-hover-color`, `--dp-font-size`, `font-family: inherit` (scopées au composant).
|
||||
- `:min-date` sur la date de fin = date de début ; `:max-date` sur la date de début = date de fin.
|
||||
|
||||
## Pills demi-journée → payload
|
||||
|
||||
Segment de boutons (style pill, bordure + fond bleu primaire quand sélectionné).
|
||||
|
||||
| Pill début | `startHalfDay` | Pill fin | `endHalfDay` |
|
||||
|------------|----------------|----------|--------------|
|
||||
| Journée entière | `null` | Journée entière | `null` |
|
||||
| Matin | `matin` | Matin | `matin` |
|
||||
| Après-midi | `apres_midi` | — | — |
|
||||
|
||||
Cas particulier — **demande sur une seule journée** (`startDate == endDate`) : seules les pills de début sont pertinentes (`Journée entière` / `Matin` / `Après-midi`) ; les pills de fin sont masquées et `endHalfDay` recopie `startHalfDay`. Le décompte reste calculé par `preview` côté backend.
|
||||
|
||||
## Validation
|
||||
|
||||
État réactif `errors: { type?: string; startDate?: string; endDate?: string; justification?: string }`.
|
||||
|
||||
`validate()` (au clic « Valider ») remplit `errors` :
|
||||
- `type` manquant → `absences.form.errors.typeRequired`
|
||||
- `startDate` manquante → `absences.form.errors.startRequired`
|
||||
- `endDate` manquante → `absences.form.errors.endRequired`
|
||||
- `endDate < startDate` → `absences.form.errors.endBeforeStart`
|
||||
- `preview.countedDays <= 0` → `absences.form.errors.zeroDays`
|
||||
- justificatif requis et absent → `absences.form.errors.justificationRequired`
|
||||
|
||||
Si `errors` non vide → on stoppe la soumission. Des `watch` vident chaque message dès que le champ correspondant redevient valide.
|
||||
|
||||
Le solde insuffisant **n'est pas** une erreur bloquante (seul le bandeau ambre).
|
||||
|
||||
### Erreurs serveur
|
||||
- `service.create` / `uploadJustification` : le toast d'erreur de `useApi` reste ; en plus, un bandeau d'erreur en tête de formulaire affiche le message renvoyé (422 de validation).
|
||||
- `preview` : conserver le `catch` pour les coupures réseau, mais ne plus masquer une erreur de validation de période (afficher le message si l'API en renvoie un).
|
||||
|
||||
## Calcul live (preview)
|
||||
|
||||
Inchangé sur le principe : `watch` debouncé (300 ms) sur `[type, startDate, endDate, startHalfDay, endHalfDay]` → `service.preview(payload)`. Les lignes de solde et de durée se mettent à jour à partir du résultat (`available`, `countedDays`, `projectedAvailable`).
|
||||
|
||||
## i18n
|
||||
|
||||
Nouvelles clés sous `absences.form.errors.*` dans `frontend/i18n/locales/fr.json` :
|
||||
`typeRequired`, `startRequired`, `endRequired`, `endBeforeStart`, `zeroDays`, `justificationRequired`, plus `absences.form.balanceAt` (« Solde au {date} ») et `absences.form.duration` (« Durée de la demande »).
|
||||
|
||||
## Découpage du composant
|
||||
|
||||
`AbsenceRequestDrawer.vue` orchestre l'état du formulaire. Pour garder le fichier focalisé, extraire :
|
||||
- `AbsenceDateField.vue` : champ date thématisé + pills demi-journée (props : `modelValue`, `halfValue`, `halfOptions`, `label`, `error`, `min`/`max`).
|
||||
|
||||
Le reste (lignes de solde, bandeau, footer) reste inline dans le drawer.
|
||||
|
||||
## Critères d'acceptation
|
||||
|
||||
- Au chargement, seul « Type d'absence » est visible ; les sections suivantes apparaissent au fur et à mesure.
|
||||
- Les dates s'affichent et se saisissent en `JJ / MM / AAAA`, calendrier en français, semaine au lundi, thème bleu primaire.
|
||||
- Cliquer « Valider » sans remplir → messages d'erreur explicites sous les champs concernés ; ils disparaissent dès correction.
|
||||
- Un type à justificatif obligatoire affiche le champ d'upload et bloque la soumission tant qu'aucun fichier n'est fourni.
|
||||
- Une période dépassant le solde affiche le bandeau ambre mais reste soumissible.
|
||||
- Le payload envoyé au backend est identique à l'actuel.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Mise en conformité légale du module Absences — Design
|
||||
|
||||
> **Date** : 2026-05-22
|
||||
> **Branche** : `feat/absence-management`
|
||||
> **Origine** : audit `2026-05-22-absence-legal-compliance-review.md`. Ce design ne traite QUE les corrections retenues (périmètre arrêté avec l'utilisateur). Les points « lourds » sont explicitement reportés.
|
||||
|
||||
## Objectif
|
||||
|
||||
Corriger les non-conformités légales **structurantes mais à faible risque** du module Absences, sans multiplier les types/paramètres (préférence utilisateur : gérer le détail via le motif plutôt que par des paramètres structurés).
|
||||
|
||||
## Décisions cadrées
|
||||
|
||||
- **Décès** : un seul type, le lien de parenté est saisi dans le **motif** (rendu obligatoire) ; pas d'éclatement par lien.
|
||||
- **Points lourds reportés en backlog** (NON traités ici) : congés d'ancienneté Syntec + arrondi légal, acquisition de CP pendant arrêt maladie (loi 2024-364), politique de rétention/purge des justificatifs.
|
||||
|
||||
## Périmètre (6 corrections)
|
||||
|
||||
### 1. Les événements familiaux ne décrémentent plus de solde
|
||||
**Problème** : `AbsenceType::decrementsBalance()` renvoie `true` pour tout sauf la maladie. Mariage/PACS, décès et congé parental décrémentent donc un « solde » par (type, année) qui n'a **aucune acquisition** (artefact sémantique). Légalement, ces congés sont des **droits par événement**, non déduits d'un solde annuel.
|
||||
|
||||
**Solution** : `decrementsBalance()` ne renvoie `true` que pour `PaidLeave` (CP).
|
||||
```php
|
||||
return self::PaidLeave === $this;
|
||||
```
|
||||
Conséquence : pour tous les autres types, `AbsenceBalanceService::reservePending/applyApproval/release` deviennent des no-op (déjà gardés par `shouldTrack()`/`decrementsBalance()`). Les demandes restent enregistrées, validées et décomptées (`countedDays`), mais ne touchent aucun `AbsenceBalance`.
|
||||
|
||||
**Impact données** : les fixtures ne doivent plus semer de `AbsenceBalance` pour des types ≠ CP (à vérifier dans `AppFixtures`). Aucune migration (pas de changement de schéma).
|
||||
|
||||
### 2. Décès — motif obligatoire, plus de forfait trompeur
|
||||
**Problème** : forfait unique `daysPerEvent = 3` < minimum légal (enfant 5 j + deuil 8 j ; conjoint/parent/fratrie 3 j).
|
||||
|
||||
**Solution** :
|
||||
- Policy décès : `daysPerEvent = null` (« selon lien de parenté », pas de forfait codé en dur faux).
|
||||
- **Motif obligatoire** pour le type décès, contrôlé à la création dans **les deux** chemins : `AbsenceRequestProcessor` (REST) et `CreateAbsenceRequestTool` (MCP). Erreur explicite si `reason` vide.
|
||||
- Les minimums légaux sont rappelés dans l'aide `/help` (06-absences) ; l'admin accorde le nombre de jours correct en validant les dates.
|
||||
|
||||
### 3. Ajout du congé naissance
|
||||
**Problème** : type absent.
|
||||
|
||||
**Solution** : nouveau cas `AbsenceType::Birth = 'naissance'`.
|
||||
- `label()` = « Naissance ».
|
||||
- `decrementsBalance()` = `false` (couvert par le point 1).
|
||||
- Policy par défaut (fixtures) : `daysPerEvent = 3`, `justificationRequired = true`, `countWorkingDaysOnly = true`, `active = true`.
|
||||
- Frontend : ajouter « naissance » à la liste des types proposés (form Nouvelle demande) + libellés i18n + couleur de badge dans `useAbsenceHelpers`.
|
||||
|
||||
### 4. Congé parental = suspension
|
||||
**Problème** : modélisé comme décompte de solde.
|
||||
|
||||
**Solution** : couvert par le point 1 (`decrementsBalance()` false). Le congé parental reste enregistré comme **absence longue justifiée**, sans impact solde. `daysPerYear`/`daysPerEvent` restent `null`. Justificatif requis (policy inchangée si déjà à true).
|
||||
|
||||
### 5. Garde-fou demi-journée
|
||||
**Problème** : `AbsenceDayCalculator::countWorkingDays` retire `-0,5` pour `startHalfDay`/`endHalfDay` **sans vérifier** que le jour-borne est réellement décompté (week-end/férié). Sous-décompte possible.
|
||||
|
||||
**Solution** : n'appliquer la déduction de demi-journée que si le jour-borne (début pour `startHalfDay`, fin pour `endHalfDay`) est un jour effectivement compté. Couvert par un **test unitaire** dédié dans `AbsenceDayCalculatorTest` (TDD : test rouge d'abord).
|
||||
|
||||
### 6. Documenter la convention collective
|
||||
**Problème** : hypothèses Syntec codées en dur, CCN nulle part formalisée.
|
||||
|
||||
**Solution** : paramètre de config `app.absence.convention` (valeur `"Syntec (IDCC 1486)"`), affiché :
|
||||
- en sous-titre de l'onglet admin « Absences » (`AdminAbsencePolicyTab`) ;
|
||||
- dans l'aide `/help` (06-absences).
|
||||
Aucune logique conditionnée à cette valeur (purement documentaire/affichage).
|
||||
|
||||
## Hors périmètre (backlog, documenté dans le rapport d'audit)
|
||||
|
||||
- Congés d'ancienneté Syntec (1/2/3/4 j à 5/10/15/20 ans) + arrondi légal à l'entier supérieur.
|
||||
- Acquisition de CP pendant arrêt maladie (loi 2024-364, 2 j ouvrables/mois plafonnés).
|
||||
- Politique de rétention/purge des justificatifs (données de santé) et des demandes.
|
||||
- Contrôle de solde négatif à l'approbation (comportement « poser le N en cours » volontairement conservé).
|
||||
- Alsace-Moselle (jours fériés spécifiques).
|
||||
|
||||
## Tests & validation
|
||||
|
||||
- **TDD** : test rouge → vert pour le garde-fou demi-journée (point 5) dans `tests/Unit/Service/AbsenceDayCalculatorTest.php`.
|
||||
- Test du motif décès obligatoire (création MCP) dans le cycle de vie existant ou un test ciblé.
|
||||
- `make test` au vert (49 + nouveaux), `lint:container` OK, `doctrine:schema:validate` OK (aucune migration), `php-cs-fixer` propre.
|
||||
- Build Nuxt OK (changements front : type naissance + i18n + libellés CCN).
|
||||
- Vérif fonctionnelle : créer une demande décès sans motif → refus ; avec motif → OK et **aucun** `AbsenceBalance` créé pour le décès.
|
||||
|
||||
## Composants touchés
|
||||
|
||||
- `src/Enum/AbsenceType.php` (decrementsBalance, cas Birth, label).
|
||||
- `src/State/AbsenceRequestProcessor.php` + `src/Mcp/Tool/Absence/CreateAbsenceRequestTool.php` (motif décès obligatoire).
|
||||
- `src/Service/AbsenceDayCalculator.php` (garde-fou demi-journée).
|
||||
- `src/DataFixtures/AppFixtures.php` (policy naissance, décès daysPerEvent=null, plus de balance non-CP).
|
||||
- `config/services.yaml` (param convention) + `AdminAbsencePolicyTab.vue` + `06-absences.md` (affichage CCN).
|
||||
- `frontend/composables/useAbsenceHelpers.ts` + `frontend/components/absence/AbsenceRequestDrawer.vue` + `i18n/locales/fr.json` (type naissance).
|
||||
- Tests : `AbsenceDayCalculatorTest`, `AbsenceRequestLifecycleTest`.
|
||||
@@ -0,0 +1,115 @@
|
||||
# Audit de conformité légale & RGPD — Module « Gestion des absences » (Lesstime)
|
||||
|
||||
> **Date** : 2026-05-22
|
||||
> **Périmètre** : code backend (`src/Enum`, `src/Entity`, `src/Service`, `src/State`, `src/Command`, `src/Controller/Absence`), fixtures, migrations et front (`frontend/components/absence`, `frontend/pages/absences.vue`, `team-absences.vue`).
|
||||
> **Nature** : audit documentaire. **Aucune modification de code** n'a été réalisée.
|
||||
> **Avertissement** : ce document est une analyse technique de conformité, pas un avis juridique formel. Faire valider par un conseil RH/juridique avant mise en production paie.
|
||||
|
||||
---
|
||||
|
||||
## 1. Convention collective applicable
|
||||
|
||||
### Constat
|
||||
Le code (`AbsencePolicy` par défaut, fixtures `AppFixtures.php`) part d'hypothèses « Syntec » sans le formaliser : 25 jours **ouvrés** de CP, période de référence **1er juin → 31 mai** (`referencePeriodStart = '06-01'`), décompte en jours ouvrés par défaut (`countWorkingDaysOnly = true`).
|
||||
|
||||
### Convention probable
|
||||
Pour une société d'édition de logiciels / services informatiques (« bureau informatique »), c'est très majoritairement la **Convention collective nationale Syntec — IDCC 1486** (« Bureaux d'études techniques, cabinets d'ingénieurs-conseils et sociétés de conseils ») qui s'applique. C'est cohérent avec les valeurs codées (25 jours ouvrés, décompte en jours ouvrés).
|
||||
|
||||
### ⚠️ Ambiguïté à lever
|
||||
La convention applicable **ne se déduit pas de l'activité seule** : elle dépend du **code APE/NAF** de l'entreprise et de l'activité principale réelle. Une SSII/éditeur peut relever de Syntec, mais certaines structures relèvent d'autres CCN. **À confirmer** sur le bulletin de paie ou auprès de l'expert-comptable. Le module **code en dur** des hypothèses Syntec mais ne stocke nulle part la CCN de référence : à documenter.
|
||||
|
||||
### Sources
|
||||
- Code du travail / congés payés : https://www.service-public.gouv.fr/particuliers/vosdroits/F2258
|
||||
- Syntec — congés payés (25 jours ouvrés, ancienneté) : https://www.legisocial.fr/conventions-collectives-nationales/1486-syntec-bureaux-etudes-techniques-cabinets-ingenieurs-conseils-societes/conges-payes-indemnites-conges-sans-solde.html
|
||||
- Syntec — congés événements familiaux (Code du travail numérique) : https://code.travail.gouv.fr/contribution/1486-les-conges-pour-evenements-familiaux
|
||||
- Texte de base Syntec (Légifrance) : https://www.legifrance.gouv.fr/conv_coll/id/KALIARTI000044253087/?idConteneur=KALICONT000005635173
|
||||
|
||||
---
|
||||
|
||||
## 2. Conformité par type d'absence
|
||||
|
||||
Valeurs implémentées : voir `AppFixtures.php` (lignes 648-667) et `User::$annualLeaveDays = 25.0`.
|
||||
|
||||
| Type | Minimum légal / Syntec | Implémenté | Verdict |
|
||||
|------|------------------------|------------|---------|
|
||||
| **Congés payés (CP)** | Légal : 2,5 j ouvrables/mois = 30 j ouvrables/an (5 sem.). Syntec : 25 j ouvrés/an = équivalent 30 j ouvrables. Période réf. 01/06→31/05. | `daysPerYear = 25`, décompte jours ouvrés, période `06-01`, acquisition 1/12 par mois (`AccrueLeaveCommand`). | ✅ **Conforme** sur le principe. ⚠️ Réserves : (a) pas de **règle d'arrondi** légal à l'entier supérieur (art. L3141-7) ; (b) pas de **congés d'ancienneté Syntec** (1 j à 5 ans, 2 j à 10 ans, 3 j à 15 ans, 4 j à 20 ans) ; (c) acquisition figée à `annualLeaveDays/12` sans assimilation des périodes d'arrêt maladie (voir §3). |
|
||||
| **Mariage / PACS** | Légal : **4 jours minimum** (L3142-4). Syntec : 4 jours ouvrés. | `daysPerEvent = 4`, justificatif requis. | ✅ **Conforme**. |
|
||||
| **Décès proche** | Légal : **enfant = 5 j min** (et jusqu'à 7 j + congé deuil 8 j si enfant <25 ans) ; conjoint/partenaire = 3 j ; père/mère/beau-parent/frère/sœur = 3 j (L3142-4). Syntec : barème ≥ légal, dont **jusqu'à ~22 j** pour décès enfant <25 ans. | **Un seul** `Bereavement` à `daysPerEvent = 3`, sans distinction du lien de parenté. | ❌ **Non conforme**. Un forfait unique de 3 j est **inférieur au minimum légal** pour le décès d'un enfant (5 j) et ignore le congé de deuil (8 j) et le barème Syntec. Dérive : sous-attribution de droits. |
|
||||
| **Naissance** | Légal : **3 jours min** (en sus du congé paternité). | **Type absent** de l'enum `AbsenceType`. | ❌ **Manquant**. La naissance n'est pas gérée ; aucun congé naissance ni paternité. |
|
||||
| **Congé parental d'éducation** | Cadre L1225-47 s. : suspension du contrat, durée 1 an renouvelable 2×, jusqu'aux 3 ans de l'enfant, **non rémunéré**, ancienneté 1 an. | `ParentalLeave` : `daysPerYear`/`daysPerEvent` null, `decrementsBalance() = true` (décrémente un solde). | ⚠️ **Modélisation incorrecte**. Le congé parental est une **suspension longue** (mois/années), pas un décompte sur solde annuel. Le traiter comme une absence qui « décrémente un solde » (vide ici, donc 0) n'a pas de sens métier. À modéliser comme suspension, sans solde. |
|
||||
| **Arrêt maladie** | Indemnisé par la Sécu ; **ne se déduit jamais des CP**. Depuis loi 2024-364 du 22/04/2024 : acquiert **2 j ouvrables/mois** de CP (maladie non pro). | `SickLeave` : `decrementsBalance() = false` → ne touche aucun solde. | ✅ **Conforme** sur le non-décompte. ⚠️ **Manque** : l'acquisition de CP pendant l'arrêt (2 j/mois) n'est pas implémentée — `AccrueLeaveCommand` crédite `annualLeaveDays/12` quel que soit le statut, sans tenir compte des arrêts. Impact paie potentiel. |
|
||||
|
||||
### Décompte des jours (`AbsenceDayCalculator`)
|
||||
- ✅ Dimanche jamais compté ; samedi compté seulement en « jours ouvrables » ; jours fériés exclus ; demi-journées aux bornes à -0,5. Logique **correcte**.
|
||||
- ✅ `PublicHolidayProvider` : 11 jours fériés métropole + Pâques/Ascension/Pentecôte via Computus. Correct **hors Alsace-Moselle** (Vendredi saint + 26/12 non gérés — documenté comme hors périmètre, à signaler si salariés concernés).
|
||||
- ⚠️ La **demi-journée à -0,5** est appliquée sans vérifier que la borne tombe sur un jour décompté : si `startHalfDay` est posé alors que le jour de début est un week-end/férié (non compté), on retire quand même 0,5, ce qui peut **sous-décompter**. Cas limite à border côté validation.
|
||||
|
||||
---
|
||||
|
||||
## 3. Constats RGPD / dérive
|
||||
|
||||
### 🔴 BLOQUANT — Fuite des données RH/familiales à tous les utilisateurs authentifiés
|
||||
**Fichiers** : `src/Entity/User.php` (lignes 32-37, groupes `user:list`), `config/packages/security.yaml` (ligne 69).
|
||||
|
||||
Les opérations API `GET /api/users` (collection) et `GET /api/users/{id}` n'ont **aucun attribut `security`** : seule la règle globale `^/api → IS_AUTHENTICATED_FULLY` s'applique. **N'importe quel salarié** (`ROLE_USER`) peut donc lister tous ses collègues et lire le groupe `user:list`, qui expose : `familySituation`, `nbChildren`, `hireDate`, `endDate`, `contractType`, `workTimeRatio`, `annualLeaveDays`, `initialLeaveBalance` et **`roles`**.
|
||||
|
||||
C'est une violation directe des principes RGPD de **minimisation** et de **limitation d'accès** (référentiel CNIL RH) : la situation familiale et le nombre d'enfants d'un collègue n'ont aucune raison d'être accessibles à un pair. Gravité maximale (donnée personnelle sensible au sens RH diffusée largement).
|
||||
|
||||
### 🟠 MOYEN — Collecte de `familySituation` / `nbChildren` non justifiée (non-minimisation)
|
||||
**Fichiers** : `User.php` (119-125), `EmployeeDrawer.vue`, `Serializer.php` (MCP, 392-393).
|
||||
|
||||
Recherche d'usage exhaustive : ces deux champs sont **stockés, saisis dans le formulaire RH et exposés (API + MCP), mais ne sont utilisés dans AUCUN calcul** d'absence (ni `AbsenceDayCalculator`, ni `AbsenceBalanceService`, ni `AccrueLeaveCommand`, ni les policies). Or :
|
||||
- Le module gère le décès via un forfait unique sans lien de parenté → `familySituation`/`nbChildren` ne servent pas au congé décès.
|
||||
- La naissance et le congé parental ne sont pas calculés à partir de `nbChildren`.
|
||||
|
||||
Conséquence : **collecte sans finalité opérationnelle** = violation du principe de minimisation (art. 5.1.c RGPD). Soit ces champs servent réellement un calcul légal (alors les implémenter et le documenter dans le registre des traitements), soit ils doivent être **supprimés**. En l'état c'est une **dérive de collecte**. De plus la situation familiale peut révéler indirectement l'orientation/la vie privée → prudence renforcée.
|
||||
|
||||
### 🟠 MOYEN — Exposition de données RH via le MCP
|
||||
**Fichier** : `src/Mcp/Tool/Serializer.php` (392-393). Le serializer MCP renvoie `familySituation` et `nbChildren`. Le serveur MCP HTTP **pointe sur la PROD** (cf. mémoire projet). Toute intégration MCP (assistant IA, scripts) peut donc aspirer ces données familiales. À restreindre / retirer du payload MCP.
|
||||
|
||||
### 🟡 MINEUR — Justificatif et motif : données potentiellement sensibles
|
||||
**Fichiers** : `AbsenceRequest::$reason`, `justificationFileName`, `AbsenceJustification*Controller`.
|
||||
- Le **contrôle d'accès au justificatif est correct** (propriétaire ou admin uniquement, `AbsenceJustificationDownloadController` lignes 38-40). ✅
|
||||
- Mais un justificatif d'**arrêt maladie** peut contenir des **données de santé**. Le champ `reason` (texte libre) peut aussi en contenir. Recommandations CNIL : ne stocker que le **volet administratif**, durée de conservation limitée, accès tracé. Aucune **politique de rétention/purge** n'est implémentée (fichiers et demandes conservés indéfiniment).
|
||||
|
||||
### 🟡 MINEUR — Approbation sans contrôle de solde
|
||||
**Fichier** : `AbsenceReviewProcessor.php`. L'approbation déplace les jours de `pending` vers `taken` **sans vérifier** que le solde disponible est suffisant — un solde peut devenir négatif. Pas un problème RGPD mais une **dérive de calcul** (congés non acquis posables sans alerte). À noter : `getAvailable()` autorise volontairement de poser les CP « en cours d'acquisition » (N), ce qui est un **choix d'entreprise admis** mais à confirmer (certaines entreprises n'autorisent que le N-1).
|
||||
|
||||
### ✅ Points conformes
|
||||
- Accès aux `AbsenceRequest` / `AbsenceBalance` : les providers (`AbsenceRequestProvider`, `AbsenceBalanceProvider`) **filtrent bien** par propriétaire pour un non-admin (un salarié ne voit que ses propres demandes/soldes). Le filtre `user` n'est appliqué que si `ROLE_ADMIN`. ✅
|
||||
- Approbation/rejet/suppression réservés à `ROLE_ADMIN`. ✅
|
||||
- Upload justificatif limité au propriétaire/admin, MIME validé côté serveur, taille plafonnée 10 Mo. ✅
|
||||
|
||||
---
|
||||
|
||||
## 4. Recommandations actionnables
|
||||
|
||||
### Priorité 1 — Bloquant (à corriger avant prod)
|
||||
1. **Restreindre `GET /api/users` et `GET /api/users/{id}`** : ajouter `security: "is_granted('ROLE_ADMIN')"` sur ces opérations, OU créer un groupe de sérialisation « annuaire » minimal (id, username, avatar) pour les non-admins et réserver `user:list` (champs RH) à l'admin via un contexte conditionné par le rôle. Retirer impérativement `familySituation`, `nbChildren`, `hireDate`, `endDate`, `contractType`, `annualLeaveDays`, `initialLeaveBalance`, `roles` de toute vue accessible à `ROLE_USER`.
|
||||
|
||||
### Priorité 2 — Moyen
|
||||
2. **Trancher sur `familySituation` / `nbChildren`** : soit les supprimer (recommandé tant qu'aucun calcul ne les utilise), soit les rattacher à une finalité réelle (barème décès/naissance) et les inscrire au registre des traitements. Les retirer du payload MCP (`Serializer.php`).
|
||||
3. **Refondre le congé décès** : remplacer le forfait unique 3 j par un barème par lien de parenté conforme au minimum légal (enfant ≥ 5 j + deuil 8 j ; conjoint/parent/fratrie 3 j) et au barème Syntec applicable.
|
||||
4. **Ajouter le congé naissance** (3 j min) et, le cas échéant, le congé paternité.
|
||||
5. **Remodéliser le congé parental** comme suspension de contrat (sans décompte de solde annuel).
|
||||
|
||||
### Priorité 3 — Mineur / robustesse
|
||||
6. **Acquisition CP pendant arrêt maladie** : aligner `AccrueLeaveCommand` sur la loi 2024-364 (2 j ouvrables/mois pour maladie non pro, plafond annuel).
|
||||
7. **Congés d'ancienneté Syntec** : implémenter le barème (1/2/3/4 j à 5/10/15/20 ans) et l'arrondi légal à l'entier supérieur.
|
||||
8. **Politique de rétention** : définir une durée de conservation des `AbsenceRequest`, `reason` et justificatifs (santé), avec purge automatique ; tracer les accès aux justificatifs.
|
||||
9. **Contrôle de solde à l'approbation** + garde-fou demi-journée sur jour non décompté dans `AbsenceDayCalculator`.
|
||||
10. **Documenter la CCN de référence** dans la config (ne pas la coder en dur implicitement) et confirmer Syntec via le code APE de l'entreprise.
|
||||
|
||||
---
|
||||
|
||||
## Sources (règles légales citées)
|
||||
- Congés payés (2,5 j/mois, 30 j, période 01/06→31/05) : https://www.service-public.gouv.fr/particuliers/vosdroits/F2258
|
||||
- Jours ouvrés vs ouvrables : https://www.juritravail.com/Actualite/decompte-du-nombre-de-jours-de-conges-payes-les-jours-ouvrables-et-les-jours-ouvres-comment-faire/Id/2161
|
||||
- Syntec — CP & ancienneté : https://www.legisocial.fr/conventions-collectives-nationales/1486-syntec-bureaux-etudes-techniques-cabinets-ingenieurs-conseils-societes/conges-payes-indemnites-conges-sans-solde.html
|
||||
- Syntec — événements familiaux : https://code.travail.gouv.fr/contribution/1486-les-conges-pour-evenements-familiaux
|
||||
- Code du travail — congés événements familiaux (L3142-1 s.) : https://www.legifrance.gouv.fr/codes/section_lc/LEGITEXT000006072050/LEGISCTA000006195795/
|
||||
- Congé de deuil / décès enfant : https://travail-emploi.gouv.fr/les-conges-pour-evenements-familiaux-et-le-conge-de-deuil
|
||||
- Congé parental d'éducation (L1225-47 s.) : https://www.legifrance.gouv.fr/codes/id/LEGISCTA000006195596/
|
||||
- CP pendant arrêt maladie (loi 2024-364) : https://code.travail.gouv.fr/information/acquisition-de-conges-payes-pendant-un-arret-maladie-les-nouvelles-regles
|
||||
- CNIL — données de santé / employeur : https://www.cnil.fr/fr/cnil-direct/question/donnees-sur-la-sante-un-employeur-peut-il-les-connaitre
|
||||
- CNIL — référentiel gestion RH (minimisation) : https://www.cnil.fr/sites/default/files/atoms/files/referentiel_grh_novembre_2019_0.pdf
|
||||
@@ -0,0 +1,96 @@
|
||||
# Réorganisation de la gestion des employés (module Absences)
|
||||
|
||||
Date : 2026-05-22
|
||||
Branche : `feat/absence-management`
|
||||
Statut : design approuvé, prêt pour plan d'implémentation
|
||||
|
||||
## Contexte
|
||||
|
||||
Aujourd'hui, le `UserDrawer` (admin → utilisateurs) porte deux responsabilités mélangées :
|
||||
|
||||
1. l'administration du compte (nom, mot de passe, rôles, client, projets) ;
|
||||
2. **tout le détail RH/employé** : case « Employé » + un bloc de champs (date d'embauche, date de sortie, type de contrat, situation familiale, temps de travail, CP annuels, début de période de référence, solde CP initial, nombre d'enfants).
|
||||
|
||||
Ces champs existent déjà sur l'entité `User` (backend) et dans le DTO `UserData`/`UserWrite` (frontend). La persistance se fait via l'API user (`PATCH /api/users/{id}`).
|
||||
|
||||
On veut séparer ces deux préoccupations : le `UserDrawer` ne décide plus que **si un utilisateur est un employé** ; l'édition des informations RH se fait dans un espace dédié, dans le module Absences.
|
||||
|
||||
## Objectifs
|
||||
|
||||
- `UserDrawer` : ne conserver que la case à cocher « Employé ».
|
||||
- `team-absences` : ajouter un onglet « Employés » (la page est déjà `middleware: ["admin"]`, donc admin-only).
|
||||
- L'onglet liste les utilisateurs marqués `isEmployee` avec leurs soldes de congés.
|
||||
- Un drawer dédié permet d'éditer les informations RH d'un employé.
|
||||
|
||||
Hors périmètre : création d'utilisateur (reste dans l'admin), modification du flag `isEmployee` ailleurs que dans le `UserDrawer`, backend (déjà en place).
|
||||
|
||||
## Composants
|
||||
|
||||
### 1. `frontend/components/user/UserDrawer.vue`
|
||||
|
||||
- Supprimer le bloc détaillé employé (`hireDate`, `endDate`, `contractType`, `familySituation`, `workTimeRatio`, `annualLeaveDays`, `referencePeriodStart`, `initialLeaveBalance`, `nbChildren`).
|
||||
- Conserver **uniquement** la case `isEmployee` (« Employé (soumis à la gestion des absences) »).
|
||||
- Le payload de sauvegarde du user **n'envoie plus** les champs détaillés, pour ne pas les écraser. Il continue d'envoyer `isEmployee` et les champs de compte existants.
|
||||
- Nettoyer l'état du formulaire et les imports devenus inutiles (options contrat / situation familiale si elles ne servent plus que là).
|
||||
|
||||
### 2. `frontend/pages/team-absences.vue` — onglet « Employés »
|
||||
|
||||
- Ajouter un 4ᵉ onglet dans `tabs` : `{ key: 'employees', label: t('absences.admin.tabs.employees'), icon: 'mdi:account-group' }`.
|
||||
- Slot `#employees` : `MalioDataTable` avec les colonnes **Nom · Contrat · CP pris · CP restants**.
|
||||
- Clic sur une ligne → ouvre `EmployeeDrawer` avec l'utilisateur sélectionné (cohérent avec l'onglet Demandes qui ouvre le détail au clic ligne).
|
||||
- Chargement des données :
|
||||
- `usersService.getAll()` filtré sur `isEmployee === true` ;
|
||||
- `absenceService.getBalances({ type: 'cp' })` → map par `user.id` pour récupérer `taken` (CP pris) et `available` (CP restants) ;
|
||||
- fusion en lignes de tableau (`taken`/`available` à `—` si pas de solde CP pour l'employé).
|
||||
- Recharger la liste après `saved` du drawer.
|
||||
|
||||
### 3. `frontend/components/absence/EmployeeDrawer.vue` (nouveau)
|
||||
|
||||
- Props : `modelValue: boolean`, `user: UserData | null`.
|
||||
- Events : `update:modelValue`, `saved`.
|
||||
- Formulaire en **composants Malio** :
|
||||
- `MalioDate` : `hireDate`, `endDate` (valeurs ISO `YYYY-MM-DD`) ;
|
||||
- `MalioSelect` : `contractType` (CDI/CDD/Stage/Alternance/Autre), `familySituation` (Célibataire/Marié/Pacsé/Divorcé/Veuf) ;
|
||||
- `MalioInputText` : `workTimeRatio`, `annualLeaveDays`, `referencePeriodStart` (MM-DD), `initialLeaveBalance`, `nbChildren`.
|
||||
- À l'ouverture, initialiser le formulaire depuis `props.user` ; remettre à jour si `user` change.
|
||||
- Sauvegarde : `usersService.update(user.id, { …champs employé })` ; à la réussite, émettre `saved` et fermer.
|
||||
- En-tête : nom de l'employé.
|
||||
|
||||
### 4. i18n (`frontend/i18n/locales/fr.json`)
|
||||
|
||||
Nouvelles clés regroupées sous `absences.admin.employees.*` (et l'onglet sous `absences.admin.tabs.employees`) :
|
||||
- onglet : `absences.admin.tabs.employees` = « Employés » ;
|
||||
- colonnes liste : `absences.admin.employees.columns.{name,contract,cpTaken,cpRemaining}` ;
|
||||
- drawer : `absences.admin.employees.drawer.title` + libellés de champs sous `absences.admin.employees.fields.*`.
|
||||
|
||||
Les libellés de contrat et de situation familiale (CDI/CDD/…, Célibataire/Marié/…) actuellement codés en dur dans `UserDrawer` sont déplacés avec leur drawer ; on les passe en clés i18n à cette occasion.
|
||||
|
||||
## Flux de données
|
||||
|
||||
```
|
||||
UserDrawer --PATCH isEmployee--> User (backend)
|
||||
|
|
||||
team-absences (onglet Employés) |
|
||||
getAll() ∩ isEmployee <-----------+
|
||||
getBalances({type:'cp'}) --> map user.id -> {taken, available}
|
||||
=> lignes tableau (nom, contrat, CP pris, CP restants)
|
||||
|
|
||||
clic ligne
|
||||
v
|
||||
EmployeeDrawer(user) --usersService.update--> User (backend)
|
||||
|
|
||||
@saved --> recharge liste
|
||||
```
|
||||
|
||||
## Découpage / responsabilités
|
||||
|
||||
- `UserDrawer` : compte + flag employé. Ne connaît plus le détail RH.
|
||||
- Onglet Employés : vue dérivée en lecture (jointure users ⋈ soldes), aiguille vers le drawer.
|
||||
- `EmployeeDrawer` : seule unité qui édite les champs RH ; interface claire (`user` en entrée, `saved` en sortie), testable isolément.
|
||||
|
||||
## Vérification
|
||||
|
||||
- `UserDrawer` : ouvrir un user, vérifier qu'il ne reste que la case « Employé », cocher/décocher et sauvegarder sans perte des autres champs RH (qui ne sont plus envoyés).
|
||||
- Onglet Employés : la liste affiche les users `isEmployee` avec CP pris/restants cohérents avec l'onglet Soldes.
|
||||
- `EmployeeDrawer` : éditer un employé (dates en JJ/MM/AAAA, selects FR), sauvegarder, vérifier la persistance (recharge) et l'absence d'erreurs console.
|
||||
- Vérification navigateur via Chrome DevTools sur les trois écrans.
|
||||
@@ -0,0 +1,141 @@
|
||||
# Spec — Extension des outils MCP : module Absences + trous CRUD
|
||||
|
||||
Date : 2026-05-22
|
||||
Branche : `feat/absence-management`
|
||||
|
||||
## Contexte & objectif
|
||||
|
||||
Le serveur MCP Lesstime (`src/Mcp/Tool/`) expose aujourd'hui les projets, tâches,
|
||||
métadonnées de tâches, time tracking et workflows. Le nouveau **module Absences**
|
||||
(`AbsenceRequest`, `AbsencePolicy`, `AbsenceBalance`) n'est pas exposé, et plusieurs
|
||||
entités existantes n'ont qu'une couverture partielle (souvent `list` sans
|
||||
create/update/delete).
|
||||
|
||||
Objectif : permettre de piloter l'app via MCP (assistant) sur ces domaines, en
|
||||
respectant strictement la logique métier déjà en place.
|
||||
|
||||
## Conventions reprises de l'existant
|
||||
|
||||
- Une classe par outil, attribut `#[McpTool(name, description)]` sur la **classe**.
|
||||
- Discovery automatique : `config/packages/mcp.yaml` scanne `src/` (exclut `DataFixtures`).
|
||||
Aucune config à ajouter — créer la classe suffit.
|
||||
- Constructeur : injection de repos/services + `Security`.
|
||||
- `__invoke(...)` : check de rôle en première ligne (`AccessDeniedException` sinon),
|
||||
validation des IDs (`InvalidArgumentException` si introuvable), retour `json_encode(...)`.
|
||||
- Sérialisation centralisée dans `App\Mcp\Tool\Serializer`.
|
||||
- Rôle : `ROLE_USER` pour la lecture/écriture courante ; `ROLE_ADMIN` pour les
|
||||
opérations sensibles (déjà le cas pour `list-clients`/`list-users`, et pour les
|
||||
opérations admin du module absences).
|
||||
|
||||
## Décision clé — réutilisation de la logique métier (pas les Processors)
|
||||
|
||||
Les Processors API Platform (`AbsenceRequestProcessor`, `AbsenceReviewProcessor`,
|
||||
`AbsenceCancelProcessor`) sont liés à `Security::getUser()` (l'utilisateur courant)
|
||||
et à l'`Operation` HTTP. En MCP, l'utilisateur courant est le **propriétaire du
|
||||
token** (admin), or on veut pouvoir agir **au nom d'un employé**.
|
||||
|
||||
→ Les outils MCP **n'appellent pas les Processors** ; ils répliquent leur
|
||||
orchestration en réutilisant les **services partagés** qui portent la vraie règle
|
||||
métier :
|
||||
|
||||
- `AbsenceDayCalculator::countWorkingDays(...)` — calcul des jours décomptés.
|
||||
- `AbsenceBalanceService` — `reservePending`, `applyApproval`, `release`, `periodFor`.
|
||||
- `AbsencePolicyRepository::findOneByType(...)` — politique active du type.
|
||||
- `AbsenceRequestRepository::hasOverlap(...)` — règle anti-chevauchement.
|
||||
|
||||
`create-absence-request` prend un `userId` explicite (l'employé cible) ;
|
||||
`review`/`cancel` posent `reviewedBy` = utilisateur du token MCP.
|
||||
|
||||
Ainsi soldes (`pending`/`taken`/`acquired`) et statuts restent cohérents avec ce
|
||||
que produit l'UI.
|
||||
|
||||
## Inventaire des outils
|
||||
|
||||
### Module Absences — `src/Mcp/Tool/Absence/` (10 outils)
|
||||
|
||||
| Outil | Rôle | Paramètres | Logique |
|
||||
|---|---|---|---|
|
||||
| `list-absence-requests` | USER | `userId?`, `status?`, `type?`, `from?`, `to?` | Filtre ; sans `userId` renvoie tout (token admin). |
|
||||
| `get-absence-request` | USER | `id` | — |
|
||||
| `create-absence-request` | USER | `userId`, `type`, `startDate`, `endDate`, `startHalfDay?`, `endHalfDay?`, `reason?` | Vérifie policy active + overlap, calcule `countedDays`, refuse si ≤ 0, statut `Pending`, `reservePending`. |
|
||||
| `review-absence-request` | ADMIN | `id`, `decision` (`approve`\|`reject`), `rejectionReason?` | Seulement si `Pending`. Approve → `applyApproval` ; reject → `rejectionReason` requis + `release(false)`. Pose `reviewedAt`/`reviewedBy`. |
|
||||
| `cancel-absence-request` | USER | `id` | `Pending` → `release(false)` ; `Approved` → ADMIN requis + `release(true)` ; sinon conflit. Statut `Cancelled`. |
|
||||
| `delete-absence-request` | ADMIN | `id` | Suppression définitive. |
|
||||
| `list-absence-policies` | USER | — | Toutes les policies (ordre `type`). |
|
||||
| `update-absence-policy` | ADMIN | `id`, `daysPerYear?`, `daysPerEvent?`, `justificationRequired?`, `noticeDays?`, `countWorkingDaysOnly?`, `active?` | Seuls les champs fournis changent. |
|
||||
| `list-absence-balances` | USER | `userId?`, `type?`, `period?` | Soldes filtrés. |
|
||||
| `update-absence-balance` | ADMIN | `id`, `acquired?`, `acquiring?`, `taken?` | Ajustement manuel (régularisation). |
|
||||
|
||||
`type` et `status` acceptés en valeur d'enum string (ex. `cp`, `maladie`,
|
||||
`pending`) ; erreur de validation explicite si invalide. `startDate`/`endDate`
|
||||
au format `YYYY-MM-DD`.
|
||||
|
||||
### Trous CRUD sur l'existant
|
||||
|
||||
**Projets / groupes**
|
||||
- `delete-project` (ADMIN) — `id`.
|
||||
- `delete-group` (USER) — `id`.
|
||||
|
||||
**Métadonnées de tâches** — `src/Mcp/Tool/TaskMeta/`
|
||||
- `create-tag` / `update-tag` / `delete-tag` (USER) — `label`, `color?`.
|
||||
- `create-effort` / `update-effort` / `delete-effort` (USER) — `label` (+ ordre éventuel).
|
||||
- `create-priority` / `update-priority` / `delete-priority` (USER) — `label`, `color?`.
|
||||
- `create-status` / `update-status` / `delete-status` (ADMIN) — **`workflowId` requis**
|
||||
+ `category` (`todo`|`in_progress`|`blocked`|`review`|`done`), `label`, `color?`,
|
||||
`position?`, `isFinal?`. (Les statuts ne sont PAS globaux : ils appartiennent à un workflow.)
|
||||
|
||||
**Clients** — `src/Mcp/Tool/Reference/` (ADMIN, aligné sur `list-clients`)
|
||||
- `get-client` — `id`.
|
||||
- `create-client` — `name` (+ `email?`, `phone?`, `street?`, `city?`, `postalCode?`).
|
||||
- `update-client` — `id` + champs optionnels.
|
||||
- `delete-client` — `id`.
|
||||
|
||||
**Utilisateurs** — `src/Mcp/Tool/Reference/` (ADMIN)
|
||||
- `get-user` — `id` (profil complet RH).
|
||||
- `update-user` — `id` + champs RH/profil : `isEmployee?`, `hireDate?`, `endDate?`,
|
||||
`contractType?`, `workTimeRatio?`, `annualLeaveDays?`, `referencePeriodStart?`,
|
||||
`initialLeaveBalance?`, `familySituation?`, `nbChildren?`.
|
||||
**Hors périmètre (décision utilisateur) : pas de `create-user`, pas de modification
|
||||
de `password` ni `roles` via MCP.**
|
||||
|
||||
## Sérialisation — ajouts à `Serializer.php`
|
||||
|
||||
- `absenceRequest(AbsenceRequest)` : id, user{id,username}, type{value,label},
|
||||
startDate, endDate, startHalfDay, endHalfDay, countedDays, reason, status{value,label},
|
||||
rejectionReason, createdAt, reviewedAt, reviewedBy, justificationFileName.
|
||||
- `absencePolicy(AbsencePolicy)` : id, type{value,label}, daysPerYear, daysPerEvent,
|
||||
justificationRequired, noticeDays, countWorkingDaysOnly, active.
|
||||
- `absenceBalance(AbsenceBalance)` : id, user, type{value,label}, period, acquired,
|
||||
acquiring, taken, pending, acquiredTotal, available.
|
||||
- `client(Client)` : id, name, email, phone, street, city, postalCode.
|
||||
- `userFull(User)` : id, username, roles, isEmployee, hireDate, endDate, contractType,
|
||||
workTimeRatio, annualLeaveDays, referencePeriodStart, initialLeaveBalance,
|
||||
familySituation, nbChildren.
|
||||
|
||||
## Mise à jour de la doc MCP
|
||||
|
||||
`config/packages/mcp.yaml` — bloc `instructions` :
|
||||
- corriger la mention « statuses … are GLOBAL » (faux : par workflow) ;
|
||||
- ajouter une phrase sur le domaine Absences (requests/policies/balances, lifecycle
|
||||
approve/reject/cancel, `userId` pour agir au nom d'un employé).
|
||||
|
||||
## Découpage du plan d'implémentation (jalons)
|
||||
|
||||
1. **Absences** : Serializer + 10 outils + tests de cohérence des soldes.
|
||||
2. **Métadonnées tâches** : delete-project/group, CRUD tag/effort/priority/status.
|
||||
3. **Clients & users** : CRUD clients, get/update user + maj `mcp.yaml`.
|
||||
|
||||
Chaque jalon est livrable et testable indépendamment.
|
||||
|
||||
## Tests
|
||||
|
||||
Tests fonctionnels MCP (si une infra de test MCP existe) ou tests unitaires sur la
|
||||
réplication de la logique de solde : créer → review(approve) → cancel et vérifier
|
||||
`pending`/`taken` à chaque étape ; vérifier le refus sur chevauchement et sur
|
||||
plage sans jour ouvré.
|
||||
|
||||
## Hors périmètre
|
||||
|
||||
- Mail, BookStack, Gitea, Zimbra, Notifications, TaskDocument (non demandés).
|
||||
- Création d'utilisateurs et gestion mot de passe/rôles via MCP.
|
||||
- Upload de justificatif d'absence via MCP.
|
||||
69
frontend/components/absence/AbsenceBalanceAdjustDrawer.vue
Normal file
69
frontend/components/absence/AbsenceBalanceAdjustDrawer.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="open" drawer-class="max-w-md">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ $t('absences.admin.adjust.title') }}</h2>
|
||||
</template>
|
||||
<div v-if="balance" class="flex flex-col gap-4">
|
||||
<p class="text-sm text-neutral-600">
|
||||
{{ balance.user.username }} · {{ balance.label }} · {{ balance.period }}
|
||||
</p>
|
||||
<MalioInputNumber v-model="acquired" :label="$t('absences.admin.adjust.acquired')" />
|
||||
<MalioInputNumber v-model="acquiring" :label="$t('absences.admin.adjust.acquiring')" />
|
||||
<MalioInputNumber v-model="taken" :label="$t('absences.admin.adjust.taken')" />
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<MalioButton :label="$t('common.cancel')" variant="tertiary" @click="open = false" />
|
||||
<MalioButton :label="$t('absences.admin.adjust.save')" :disabled="submitting" @click="submit" />
|
||||
</div>
|
||||
</div>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsenceBalance } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
balance: AbsenceBalance | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'adjusted': []
|
||||
}>()
|
||||
|
||||
const service = useAbsenceService()
|
||||
|
||||
const open = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
// MalioInputNumber works with string values (v-model is string | null).
|
||||
const acquired = ref('0')
|
||||
const acquiring = ref('0')
|
||||
const taken = ref('0')
|
||||
const submitting = ref(false)
|
||||
|
||||
watch(() => props.balance, (b) => {
|
||||
acquired.value = String(b?.acquired ?? 0)
|
||||
acquiring.value = String(b?.acquiring ?? 0)
|
||||
taken.value = String(b?.taken ?? 0)
|
||||
}, { immediate: true })
|
||||
|
||||
async function submit() {
|
||||
if (!props.balance) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await service.adjustBalance(props.balance.id, {
|
||||
acquired: Number(acquired.value) || 0,
|
||||
acquiring: Number(acquiring.value) || 0,
|
||||
taken: Number(taken.value) || 0,
|
||||
})
|
||||
emit('adjusted')
|
||||
open.value = false
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
119
frontend/components/absence/AbsenceBalanceCards.vue
Normal file
119
frontend/components/absence/AbsenceBalanceCards.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="balances.length === 0" class="rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-6 text-center text-sm text-neutral-500">
|
||||
{{ $t('absences.noBalance') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="rounded-xl border border-neutral-200 bg-white p-5 shadow-sm">
|
||||
<!-- Primary balance, highlighted -->
|
||||
<div v-if="primary" class="flex items-start justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-neutral-500">{{ primary.label }}</p>
|
||||
<p class="mt-1 text-3xl font-bold text-neutral-900">
|
||||
{{ formatNumber(primary.available) }}
|
||||
<span class="text-lg font-normal text-neutral-400">/ {{ formatNumber(acquiredTotal(primary)) }}</span>
|
||||
</p>
|
||||
<p class="text-xs text-neutral-400">{{ $t('absences.remaining') }}</p>
|
||||
</div>
|
||||
<span
|
||||
class="rounded-full px-2.5 py-1 text-xs font-medium"
|
||||
:style="{ backgroundColor: typeColor(primary.type) + '22', color: typeColor(primary.type) }"
|
||||
>
|
||||
{{ primary.period }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="primary" class="mt-3 h-2 w-full overflow-hidden rounded-full bg-neutral-100">
|
||||
<div
|
||||
class="h-full rounded-full transition-all"
|
||||
:style="{ width: progress(primary) + '%', backgroundColor: typeColor(primary.type) }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Acquired (N-1) vs in-progress (N), as on a French payslip -->
|
||||
<div v-if="primary && primary.type === 'cp'" class="mt-3 grid grid-cols-2 gap-2">
|
||||
<div class="rounded-lg bg-neutral-50 px-3 py-2">
|
||||
<p class="text-xs text-neutral-400">{{ $t('absences.acquiredN1') }}</p>
|
||||
<p class="text-sm font-semibold text-neutral-800">{{ formatNumber(primary.acquired) }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-neutral-50 px-3 py-2">
|
||||
<p class="text-xs text-neutral-400">{{ $t('absences.acquiringN') }}</p>
|
||||
<p class="text-sm font-semibold text-neutral-800">{{ formatNumber(primary.acquiring) }}</p>
|
||||
<p class="text-[10px] leading-tight text-neutral-400">{{ $t('absences.acquiringHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="primary" class="mt-2 flex justify-between text-xs text-neutral-500">
|
||||
<span>{{ formatNumber(primary.taken) }} {{ $t('absences.taken') }}</span>
|
||||
<span v-if="primary.pending > 0" class="text-amber-600">
|
||||
{{ formatNumber(primary.pending) }} {{ $t('absences.pending') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Other balances, compact rows -->
|
||||
<div v-if="others.length" class="mt-4 flex flex-col divide-y divide-neutral-100 border-t border-neutral-100 pt-1">
|
||||
<div
|
||||
v-for="balance in others"
|
||||
:key="balance.id"
|
||||
class="flex items-center justify-between py-2 text-sm"
|
||||
>
|
||||
<span class="flex items-center gap-2 text-neutral-600">
|
||||
<span class="h-2.5 w-2.5 flex-shrink-0 rounded-full" :style="{ backgroundColor: typeColor(balance.type) }" />
|
||||
{{ balance.label }}
|
||||
<span class="text-xs text-neutral-400">{{ balance.period }}</span>
|
||||
</span>
|
||||
<span class="text-neutral-900">
|
||||
<span class="font-semibold">{{ formatNumber(balance.available) }}</span>
|
||||
<span class="text-neutral-400"> / {{ formatNumber(acquiredTotal(balance)) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsenceBalance } from '~/services/dto/absence'
|
||||
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||
|
||||
const props = defineProps<{
|
||||
balances: AbsenceBalance[]
|
||||
}>()
|
||||
|
||||
const { typeColor } = useAbsenceHelpers()
|
||||
|
||||
// Current paid-leave reference period, mirroring AbsenceBalanceService::periodFor.
|
||||
const currentCpPeriod = computed<string>(() => {
|
||||
const start = useAuthStore().user?.referencePeriodStart ?? '06-01'
|
||||
const now = new Date()
|
||||
const md = `${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||
const startYear = md >= start ? now.getFullYear() : now.getFullYear() - 1
|
||||
return `${startYear}-${startYear + 1}`
|
||||
})
|
||||
|
||||
// The current "congés payés" balance is the headline; fall back to any CP, then any balance.
|
||||
const primary = computed<AbsenceBalance | null>(() => {
|
||||
const cps = props.balances.filter(b => b.type === 'cp')
|
||||
return cps.find(b => b.period === currentCpPeriod.value) ?? cps[0] ?? props.balances[0] ?? null
|
||||
})
|
||||
|
||||
const others = computed<AbsenceBalance[]>(() =>
|
||||
props.balances.filter(b => b.id !== primary.value?.id),
|
||||
)
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
return (Math.round(n * 2) / 2).toString()
|
||||
}
|
||||
|
||||
// Total entitlement = acquired (N-1) + in-progress (N); falls back to the
|
||||
// backend-computed field when present.
|
||||
function acquiredTotal(balance: AbsenceBalance): number {
|
||||
return balance.acquiredTotal ?? balance.acquired + balance.acquiring
|
||||
}
|
||||
|
||||
function progress(balance: AbsenceBalance): number {
|
||||
const total = acquiredTotal(balance)
|
||||
if (total <= 0) return 0
|
||||
return Math.min(100, Math.max(0, (balance.taken / total) * 100))
|
||||
}
|
||||
</script>
|
||||
143
frontend/components/absence/AbsenceCalendar.vue
Normal file
143
frontend/components/absence/AbsenceCalendar.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="rounded-lg border border-neutral-200 bg-white">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-neutral-200 px-4 py-3">
|
||||
<button class="rounded-md p-1.5 text-neutral-500 hover:bg-neutral-100" @click="shiftMonth(-1)">
|
||||
<Icon name="mdi:chevron-left" size="22" />
|
||||
</button>
|
||||
<p class="text-lg font-semibold capitalize text-neutral-900">{{ monthLabel }}</p>
|
||||
<button class="rounded-md p-1.5 text-neutral-500 hover:bg-neutral-100" @click="shiftMonth(1)">
|
||||
<Icon name="mdi:chevron-right" size="22" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Weekday headers -->
|
||||
<div class="grid grid-cols-7 border-b border-neutral-100 text-center text-xs font-medium text-neutral-400">
|
||||
<div v-for="d in weekdays" :key="d" class="py-2">{{ d }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid -->
|
||||
<div class="grid grid-cols-7">
|
||||
<div
|
||||
v-for="cell in cells"
|
||||
:key="cell.key"
|
||||
class="min-h-[92px] border-b border-r border-neutral-100 p-1.5"
|
||||
:class="cell.holiday ? 'bg-amber-50' : (cell.inMonth ? 'bg-white' : 'bg-neutral-50')"
|
||||
:title="cell.holiday ?? undefined"
|
||||
>
|
||||
<div class="mb-1 flex items-center gap-1">
|
||||
<span v-if="cell.holiday" class="flex min-w-0 flex-1 items-center gap-1 text-[10px] font-medium text-amber-700">
|
||||
<Icon name="mdi:star-four-points-outline" size="11" class="flex-shrink-0" />
|
||||
<span class="truncate">{{ cell.holiday }}</span>
|
||||
</span>
|
||||
<span v-else class="flex-1" />
|
||||
<span class="flex-shrink-0 text-xs" :class="cell.isToday ? 'font-bold text-orange-500' : 'text-neutral-400'">
|
||||
{{ cell.day }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span
|
||||
v-for="abs in cell.absences"
|
||||
:key="abs.id"
|
||||
class="truncate rounded px-1 py-0.5 text-[11px] font-medium text-white"
|
||||
:style="{ backgroundColor: abs.status === 'pending' ? typeColor(abs.type) + 'aa' : typeColor(abs.type) }"
|
||||
:title="`${abs.user.username} · ${abs.label} (${statusLabel(abs.status)})`"
|
||||
>
|
||||
{{ abs.user.username }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsenceRequest } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||
|
||||
const props = defineProps<{
|
||||
absences: AbsenceRequest[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'range-change': [from: string, to: string]
|
||||
}>()
|
||||
|
||||
const service = useAbsenceService()
|
||||
const { typeColor, statusLabel } = useAbsenceHelpers()
|
||||
|
||||
const holidays = ref<Record<string, string>>({})
|
||||
|
||||
const weekdays = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim']
|
||||
|
||||
const cursor = ref(startOfMonth(new Date()))
|
||||
|
||||
const monthLabel = computed(() =>
|
||||
cursor.value.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }),
|
||||
)
|
||||
|
||||
type Cell = { key: string; day: number; date: Date; inMonth: boolean; isToday: boolean; holiday: string | null; absences: AbsenceRequest[] }
|
||||
|
||||
function startOfMonth(d: Date): Date {
|
||||
return new Date(d.getFullYear(), d.getMonth(), 1)
|
||||
}
|
||||
|
||||
function ymd(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// First Monday on/before the 1st of the month
|
||||
function gridStart(month: Date): Date {
|
||||
const first = startOfMonth(month)
|
||||
const dow = (first.getDay() + 6) % 7 // 0 = Monday
|
||||
const start = new Date(first)
|
||||
start.setDate(first.getDate() - dow)
|
||||
return start
|
||||
}
|
||||
|
||||
const visibleRange = computed(() => {
|
||||
const start = gridStart(cursor.value)
|
||||
const end = new Date(start)
|
||||
end.setDate(start.getDate() + 41) // 6 weeks grid
|
||||
return { start, end }
|
||||
})
|
||||
|
||||
const cells = computed<Cell[]>(() => {
|
||||
const { start } = visibleRange.value
|
||||
const today = ymd(new Date())
|
||||
const result: Cell[] = []
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const date = new Date(start)
|
||||
date.setDate(start.getDate() + i)
|
||||
const key = ymd(date)
|
||||
result.push({
|
||||
key,
|
||||
day: date.getDate(),
|
||||
date,
|
||||
inMonth: date.getMonth() === cursor.value.getMonth(),
|
||||
isToday: key === today,
|
||||
holiday: holidays.value[key] ?? null,
|
||||
absences: props.absences.filter(a => key >= a.startDate.slice(0, 10) && key <= a.endDate.slice(0, 10)),
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
async function emitRange() {
|
||||
const { start, end } = visibleRange.value
|
||||
emit('range-change', ymd(start), ymd(end))
|
||||
try {
|
||||
holidays.value = await service.getPublicHolidays(ymd(start), ymd(end))
|
||||
} catch {
|
||||
holidays.value = {}
|
||||
}
|
||||
}
|
||||
|
||||
function shiftMonth(delta: number) {
|
||||
cursor.value = new Date(cursor.value.getFullYear(), cursor.value.getMonth() + delta, 1)
|
||||
emitRange()
|
||||
}
|
||||
|
||||
onMounted(emitRange)
|
||||
</script>
|
||||
73
frontend/components/absence/AbsenceDateField.vue
Normal file
73
frontend/components/absence/AbsenceDateField.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="absence-date-field">
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ label }}</label>
|
||||
<MalioDate
|
||||
:model-value="modelValue"
|
||||
:min="min ?? undefined"
|
||||
:max="max ?? undefined"
|
||||
:error="error"
|
||||
:clearable="true"
|
||||
group-class="w-full"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
/>
|
||||
|
||||
<div v-if="showPills" class="mt-2 flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="opt in pillOptions"
|
||||
:key="String(opt.value)"
|
||||
type="button"
|
||||
class="rounded-full border px-4 py-1.5 text-sm font-medium transition"
|
||||
:class="half === opt.value
|
||||
? 'border-primary-500 bg-primary-50 text-primary-600'
|
||||
: 'border-neutral-300 text-neutral-600 hover:border-neutral-400'"
|
||||
@click="$emit('update:half', opt.value)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HalfDay } from '~/services/dto/absence'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** ISO date string "YYYY-MM-DD" or null. */
|
||||
modelValue: string | null
|
||||
half: HalfDay | null
|
||||
label: string
|
||||
/** 'start' shows full/morning/afternoon, 'end' shows full/morning only. */
|
||||
mode?: 'start' | 'end'
|
||||
error?: string
|
||||
/** ISO date string "YYYY-MM-DD" or null. */
|
||||
min?: string | null
|
||||
max?: string | null
|
||||
showPills?: boolean
|
||||
}>(), {
|
||||
mode: 'start',
|
||||
error: '',
|
||||
min: null,
|
||||
max: null,
|
||||
showPills: true,
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: string | null]
|
||||
'update:half': [value: HalfDay | null]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
type PillOption = { label: string; value: HalfDay | null }
|
||||
|
||||
const pillOptions = computed<PillOption[]>(() => {
|
||||
const base: PillOption[] = [
|
||||
{ label: t('absences.form.fullDay'), value: null },
|
||||
{ label: t('absences.halfDay.matin'), value: 'matin' },
|
||||
]
|
||||
if (props.mode === 'start') {
|
||||
base.push({ label: t('absences.halfDay.apres_midi'), value: 'apres_midi' })
|
||||
}
|
||||
return base
|
||||
})
|
||||
</script>
|
||||
193
frontend/components/absence/AbsenceDetailDrawer.vue
Normal file
193
frontend/components/absence/AbsenceDetailDrawer.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="open" drawer-class="max-w-lg">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ $t('absences.detail.title') }}</h2>
|
||||
</template>
|
||||
<div v-if="request" class="flex flex-col gap-5">
|
||||
<!-- Hero -->
|
||||
<div class="overflow-hidden rounded-xl border border-neutral-200 shadow-sm">
|
||||
<div
|
||||
class="flex items-start gap-3 p-4"
|
||||
:style="{ borderLeft: `4px solid ${typeColor(request.type)}` }"
|
||||
>
|
||||
<span
|
||||
class="mt-0.5 flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-full"
|
||||
:style="{ backgroundColor: tint(request.type), color: typeColor(request.type) }"
|
||||
>
|
||||
<Icon name="mdi:calendar-account" size="22" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-lg font-semibold text-neutral-900">{{ request.label }}</p>
|
||||
<p class="mt-0.5 flex items-center gap-1.5 text-sm text-neutral-500">
|
||||
<Icon name="mdi:calendar-range" size="15" />
|
||||
{{ formatRange(request) }}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge
|
||||
class="flex-shrink-0"
|
||||
:label="statusLabel(request.status)"
|
||||
:variant="statusVariant(request.status)"
|
||||
:icon="statusIcon(request.status)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<dl class="grid grid-cols-2 divide-x divide-neutral-200 border-t border-neutral-200 bg-neutral-50">
|
||||
<div class="flex items-center gap-2.5 p-3">
|
||||
<span
|
||||
v-if="request.user.avatarUrl"
|
||||
class="h-9 w-9 flex-shrink-0 overflow-hidden rounded-full"
|
||||
>
|
||||
<img :src="request.user.avatarUrl" alt="" class="h-full w-full object-cover">
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-primary-100 text-xs font-semibold text-primary-600"
|
||||
>
|
||||
{{ initials }}
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<dt class="text-xs text-neutral-400">{{ $t('absences.table.employee') }}</dt>
|
||||
<dd class="truncate text-sm font-medium text-neutral-800">{{ request.user.username }}</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center p-3">
|
||||
<dt class="text-xs text-neutral-400">{{ $t('absences.table.days') }}</dt>
|
||||
<dd class="text-sm font-semibold text-neutral-900">{{ formatDays(request.countedDays) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Reason -->
|
||||
<div v-if="request.reason" class="rounded-lg border border-neutral-200 p-3">
|
||||
<p class="mb-1 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||
<Icon name="mdi:comment-text-outline" size="14" />
|
||||
{{ $t('absences.form.reason') }}
|
||||
</p>
|
||||
<p class="whitespace-pre-line text-sm text-neutral-800">{{ request.reason }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Rejection -->
|
||||
<div
|
||||
v-if="request.status === 'rejected' && request.rejectionReason"
|
||||
class="rounded-lg border border-red-200 bg-red-50 p-3"
|
||||
>
|
||||
<p class="mb-1 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-red-500">
|
||||
<Icon name="mdi:close-circle-outline" size="14" />
|
||||
{{ $t('absences.detail.rejectionReason') }}
|
||||
</p>
|
||||
<p class="text-sm text-red-700">{{ request.rejectionReason }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Justification -->
|
||||
<a
|
||||
v-if="request.justificationUrl"
|
||||
:href="request.justificationUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="flex items-center gap-2 rounded-lg border border-neutral-200 p-3 text-sm font-medium text-neutral-700 transition hover:border-primary-300 hover:bg-primary-50"
|
||||
>
|
||||
<Icon name="mdi:file-document-outline" size="20" class="text-primary-500" />
|
||||
<span class="flex-1 truncate">{{ request.justificationFileName || $t('absences.detail.downloadJustification') }}</span>
|
||||
<Icon name="mdi:download" size="16" class="text-neutral-400" />
|
||||
</a>
|
||||
|
||||
<!-- Timeline -->
|
||||
<div>
|
||||
<p class="mb-3 text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||
{{ $t('absences.detail.timeline') }}
|
||||
</p>
|
||||
<ol class="relative ml-1 border-l border-neutral-200 pl-5">
|
||||
<li class="mb-4 last:mb-0">
|
||||
<span class="absolute -left-[7px] mt-0.5 h-3.5 w-3.5 rounded-full border-2 border-white bg-primary-500 ring-1 ring-primary-200" />
|
||||
<p class="text-sm font-medium text-neutral-800">{{ $t('absences.detail.created') }}</p>
|
||||
<p class="text-xs text-neutral-400">{{ formatDateTime(request.createdAt) }}</p>
|
||||
</li>
|
||||
<li v-if="request.reviewedAt" class="last:mb-0">
|
||||
<span
|
||||
class="absolute -left-[7px] mt-0.5 h-3.5 w-3.5 rounded-full border-2 border-white ring-1"
|
||||
:class="request.status === 'rejected'
|
||||
? 'bg-red-500 ring-red-200'
|
||||
: 'bg-green-500 ring-green-200'"
|
||||
/>
|
||||
<p class="text-sm font-medium text-neutral-800">
|
||||
{{ statusLabel(request.status) }}
|
||||
<span v-if="request.reviewedBy" class="font-normal text-neutral-500">
|
||||
· {{ $t('absences.detail.reviewed', { name: request.reviewedBy.username }) }}
|
||||
</span>
|
||||
</p>
|
||||
<p class="text-xs text-neutral-400">{{ formatDateTime(request.reviewedAt) }}</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div v-if="canCancel" class="flex justify-end border-t border-neutral-100 pt-4">
|
||||
<MalioButton
|
||||
:label="$t('absences.detail.cancel')"
|
||||
variant="danger"
|
||||
icon-name="mdi:cancel"
|
||||
icon-position="left"
|
||||
:disabled="cancelling"
|
||||
@click="onCancel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsenceRequest } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
request: AbsenceRequest | null
|
||||
canCancel?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'cancelled': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const service = useAbsenceService()
|
||||
const { statusLabel, statusVariant, statusIcon, formatRange, formatDays, typeColor } = useAbsenceHelpers()
|
||||
|
||||
const open = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const cancelling = ref(false)
|
||||
|
||||
const initials = computed(() => {
|
||||
const name = props.request?.user.username ?? ''
|
||||
return name.slice(0, 2).toUpperCase() || '?'
|
||||
})
|
||||
|
||||
/** Type colour at ~12% opacity for soft backgrounds. */
|
||||
function tint(type: AbsenceRequest['type']): string {
|
||||
return `${typeColor(type)}1f`
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string | null): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d.getTime())) return ''
|
||||
return d.toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
async function onCancel() {
|
||||
if (!props.request) return
|
||||
if (!confirm(t('absences.detail.cancelConfirm'))) return
|
||||
cancelling.value = true
|
||||
try {
|
||||
await service.cancel(props.request.id)
|
||||
emit('cancelled')
|
||||
open.value = false
|
||||
} finally {
|
||||
cancelling.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
69
frontend/components/absence/AbsenceRejectDrawer.vue
Normal file
69
frontend/components/absence/AbsenceRejectDrawer.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="open" drawer-class="max-w-md">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ $t('absences.review.rejectTitle') }}</h2>
|
||||
</template>
|
||||
<div v-if="request" class="flex flex-col gap-4">
|
||||
<p class="text-sm text-neutral-600">
|
||||
{{ request.user.username }} · {{ request.label }} · {{ formatRange(request) }}
|
||||
</p>
|
||||
<MalioInputTextArea
|
||||
v-model="reason"
|
||||
:label="$t('absences.review.rejectReasonLabel')"
|
||||
:placeholder="$t('absences.review.rejectReasonPlaceholder')"
|
||||
/>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<MalioButton :label="$t('common.cancel')" variant="tertiary" @click="open = false" />
|
||||
<MalioButton
|
||||
:label="$t('absences.review.confirm')"
|
||||
variant="danger"
|
||||
:disabled="!reason.trim() || submitting"
|
||||
@click="submit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsenceRequest } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
request: AbsenceRequest | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'rejected': []
|
||||
}>()
|
||||
|
||||
const service = useAbsenceService()
|
||||
const { formatRange } = useAbsenceHelpers()
|
||||
|
||||
const open = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const reason = ref('')
|
||||
const submitting = ref(false)
|
||||
|
||||
watch(open, (v) => {
|
||||
if (v) reason.value = ''
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
if (!props.request || !reason.value.trim()) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await service.reject(props.request.id, reason.value.trim())
|
||||
emit('rejected')
|
||||
open.value = false
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
296
frontend/components/absence/AbsenceRequestDrawer.vue
Normal file
296
frontend/components/absence/AbsenceRequestDrawer.vue
Normal file
@@ -0,0 +1,296 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="open" drawer-class="max-w-xl">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ $t('absences.newRequest') }}</h2>
|
||||
</template>
|
||||
<div class="flex flex-col gap-5">
|
||||
<!-- Server-side error banner -->
|
||||
<div v-if="serverError" class="flex items-start gap-2 rounded-lg bg-red-50 p-3 text-sm text-red-700">
|
||||
<Icon name="mdi:alert-circle-outline" size="18" class="mt-0.5 flex-shrink-0" />
|
||||
<span>{{ serverError }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Step 1 — type (always visible) -->
|
||||
<MalioSelect
|
||||
v-model="form.type"
|
||||
:label="$t('absences.form.type')"
|
||||
:options="typeOptions"
|
||||
:empty-option-label="$t('absences.filters.allTypes')"
|
||||
:error="errors.type"
|
||||
group-class="w-full"
|
||||
/>
|
||||
|
||||
<!-- Step 2 — start date (revealed once a type is chosen) -->
|
||||
<AbsenceDateField
|
||||
v-if="showDates"
|
||||
v-model="form.startDate"
|
||||
v-model:half="form.startHalf"
|
||||
:label="$t('absences.form.startDate')"
|
||||
mode="start"
|
||||
:error="errors.startDate"
|
||||
:max="form.endDate"
|
||||
/>
|
||||
|
||||
<!-- Balance at start date -->
|
||||
<div v-if="preview && preview.available !== null" class="flex items-center justify-between border-t border-neutral-100 pt-3 text-sm">
|
||||
<span class="font-medium text-neutral-700">{{ $t('absences.form.balanceAt', { date: startDateLabel }) }}</span>
|
||||
<span class="text-neutral-900">{{ formatDays(preview.available) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Step 3 — end date (revealed once a start date is set) -->
|
||||
<AbsenceDateField
|
||||
v-if="showEnd"
|
||||
v-model="form.endDate"
|
||||
v-model:half="form.endHalf"
|
||||
:label="$t('absences.form.endDate')"
|
||||
mode="end"
|
||||
:error="errors.endDate"
|
||||
:min="form.startDate"
|
||||
:show-pills="!isSingleDay"
|
||||
/>
|
||||
|
||||
<!-- Duration & projected balance -->
|
||||
<div v-if="preview" class="flex flex-col gap-1 rounded-lg bg-neutral-50 p-3">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-neutral-600">{{ $t('absences.form.duration') }}</span>
|
||||
<span class="font-semibold text-neutral-900">{{ formatDays(preview.countedDays) }}</span>
|
||||
</div>
|
||||
<div v-if="preview.projectedAvailable !== null" class="flex items-center justify-between border-t border-neutral-200 pt-1 text-sm">
|
||||
<span class="font-medium text-neutral-700">{{ $t('absences.form.balanceAfterValidation') }}</span>
|
||||
<span :class="preview.projectedAvailable < 0 ? 'font-semibold text-amber-600' : 'text-neutral-900'">
|
||||
{{ formatDays(preview.projectedAvailable) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="preview && preview.projectedAvailable !== null && preview.projectedAvailable < 0"
|
||||
class="rounded-lg bg-amber-50 p-3 text-sm text-amber-700"
|
||||
>
|
||||
{{ $t('absences.form.negativeWarning') }}
|
||||
</div>
|
||||
|
||||
<!-- Step 4 — justification (only when the policy requires it) -->
|
||||
<MalioInputUpload
|
||||
v-if="showJustification"
|
||||
:model-value="form.file?.name ?? null"
|
||||
:label="`${$t('absences.form.justification')} *`"
|
||||
accept="application/pdf,image/png,image/jpeg,image/webp"
|
||||
:error="errors.justification"
|
||||
@file-selected="onFileSelected"
|
||||
/>
|
||||
|
||||
<!-- Comment (optional) -->
|
||||
<div v-if="showComment" class="flex items-start gap-2">
|
||||
<span class="mt-1 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-primary-100 text-xs font-semibold text-primary-600">
|
||||
{{ initials }}
|
||||
</span>
|
||||
<MalioInputTextArea
|
||||
v-model="form.reason"
|
||||
group-class="flex-1"
|
||||
:placeholder="$t('absences.form.commentPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<MalioButton :label="$t('common.cancel')" variant="tertiary" @click="open = false" />
|
||||
<MalioButton
|
||||
:label="$t('absences.form.submit')"
|
||||
:disabled="submitting"
|
||||
@click="submit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsencePolicy, AbsencePreviewResult, AbsenceType, HalfDay } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
policies: AbsencePolicy[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'created': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { formatDays, formatDate } = useAbsenceHelpers()
|
||||
const service = useAbsenceService()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const open = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
type FormState = {
|
||||
type: AbsenceType | null
|
||||
// ISO date strings "YYYY-MM-DD" (lexicographic order == chronological order).
|
||||
startDate: string | null
|
||||
startHalf: HalfDay | null
|
||||
endDate: string | null
|
||||
endHalf: HalfDay | null
|
||||
reason: string
|
||||
file: File | null
|
||||
}
|
||||
|
||||
const form = reactive<FormState>({
|
||||
type: null,
|
||||
startDate: null,
|
||||
startHalf: null,
|
||||
endDate: null,
|
||||
endHalf: null,
|
||||
reason: '',
|
||||
file: null,
|
||||
})
|
||||
|
||||
const errors = reactive<{ type: string; startDate: string; endDate: string; justification: string }>({
|
||||
type: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
justification: '',
|
||||
})
|
||||
|
||||
const serverError = ref('')
|
||||
const preview = ref<AbsencePreviewResult | null>(null)
|
||||
const submitting = ref(false)
|
||||
|
||||
const typeOptions = computed(() =>
|
||||
props.policies
|
||||
.filter(p => p.active)
|
||||
.map(p => ({ label: p.label, value: p.type })),
|
||||
)
|
||||
|
||||
const selectedPolicy = computed(() => props.policies.find(p => p.type === form.type) ?? null)
|
||||
const justificationRequired = computed(() => selectedPolicy.value?.justificationRequired ?? false)
|
||||
|
||||
const showDates = computed(() => form.type !== null)
|
||||
const showEnd = computed(() => form.startDate !== null)
|
||||
const showJustification = computed(() => form.type !== null && justificationRequired.value)
|
||||
const showComment = computed(() => form.startDate !== null)
|
||||
|
||||
const isSingleDay = computed(() =>
|
||||
form.startDate !== null
|
||||
&& form.endDate !== null
|
||||
&& form.startDate === form.endDate,
|
||||
)
|
||||
|
||||
const startDateLabel = computed(() => formatDate(form.startDate))
|
||||
|
||||
const initials = computed(() => {
|
||||
const name = auth.user?.username ?? ''
|
||||
return name.slice(0, 2).toUpperCase() || '?'
|
||||
})
|
||||
|
||||
function onFileSelected(file: File) {
|
||||
form.file = file
|
||||
errors.justification = ''
|
||||
}
|
||||
|
||||
function buildPayload() {
|
||||
// On a single-day request the end half-day mirrors the start.
|
||||
const endHalf = isSingleDay.value ? form.startHalf : form.endHalf
|
||||
return {
|
||||
type: form.type as AbsenceType,
|
||||
startDate: form.startDate as string,
|
||||
endDate: form.endDate as string,
|
||||
startHalfDay: form.startHalf,
|
||||
endHalfDay: endHalf,
|
||||
reason: form.reason || null,
|
||||
}
|
||||
}
|
||||
|
||||
function validate(): boolean {
|
||||
errors.type = form.type ? '' : t('absences.form.errors.typeRequired')
|
||||
errors.startDate = form.startDate ? '' : t('absences.form.errors.startRequired')
|
||||
|
||||
if (form.endDate === null) {
|
||||
errors.endDate = t('absences.form.errors.endRequired')
|
||||
} else if (form.startDate && form.endDate < form.startDate) {
|
||||
errors.endDate = t('absences.form.errors.endBeforeStart')
|
||||
} else if (form.type && form.startDate && (preview.value?.countedDays ?? 0) <= 0) {
|
||||
errors.endDate = t('absences.form.errors.zeroDays')
|
||||
} else {
|
||||
errors.endDate = ''
|
||||
}
|
||||
|
||||
errors.justification = justificationRequired.value && !form.file
|
||||
? t('absences.form.errors.justificationRequired')
|
||||
: ''
|
||||
|
||||
return !errors.type && !errors.startDate && !errors.endDate && !errors.justification
|
||||
}
|
||||
|
||||
// Clear field errors as soon as the user corrects them.
|
||||
watch(() => form.type, (v) => { if (v) errors.type = '' })
|
||||
watch(() => form.startDate, (v) => { if (v) errors.startDate = '' })
|
||||
watch(() => [form.endDate, form.startDate], () => {
|
||||
if (form.endDate && (!form.startDate || form.endDate >= form.startDate)) errors.endDate = ''
|
||||
})
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch(
|
||||
() => [form.type, form.startDate, form.endDate, form.startHalf, form.endHalf],
|
||||
() => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
if (!form.type || !form.startDate || !form.endDate) {
|
||||
preview.value = null
|
||||
return
|
||||
}
|
||||
debounceTimer = setTimeout(async () => {
|
||||
try {
|
||||
preview.value = await service.preview(buildPayload())
|
||||
} catch {
|
||||
preview.value = null
|
||||
}
|
||||
}, 300)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
async function submit() {
|
||||
serverError.value = ''
|
||||
if (!validate()) return
|
||||
submitting.value = true
|
||||
try {
|
||||
const created = await service.create(buildPayload())
|
||||
if (form.file) {
|
||||
await service.uploadJustification(created.id, form.file)
|
||||
}
|
||||
emit('created')
|
||||
open.value = false
|
||||
resetForm()
|
||||
} catch (e) {
|
||||
serverError.value = (e instanceof Error && e.message) ? e.message : t('absences.form.serverError')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.type = null
|
||||
form.startDate = null
|
||||
form.startHalf = null
|
||||
form.endDate = null
|
||||
form.endHalf = null
|
||||
form.reason = ''
|
||||
form.file = null
|
||||
errors.type = ''
|
||||
errors.startDate = ''
|
||||
errors.endDate = ''
|
||||
errors.justification = ''
|
||||
serverError.value = ''
|
||||
preview.value = null
|
||||
}
|
||||
|
||||
watch(open, (v) => {
|
||||
if (v) resetForm()
|
||||
})
|
||||
</script>
|
||||
137
frontend/components/absence/EmployeeDrawer.vue
Normal file
137
frontend/components/absence/EmployeeDrawer.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="open" drawer-class="max-w-lg">
|
||||
<template #header>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold">{{ $t('absences.admin.employees.drawer.title') }}</h2>
|
||||
<p v-if="user" class="text-sm text-neutral-500">{{ user.username }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<form v-if="user" class="grid grid-cols-1 gap-4 sm:grid-cols-2" @submit.prevent="save">
|
||||
<MalioDate
|
||||
v-model="form.hireDate"
|
||||
:label="$t('absences.admin.employees.fields.hireDate')"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioDate
|
||||
v-model="form.endDate"
|
||||
:label="$t('absences.admin.employees.fields.endDate')"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.contractType"
|
||||
:label="$t('absences.admin.employees.fields.contractType')"
|
||||
:options="contractOptions"
|
||||
empty-option-label="—"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.workTimeRatio"
|
||||
:label="$t('absences.admin.employees.fields.workTimeRatio')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.annualLeaveDays"
|
||||
:label="$t('absences.admin.employees.fields.annualLeaveDays')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.referencePeriodStart"
|
||||
:label="$t('absences.admin.employees.fields.referencePeriodStart')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.initialLeaveBalance"
|
||||
:label="$t('absences.admin.employees.fields.initialLeaveBalance')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<div class="col-span-full mt-2 flex justify-end">
|
||||
<MalioButton
|
||||
:label="$t('absences.admin.employees.drawer.save')"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="submitting"
|
||||
@click="save"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ContractType, UserData } from '~/services/dto/user-data'
|
||||
import { useUserService } from '~/services/users'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
user: UserData | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'saved': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { update } = useUserService()
|
||||
|
||||
const open = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const submitting = ref(false)
|
||||
|
||||
const contractOptions = [
|
||||
{ label: t('absences.admin.employees.contract.cdi'), value: 'CDI' },
|
||||
{ label: t('absences.admin.employees.contract.cdd'), value: 'CDD' },
|
||||
{ label: t('absences.admin.employees.contract.stage'), value: 'STAGE' },
|
||||
{ label: t('absences.admin.employees.contract.alternance'), value: 'ALTERNANCE' },
|
||||
{ label: t('absences.admin.employees.contract.autre'), value: 'AUTRE' },
|
||||
]
|
||||
|
||||
const form = reactive({
|
||||
hireDate: null as string | null,
|
||||
endDate: null as string | null,
|
||||
contractType: null as ContractType | null,
|
||||
workTimeRatio: '1.0',
|
||||
annualLeaveDays: '25',
|
||||
referencePeriodStart: '06-01',
|
||||
initialLeaveBalance: '0',
|
||||
})
|
||||
|
||||
function hydrate(u: UserData | null) {
|
||||
if (!u) return
|
||||
form.hireDate = u.hireDate ? u.hireDate.slice(0, 10) : null
|
||||
form.endDate = u.endDate ? u.endDate.slice(0, 10) : null
|
||||
form.contractType = u.contractType ?? null
|
||||
form.workTimeRatio = String(u.workTimeRatio ?? 1)
|
||||
form.annualLeaveDays = String(u.annualLeaveDays ?? 25)
|
||||
form.referencePeriodStart = u.referencePeriodStart ?? '06-01'
|
||||
form.initialLeaveBalance = String(u.initialLeaveBalance ?? 0)
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (isOpen) => {
|
||||
if (isOpen) hydrate(props.user)
|
||||
})
|
||||
|
||||
async function save() {
|
||||
if (!props.user) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await update(props.user.id, {
|
||||
isEmployee: true,
|
||||
hireDate: form.hireDate || null,
|
||||
endDate: form.endDate || null,
|
||||
contractType: form.contractType,
|
||||
workTimeRatio: Number(form.workTimeRatio) || 1,
|
||||
annualLeaveDays: Number(form.annualLeaveDays) || 0,
|
||||
referencePeriodStart: form.referencePeriodStart || '06-01',
|
||||
initialLeaveBalance: Number(form.initialLeaveBalance) || 0,
|
||||
})
|
||||
emit('saved')
|
||||
open.value = false
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
76
frontend/components/admin/AdminAbsencePolicyTab.vue
Normal file
76
frontend/components/admin/AdminAbsencePolicyTab.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 pt-2">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-neutral-900">{{ $t('absences.policies.title') }}</h2>
|
||||
<p class="text-sm text-neutral-500">{{ $t('absences.policies.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-200 text-left text-neutral-500">
|
||||
<th class="py-2 pr-3">{{ $t('absences.policies.type') }}</th>
|
||||
<th class="py-2 px-2">{{ $t('absences.policies.daysPerYear') }}</th>
|
||||
<th class="py-2 px-2">{{ $t('absences.policies.daysPerEvent') }}</th>
|
||||
<th class="py-2 px-2">{{ $t('absences.policies.noticeDays') }}</th>
|
||||
<th class="py-2 px-2 text-center">{{ $t('absences.policies.justificationRequired') }}</th>
|
||||
<th class="py-2 px-2 text-center">{{ $t('absences.policies.countWorkingDaysOnly') }}</th>
|
||||
<th class="py-2 px-2 text-center">{{ $t('absences.policies.active') }}</th>
|
||||
<th class="py-2 pl-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in rows" :key="row.id" class="border-b border-neutral-100">
|
||||
<td class="py-2 pr-3 font-medium text-neutral-800">{{ row.label }}</td>
|
||||
<td class="py-2 px-2">
|
||||
<input v-model.number="row.daysPerYear" type="number" step="0.5" class="w-20 rounded border border-neutral-300 px-2 py-1">
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<input v-model.number="row.daysPerEvent" type="number" step="0.5" class="w-20 rounded border border-neutral-300 px-2 py-1">
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<input v-model.number="row.noticeDays" type="number" class="w-16 rounded border border-neutral-300 px-2 py-1">
|
||||
</td>
|
||||
<td class="py-2 px-2 text-center">
|
||||
<input v-model="row.justificationRequired" type="checkbox" class="h-4 w-4">
|
||||
</td>
|
||||
<td class="py-2 px-2 text-center">
|
||||
<input v-model="row.countWorkingDaysOnly" type="checkbox" class="h-4 w-4">
|
||||
</td>
|
||||
<td class="py-2 px-2 text-center">
|
||||
<input v-model="row.active" type="checkbox" class="h-4 w-4">
|
||||
</td>
|
||||
<td class="py-2 pl-2 text-right">
|
||||
<MalioButton :label="$t('absences.policies.save')" variant="secondary" @click="save(row)" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsencePolicy } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
|
||||
const service = useAbsenceService()
|
||||
const rows = ref<AbsencePolicy[]>([])
|
||||
|
||||
async function load() {
|
||||
rows.value = await service.getPolicies()
|
||||
}
|
||||
|
||||
async function save(row: AbsencePolicy) {
|
||||
await service.updatePolicy(row.id, {
|
||||
daysPerYear: row.daysPerYear === null || Number.isNaN(row.daysPerYear) ? null : Number(row.daysPerYear),
|
||||
daysPerEvent: row.daysPerEvent === null || Number.isNaN(row.daysPerEvent) ? null : Number(row.daysPerEvent),
|
||||
noticeDays: Number(row.noticeDays),
|
||||
justificationRequired: row.justificationRequired,
|
||||
countWorkingDaysOnly: row.countWorkingDaysOnly,
|
||||
active: row.active,
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -1,382 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.adminTab') }}</h2>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<MalioSelect
|
||||
v-model="filterProjectId"
|
||||
:options="projectOptions"
|
||||
label="Projet"
|
||||
:empty-option-label="$t('clientTicket.allProjects')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">Statut</label>
|
||||
<select
|
||||
v-model="filterStatus"
|
||||
class="rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option :value="null">{{ $t('clientTicket.allStatuses') }}</option>
|
||||
<option value="new">{{ $t('clientTicket.status.new') }}</option>
|
||||
<option value="in_progress">{{ $t('clientTicket.status.in_progress') }}</option>
|
||||
<option value="done">{{ $t('clientTicket.status.done') }}</option>
|
||||
<option value="rejected">{{ $t('clientTicket.status.rejected') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ticket list -->
|
||||
<div v-if="isLoading" class="py-8 text-center text-sm text-neutral-400">
|
||||
{{ $t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredTickets.length === 0" class="py-8 text-center text-sm text-neutral-400">
|
||||
{{ $t('clientTicket.noTickets') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-200 text-xs font-semibold uppercase text-neutral-500">
|
||||
<th class="px-3 py-3">#</th>
|
||||
<th class="px-3 py-3">Type</th>
|
||||
<th class="px-3 py-3">{{ $t('clientTicket.title') }}</th>
|
||||
<th class="px-3 py-3">Statut</th>
|
||||
<th class="px-3 py-3">Projet</th>
|
||||
<th class="px-3 py-3">{{ $t('clientTicket.submittedBy') }}</th>
|
||||
<th class="px-3 py-3">{{ $t('clientTicket.createdAt') }}</th>
|
||||
<th class="px-3 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="ticket in filteredTickets"
|
||||
:key="ticket.id"
|
||||
class="cursor-pointer border-b border-neutral-100 transition-colors hover:bg-neutral-50"
|
||||
@click="openDetail(ticket)"
|
||||
>
|
||||
<td class="px-3 py-3 font-bold text-primary-500">CT-{{ String(ticket.number).padStart(3, '0') }}</td>
|
||||
<td class="px-3 py-3">
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:class="typeBadgeClass(ticket.type)"
|
||||
>
|
||||
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-3 font-medium text-neutral-900">{{ ticket.title }}</td>
|
||||
<td class="px-3 py-3">
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold"
|
||||
:class="statusBadgeClass(ticket.status)"
|
||||
>
|
||||
{{ $t(`clientTicket.status.${ticket.status}`) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-3 text-neutral-600">{{ getProjectName(ticket.project) }}</td>
|
||||
<td class="px-3 py-3 text-neutral-600">
|
||||
<div class="flex items-center gap-2">
|
||||
<UserAvatar
|
||||
v-if="getSubmitterUser(ticket.submittedBy)"
|
||||
:user="getSubmitterUser(ticket.submittedBy)!"
|
||||
size="sm"
|
||||
/>
|
||||
{{ getSubmitterName(ticket.submittedBy) }}
|
||||
</div>
|
||||
</td>
|
||||
<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">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:swap-horizontal"
|
||||
:aria-label="$t('clientTicket.changeStatus')"
|
||||
variant="ghost"
|
||||
icon-size="18"
|
||||
@click.stop="openStatusChange(ticket)"
|
||||
/>
|
||||
<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)"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Status change modal -->
|
||||
<Teleport v-if="statusModalOpen" to="body">
|
||||
<Transition name="status-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="statusModalOpen = false"
|
||||
/>
|
||||
<div class="relative z-10 w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.changeStatus') }}</h3>
|
||||
<p v-if="statusTarget" class="mt-1 text-sm text-neutral-500">
|
||||
CT-{{ String(statusTarget.number).padStart(3, '0') }} — {{ statusTarget.title }}
|
||||
</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">Nouveau statut</label>
|
||||
<select
|
||||
v-model="newStatus"
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option :value="null" disabled>—</option>
|
||||
<option
|
||||
v-for="s in availableStatusTransitions"
|
||||
:key="s.value"
|
||||
:value="s.value"
|
||||
>
|
||||
{{ s.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="newStatus === 'rejected'" class="mt-4">
|
||||
<MalioInputTextArea
|
||||
v-model="statusComment"
|
||||
:label="$t('clientTicket.statusComment')"
|
||||
:size="3"
|
||||
/>
|
||||
<p v-if="rejectionError" class="mt-1 text-xs text-red-500">
|
||||
{{ $t('clientTicket.rejectionRequired') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('common.cancel')"
|
||||
button-class="w-auto px-4"
|
||||
@click="statusModalOpen = false"
|
||||
/>
|
||||
<MalioButton
|
||||
label="Confirmer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isUpdatingStatus"
|
||||
@click="confirmStatusChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Delete confirm modal -->
|
||||
<Teleport v-if="deleteModalOpen" to="body">
|
||||
<Transition name="status-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="deleteModalOpen = false"
|
||||
/>
|
||||
<div class="relative z-10 w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl">
|
||||
<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">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('common.cancel')"
|
||||
button-class="w-auto px-4"
|
||||
@click="deleteModalOpen = false"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
label="Supprimer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isDeleting"
|
||||
@click="confirmDelete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Ticket detail modal (read-only) -->
|
||||
<ClientTicketDetailModal
|
||||
v-model="detailOpen"
|
||||
:ticket="detailTicket"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ClientTicket, ClientTicketStatus } from '~/services/dto/client-ticket'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
import { useUserService } from '~/services/users'
|
||||
|
||||
const { t } = useI18n()
|
||||
const clientTicketService = useClientTicketService()
|
||||
const projectService = useProjectService()
|
||||
const userService = useUserService()
|
||||
const { typeBadgeClass, statusBadgeClass, formatDate, getAvailableStatusTransitions } = useClientTicketHelpers()
|
||||
|
||||
const tickets = ref<ClientTicket[]>([])
|
||||
const projects = ref<Project[]>([])
|
||||
const users = ref<UserData[]>([])
|
||||
const isLoading = ref(true)
|
||||
|
||||
// Filters
|
||||
const filterProjectId = ref<number | null>(null)
|
||||
const filterStatus = ref<string | null>(null)
|
||||
|
||||
const projectOptions = computed(() =>
|
||||
projects.value.map(p => ({ label: p.name, value: p.id }))
|
||||
)
|
||||
|
||||
const filteredTickets = computed(() => {
|
||||
let result = tickets.value
|
||||
if (filterProjectId.value) {
|
||||
result = result.filter(t => t.project === `/api/projects/${filterProjectId.value}`)
|
||||
}
|
||||
if (filterStatus.value) {
|
||||
result = result.filter(t => t.status === filterStatus.value)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
// Status change modal
|
||||
const statusModalOpen = ref(false)
|
||||
const statusTarget = ref<ClientTicket | null>(null)
|
||||
const newStatus = ref<string | null>(null)
|
||||
const statusComment = ref('')
|
||||
const rejectionError = ref(false)
|
||||
const isUpdatingStatus = ref(false)
|
||||
|
||||
// Delete modal
|
||||
const deleteModalOpen = ref(false)
|
||||
const deleteTarget = ref<ClientTicket | null>(null)
|
||||
const isDeleting = ref(false)
|
||||
|
||||
// Detail modal
|
||||
const detailOpen = ref(false)
|
||||
const detailTicket = ref<ClientTicket | null>(null)
|
||||
|
||||
const availableStatusTransitions = computed(() => {
|
||||
if (!statusTarget.value) return []
|
||||
return getAvailableStatusTransitions(statusTarget.value.status, t)
|
||||
})
|
||||
|
||||
function getProjectName(iri: string): string {
|
||||
const id = extractIdFromIri(iri)
|
||||
if (!id) return ''
|
||||
return projects.value.find(p => p.id === id)?.name ?? ''
|
||||
}
|
||||
|
||||
function getSubmitterName(iri: string | null): string {
|
||||
if (!iri) return '-'
|
||||
const id = extractIdFromIri(iri)
|
||||
if (!id) return ''
|
||||
return users.value.find(u => u.id === id)?.username ?? ''
|
||||
}
|
||||
|
||||
function getSubmitterUser(iri: string | null): UserData | undefined {
|
||||
if (!iri) return undefined
|
||||
const id = extractIdFromIri(iri)
|
||||
if (!id) return undefined
|
||||
return users.value.find(u => u.id === id)
|
||||
}
|
||||
|
||||
function openDetail(ticket: ClientTicket) {
|
||||
detailTicket.value = ticket
|
||||
detailOpen.value = true
|
||||
}
|
||||
|
||||
function openStatusChange(ticket: ClientTicket) {
|
||||
statusTarget.value = ticket
|
||||
newStatus.value = null
|
||||
statusComment.value = ''
|
||||
rejectionError.value = false
|
||||
statusModalOpen.value = true
|
||||
}
|
||||
|
||||
function openDeleteConfirm(ticket: ClientTicket) {
|
||||
deleteTarget.value = ticket
|
||||
deleteModalOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmStatusChange() {
|
||||
if (!statusTarget.value || !newStatus.value) return
|
||||
|
||||
if (newStatus.value === 'rejected' && !statusComment.value.trim()) {
|
||||
rejectionError.value = true
|
||||
return
|
||||
}
|
||||
|
||||
isUpdatingStatus.value = true
|
||||
try {
|
||||
await clientTicketService.updateStatus(statusTarget.value.id, {
|
||||
status: newStatus.value as ClientTicketStatus,
|
||||
statusComment: newStatus.value === 'rejected' ? statusComment.value.trim() : null,
|
||||
})
|
||||
statusModalOpen.value = false
|
||||
await loadTickets()
|
||||
} finally {
|
||||
isUpdatingStatus.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget.value) return
|
||||
isDeleting.value = true
|
||||
try {
|
||||
await clientTicketService.remove(deleteTarget.value.id)
|
||||
deleteModalOpen.value = false
|
||||
await loadTickets()
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTickets() {
|
||||
tickets.value = await clientTicketService.getAll()
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const [ticketsResult, projectsResult, usersResult] = await Promise.all([
|
||||
clientTicketService.getAll(),
|
||||
projectService.getAll(),
|
||||
userService.getAll(),
|
||||
])
|
||||
tickets.value = ticketsResult
|
||||
projects.value = projectsResult
|
||||
users.value = usersResult
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.status-modal-enter-active,
|
||||
.status-modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.status-modal-enter-from,
|
||||
.status-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('workflows.editWorkflow') : $t('workflows.addWorkflow')">
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('workflows.editWorkflow') : $t('workflows.addWorkflow') }}</h2>
|
||||
</template>
|
||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
@@ -46,15 +49,12 @@
|
||||
label="Libellé"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<select
|
||||
<MalioSelect
|
||||
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>
|
||||
:options="categoryOptions"
|
||||
label="Catégorie"
|
||||
group-class="w-48 shrink-0"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 px-2 text-red-600 hover:text-red-800"
|
||||
@@ -97,6 +97,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Workflow, StatusCategory } from '~/services/dto/workflow'
|
||||
import { STATUS_CATEGORY_COLOR } from '~/services/dto/workflow'
|
||||
import type { TaskStatusWrite } from '~/services/dto/task-status'
|
||||
import { useWorkflowService } from '~/services/workflows'
|
||||
import { useTaskStatusService } from '~/services/task-statuses'
|
||||
@@ -171,10 +172,20 @@ watch(() => props.modelValue, (open) => {
|
||||
touched.name = false
|
||||
})
|
||||
|
||||
watch(() => form.statuses.map(s => s.category), (cats, prev) => {
|
||||
if (!prev) return
|
||||
cats.forEach((cat, i) => {
|
||||
const s = form.statuses[i]
|
||||
if (s && cat !== prev[i] && s.color === STATUS_CATEGORY_COLOR[prev[i] as StatusCategory]) {
|
||||
s.color = STATUS_CATEGORY_COLOR[cat as StatusCategory]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function addStatus() {
|
||||
form.statuses.push({
|
||||
label: '',
|
||||
color: '#222783',
|
||||
color: STATUS_CATEGORY_COLOR.todo,
|
||||
position: form.statuses.length,
|
||||
isFinal: false,
|
||||
category: 'todo',
|
||||
|
||||
@@ -1,353 +0,0 @@
|
||||
<template>
|
||||
<Teleport v-if="isOpen" to="body">
|
||||
<Transition name="ticket-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 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(90vh, 900px)"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="border-b border-neutral-100 bg-neutral-50/80 px-4 py-4 sm:px-8 sm:py-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
v-if="ticket"
|
||||
class="rounded-md bg-primary-500 px-2.5 py-1 text-xs font-bold tracking-wide text-white"
|
||||
>
|
||||
CT-{{ String(ticket.number).padStart(3, '0') }}
|
||||
</span>
|
||||
<h2 class="text-lg font-bold tracking-tight text-neutral-900">
|
||||
{{ $t('portal.ticketDetail') }}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Edit button (only for open tickets submitted by current user) -->
|
||||
<MalioButton
|
||||
v-if="canEdit && !isEditing"
|
||||
variant="tertiary"
|
||||
icon-name="mdi:pencil-outline"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-3"
|
||||
:label="$t('common.edit')"
|
||||
@click="startEdit"
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:close"
|
||||
aria-label="Fermer"
|
||||
variant="ghost"
|
||||
icon-size="20"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div v-if="ticket" class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
|
||||
|
||||
<!-- Edit mode -->
|
||||
<template v-if="isEditing">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
||||
{{ $t('clientTicket.fields.title') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="editForm.title"
|
||||
type="text"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<MalioInputRichText
|
||||
v-model="editForm.description"
|
||||
:label="$t('clientTicket.description')"
|
||||
min-height="180px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="ticket.type === 'bug'" class="mt-4">
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
||||
{{ $t('clientTicket.fields.url') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="editForm.url"
|
||||
type="url"
|
||||
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"
|
||||
:placeholder="$t('clientTicket.fields.urlPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('common.cancel')"
|
||||
button-class="w-auto px-4"
|
||||
@click="cancelEdit"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="$t('common.save')"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSaving"
|
||||
@click="saveEdit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- View mode -->
|
||||
<template v-else>
|
||||
<!-- Title -->
|
||||
<h3 class="text-base font-bold text-neutral-900">{{ ticket.title }}</h3>
|
||||
|
||||
<!-- Badges -->
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:class="typeBadgeClass(ticket.type)"
|
||||
>
|
||||
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full px-3 py-1 text-xs font-semibold"
|
||||
:class="statusBadgeClass(ticket.status)"
|
||||
>
|
||||
{{ $t(`clientTicket.status.${ticket.status}`) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mt-4">
|
||||
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.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) -->
|
||||
<div v-if="ticket.url" class="mt-4">
|
||||
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.url') }}</p>
|
||||
<a
|
||||
:href="ticket.url"
|
||||
target="_blank"
|
||||
class="mt-1 text-sm text-primary-500 underline hover:text-primary-600"
|
||||
>
|
||||
{{ ticket.url }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Status comment -->
|
||||
<div v-if="ticket.statusComment" class="mt-4">
|
||||
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.statusComment') }}</p>
|
||||
<p class="mt-1 whitespace-pre-wrap rounded-lg bg-neutral-50 p-3 text-sm text-neutral-600">{{ ticket.statusComment }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Documents -->
|
||||
<TaskDocumentList
|
||||
v-if="localDocuments.length"
|
||||
:documents="localDocuments"
|
||||
:is-admin="canEdit"
|
||||
@preview="openPreview"
|
||||
@delete="handleDeleteDocument"
|
||||
/>
|
||||
|
||||
<!-- Document preview -->
|
||||
<TaskDocumentPreview
|
||||
:document="previewDoc"
|
||||
:has-prev="previewIndex > 0"
|
||||
:has-next="previewIndex < localDocuments.length - 1"
|
||||
@close="previewDoc = null"
|
||||
@prev="prevPreview"
|
||||
@next="nextPreview"
|
||||
/>
|
||||
|
||||
<!-- Upload zone -->
|
||||
<TaskDocumentUpload
|
||||
v-if="ticket"
|
||||
:client-ticket-id="ticket.id"
|
||||
@uploaded="refreshDocuments"
|
||||
/>
|
||||
|
||||
<!-- Date -->
|
||||
<p class="mt-6 text-xs text-neutral-400">
|
||||
{{ $t('clientTicket.createdAt') }} : {{ formatDate(ticket.createdAt) }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ClientTicket, ClientTicketWrite } from '~/services/dto/client-ticket'
|
||||
import type { TaskDocument } from '~/services/dto/task-document'
|
||||
import { useTaskDocumentService } from '~/services/task-documents'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
ticket: ClientTicket | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'refresh'): void
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
function close() {
|
||||
isEditing.value = false
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const auth = useAuthStore()
|
||||
const { getByTicket, remove: removeDocument } = useTaskDocumentService()
|
||||
const clientTicketService = useClientTicketService()
|
||||
const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()
|
||||
|
||||
// Edit mode
|
||||
const isEditing = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const editForm = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
url: '',
|
||||
})
|
||||
|
||||
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||
|
||||
const canEdit = computed(() => {
|
||||
if (!props.ticket) return false
|
||||
if (isAdmin.value) return true
|
||||
const status = props.ticket.status
|
||||
if (status === 'done' || status === 'rejected') return false
|
||||
const userId = auth.user?.id
|
||||
if (!userId) return false
|
||||
const sub = props.ticket.submittedBy
|
||||
if (!sub) return false
|
||||
// submittedBy can be an IRI string or an embedded object
|
||||
if (typeof sub === 'string') return sub === `/api/users/${userId}`
|
||||
if (typeof sub === 'object' && 'id' in sub) return (sub as { id: number }).id === userId
|
||||
return false
|
||||
})
|
||||
|
||||
function startEdit() {
|
||||
if (!props.ticket) return
|
||||
editForm.title = props.ticket.title
|
||||
editForm.description = props.ticket.description
|
||||
editForm.url = props.ticket.url ?? ''
|
||||
isEditing.value = true
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!props.ticket) return
|
||||
isSaving.value = true
|
||||
try {
|
||||
const data: Record<string, unknown> = {
|
||||
title: editForm.title,
|
||||
description: editForm.description,
|
||||
}
|
||||
if (props.ticket.type === 'bug') {
|
||||
data.url = editForm.url || null
|
||||
}
|
||||
await clientTicketService.update(props.ticket.id, data as Partial<ClientTicketWrite>)
|
||||
isEditing.value = false
|
||||
emit('refresh')
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Reset edit mode when ticket changes
|
||||
watch(() => props.ticket?.id, () => {
|
||||
isEditing.value = false
|
||||
})
|
||||
|
||||
async function handleDeleteDocument(doc: TaskDocument) {
|
||||
await removeDocument(doc.id)
|
||||
await refreshDocuments()
|
||||
}
|
||||
|
||||
async function refreshDocuments() {
|
||||
if (!props.ticket) return
|
||||
localDocuments.value = await getByTicket(props.ticket.id)
|
||||
}
|
||||
|
||||
// Document list (local copy to allow refresh)
|
||||
const localDocuments = ref<TaskDocument[]>([])
|
||||
|
||||
watch(() => props.ticket?.documents, (docs) => {
|
||||
localDocuments.value = docs ? [...docs] : []
|
||||
}, { immediate: true })
|
||||
|
||||
// Document preview
|
||||
const previewDoc = ref<TaskDocument | null>(null)
|
||||
|
||||
const previewIndex = computed(() => {
|
||||
if (!previewDoc.value) return -1
|
||||
return localDocuments.value.findIndex(d => d.id === previewDoc.value!.id)
|
||||
})
|
||||
|
||||
function openPreview(doc: TaskDocument) {
|
||||
previewDoc.value = doc
|
||||
}
|
||||
|
||||
function prevPreview() {
|
||||
if (previewIndex.value > 0) {
|
||||
previewDoc.value = localDocuments.value[previewIndex.value - 1]
|
||||
}
|
||||
}
|
||||
|
||||
function nextPreview() {
|
||||
if (previewIndex.value < localDocuments.value.length - 1) {
|
||||
previewDoc.value = localDocuments.value[previewIndex.value + 1]
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ticket-modal-enter-active,
|
||||
.ticket-modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.ticket-modal-enter-active > div:last-child,
|
||||
.ticket-modal-leave-active > div:last-child {
|
||||
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.ticket-modal-enter-from,
|
||||
.ticket-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ticket-modal-enter-from > div:last-child {
|
||||
transform: scale(0.95) translateY(8px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ticket-modal-leave-to > div:last-child {
|
||||
transform: scale(0.97);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,333 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Trigger button -->
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
icon-name="mdi:ticket-outline"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-3 sm:px-4 shrink-0"
|
||||
@click="open"
|
||||
>
|
||||
<span class="hidden sm:inline">{{ $t('clientTicket.adminTab') }}</span>
|
||||
<span
|
||||
v-if="totalCount > 0"
|
||||
class="flex h-5 min-w-5 items-center justify-center rounded-full bg-primary-500 px-1 text-xs font-bold text-white"
|
||||
>
|
||||
{{ totalCount }}
|
||||
</span>
|
||||
</MalioButton>
|
||||
|
||||
<!-- Panel -->
|
||||
<Teleport v-if="isOpen" to="body">
|
||||
<Transition name="ct-panel" appear>
|
||||
<div class="fixed inset-0 z-50 flex justify-end">
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
@click="close"
|
||||
/>
|
||||
|
||||
<!-- Slide panel -->
|
||||
<div class="relative z-10 flex h-full w-full max-w-lg flex-col bg-white shadow-2xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-neutral-200 px-5 py-4">
|
||||
<div>
|
||||
<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>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:close"
|
||||
aria-label="Fermer"
|
||||
variant="ghost"
|
||||
icon-size="20"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex items-center gap-3 border-b border-neutral-100 px-5 py-3">
|
||||
<select
|
||||
v-model="filterStatus"
|
||||
class="rounded-lg border border-neutral-300 px-3 py-1.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
>
|
||||
<option :value="null">{{ $t('clientTicket.allStatuses') }}</option>
|
||||
<option value="new">{{ $t('clientTicket.status.new') }}</option>
|
||||
<option value="in_progress">{{ $t('clientTicket.status.in_progress') }}</option>
|
||||
<option value="done">{{ $t('clientTicket.status.done') }}</option>
|
||||
<option value="rejected">{{ $t('clientTicket.status.rejected') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto px-5 py-4">
|
||||
<div v-if="isLoading" class="py-8 text-center text-sm text-neutral-400">
|
||||
{{ $t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredTickets.length === 0" class="py-8 text-center text-sm text-neutral-400">
|
||||
{{ $t('clientTicket.noTickets') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="ticket in filteredTickets"
|
||||
:key="ticket.id"
|
||||
class="rounded-lg border border-neutral-200 bg-white"
|
||||
>
|
||||
<!-- Ticket row -->
|
||||
<div
|
||||
class="flex cursor-pointer items-start justify-between gap-3 p-3 transition-colors hover:bg-neutral-50"
|
||||
@click="toggleExpand(ticket.id)"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-bold text-primary-500">CT-{{ String(ticket.number).padStart(3, '0') }}</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:class="typeBadgeClass(ticket.type)"
|
||||
>
|
||||
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold"
|
||||
:class="statusBadgeClass(ticket.status)"
|
||||
>
|
||||
{{ $t(`clientTicket.status.${ticket.status}`) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-sm font-semibold text-neutral-900 leading-snug">{{ ticket.title }}</p>
|
||||
<p class="mt-0.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:swap-horizontal"
|
||||
:aria-label="$t('clientTicket.changeStatus')"
|
||||
variant="ghost"
|
||||
icon-size="16"
|
||||
@click.stop="openStatusChange(ticket)"
|
||||
/>
|
||||
<Icon
|
||||
:name="expandedId === ticket.id ? 'mdi:chevron-up' : 'mdi:chevron-down'"
|
||||
size="18"
|
||||
class="text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded details -->
|
||||
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-3 py-3">
|
||||
<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"
|
||||
target="_blank"
|
||||
class="text-xs text-primary-500 underline hover:text-primary-600"
|
||||
>
|
||||
{{ ticket.url }}
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="ticket.statusComment" class="mt-2 rounded-lg bg-neutral-50 p-2 text-xs text-neutral-500">
|
||||
{{ ticket.statusComment }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Status change modal -->
|
||||
<Teleport v-if="statusModalOpen" to="body">
|
||||
<Transition name="ct-modal" appear>
|
||||
<div class="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||
<div
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
@click="statusModalOpen = false"
|
||||
/>
|
||||
<div class="relative z-10 w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.changeStatus') }}</h3>
|
||||
<p v-if="statusTarget" class="mt-1 text-sm text-neutral-500">
|
||||
CT-{{ String(statusTarget.number).padStart(3, '0') }} — {{ statusTarget.title }}
|
||||
</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">Nouveau statut</label>
|
||||
<select
|
||||
v-model="newStatus"
|
||||
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"
|
||||
>
|
||||
<option :value="null" disabled>—</option>
|
||||
<option
|
||||
v-for="s in availableStatusTransitions"
|
||||
:key="s.value"
|
||||
:value="s.value"
|
||||
>
|
||||
{{ s.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="newStatus === 'rejected'" class="mt-4">
|
||||
<MalioInputTextArea
|
||||
v-model="statusComment"
|
||||
:label="$t('clientTicket.statusComment')"
|
||||
:size="3"
|
||||
/>
|
||||
<p v-if="rejectionError" class="mt-1 text-xs text-red-500">
|
||||
{{ $t('clientTicket.rejectionRequired') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('common.cancel')"
|
||||
button-class="w-auto px-4"
|
||||
@click="statusModalOpen = false"
|
||||
/>
|
||||
<MalioButton
|
||||
label="Confirmer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isUpdatingStatus"
|
||||
@click="confirmStatusChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ClientTicket, ClientTicketStatus } from '~/services/dto/client-ticket'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: number
|
||||
projectName: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const clientTicketService = useClientTicketService()
|
||||
const { typeBadgeClass, statusBadgeClass, formatDate, getAvailableStatusTransitions } = useClientTicketHelpers()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const tickets = ref<ClientTicket[]>([])
|
||||
const filterStatus = ref<string | null>(null)
|
||||
const expandedId = ref<number | null>(null)
|
||||
|
||||
const totalCount = computed(() =>
|
||||
tickets.value.filter(t => t.status === 'new' || t.status === 'in_progress').length
|
||||
)
|
||||
|
||||
const filteredTickets = computed(() => {
|
||||
if (!filterStatus.value) return tickets.value
|
||||
return tickets.value.filter(t => t.status === filterStatus.value)
|
||||
})
|
||||
|
||||
// Status change
|
||||
const statusModalOpen = ref(false)
|
||||
const statusTarget = ref<ClientTicket | null>(null)
|
||||
const newStatus = ref<string | null>(null)
|
||||
const statusComment = ref('')
|
||||
const rejectionError = ref(false)
|
||||
const isUpdatingStatus = ref(false)
|
||||
|
||||
const availableStatusTransitions = computed(() => {
|
||||
if (!statusTarget.value) return []
|
||||
return getAvailableStatusTransitions(statusTarget.value.status, t)
|
||||
})
|
||||
|
||||
async function loadTickets() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
tickets.value = await clientTicketService.getAll({ project: props.projectId })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
isOpen.value = true
|
||||
loadTickets()
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen.value = false
|
||||
expandedId.value = null
|
||||
}
|
||||
|
||||
function toggleExpand(id: number) {
|
||||
expandedId.value = expandedId.value === id ? null : id
|
||||
}
|
||||
|
||||
function openStatusChange(ticket: ClientTicket) {
|
||||
statusTarget.value = ticket
|
||||
newStatus.value = null
|
||||
statusComment.value = ''
|
||||
rejectionError.value = false
|
||||
statusModalOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmStatusChange() {
|
||||
if (!statusTarget.value || !newStatus.value) return
|
||||
|
||||
if (newStatus.value === 'rejected' && !statusComment.value.trim()) {
|
||||
rejectionError.value = true
|
||||
return
|
||||
}
|
||||
|
||||
isUpdatingStatus.value = true
|
||||
try {
|
||||
await clientTicketService.updateStatus(statusTarget.value.id, {
|
||||
status: newStatus.value as ClientTicketStatus,
|
||||
statusComment: newStatus.value === 'rejected' ? statusComment.value.trim() : null,
|
||||
})
|
||||
statusModalOpen.value = false
|
||||
await loadTickets()
|
||||
} finally {
|
||||
isUpdatingStatus.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ct-panel-enter-active,
|
||||
.ct-panel-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.ct-panel-enter-active > div:last-child,
|
||||
.ct-panel-leave-active > div:last-child {
|
||||
transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.ct-panel-enter-from,
|
||||
.ct-panel-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ct-panel-enter-from > div:last-child,
|
||||
.ct-panel-leave-to > div:last-child {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.ct-modal-enter-active,
|
||||
.ct-modal-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.ct-modal-enter-from,
|
||||
.ct-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('clients.editClient') : $t('clients.addClient')">
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('clients.editClient') : $t('clients.addClient') }}</h2>
|
||||
</template>
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
|
||||
121
frontend/components/mail/MailAttachmentPreview.vue
Normal file
121
frontend/components/mail/MailAttachmentPreview.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
/** Ouverture de la visionneuse. */
|
||||
modelValue: boolean
|
||||
/** Nom du fichier affiché dans la barre. */
|
||||
filename: string
|
||||
/** Type MIME — détermine le rendu (image vs PDF). */
|
||||
mimeType: string
|
||||
/** Object URL du Blob de la pièce jointe. null tant que le contenu charge. */
|
||||
url: string | null
|
||||
/** Téléchargement en cours du contenu. */
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
download: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const isImage = computed(() => props.mimeType.startsWith('image/'))
|
||||
const isPdf = computed(() => props.mimeType === 'application/pdf')
|
||||
|
||||
function close(): void {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent): void {
|
||||
if (e.key === 'Escape') close()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (open) {
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
} else {
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport v-if="modelValue" to="body">
|
||||
<Transition name="mail-preview" appear>
|
||||
<div class="fixed inset-0 z-50 flex flex-col bg-slate-900/80 backdrop-blur-sm">
|
||||
<!-- Barre supérieure -->
|
||||
<div class="flex flex-shrink-0 items-center justify-between gap-4 px-4 py-3 text-white">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Icon
|
||||
:name="isImage ? 'material-symbols:image-outline' : 'material-symbols:picture-as-pdf-outline'"
|
||||
size="18"
|
||||
class="flex-shrink-0 text-white/70"
|
||||
/>
|
||||
<span class="truncate text-sm font-medium">{{ filename }}</span>
|
||||
</div>
|
||||
<div class="flex flex-shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-white/90 transition-colors hover:bg-white/10"
|
||||
@click="emit('download')"
|
||||
>
|
||||
<Icon name="material-symbols:download" size="18" />
|
||||
<span class="hidden sm:inline">{{ t('mail.actions.download') }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md p-1.5 text-white/90 transition-colors hover:bg-white/10"
|
||||
:aria-label="t('mail.preview.close')"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="mdi:close" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contenu -->
|
||||
<div class="flex min-h-0 flex-1 items-center justify-center overflow-auto p-4" @click.self="close">
|
||||
<div v-if="loading" class="flex flex-col items-center gap-3 text-white/70">
|
||||
<Icon name="material-symbols:progress-activity" size="32" class="animate-spin" />
|
||||
<span class="text-sm">{{ t('mail.preview.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<img
|
||||
v-else-if="isImage && url"
|
||||
:src="url"
|
||||
:alt="filename"
|
||||
class="max-h-full max-w-full rounded-lg object-contain shadow-2xl"
|
||||
>
|
||||
|
||||
<iframe
|
||||
v-else-if="isPdf && url"
|
||||
:src="url"
|
||||
:title="filename"
|
||||
class="h-full w-full max-w-5xl rounded-lg bg-white shadow-2xl"
|
||||
/>
|
||||
|
||||
<div v-else class="flex flex-col items-center gap-3 text-white/70">
|
||||
<Icon name="material-symbols:visibility-off-outline" size="32" />
|
||||
<span class="text-sm">{{ t('mail.preview.unavailable') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-preview-enter-active,
|
||||
.mail-preview-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.mail-preview-enter-from,
|
||||
.mail-preview-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -3,74 +3,63 @@ import type { MailMessageDetailDto } from '~/services/dto/mail'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { useMailService } from '~/services/mail'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
import { useTaskGroupService } from '~/services/task-groups'
|
||||
import { useTaskPriorityService } from '~/services/task-priorities'
|
||||
import { useUserService } from '~/services/users'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 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 auth = useAuthStore()
|
||||
const mailService = useMailService()
|
||||
const projectService = useProjectService()
|
||||
const taskGroupService = useTaskGroupService()
|
||||
const priorityService = useTaskPriorityService()
|
||||
|
||||
// ─── État formulaire ──────────────────────────────────────────────────────
|
||||
const userService = useUserService()
|
||||
|
||||
const projectId = ref<number | null>(null)
|
||||
const taskGroupId = ref<number | null>(null)
|
||||
const priorityId = ref<number | null>(null)
|
||||
const assigneeId = ref<number | null>(null)
|
||||
const statusId = ref<number | null>(null)
|
||||
const isSubmitting = ref(false)
|
||||
const touchedProject = ref(false)
|
||||
|
||||
// ─── Données de référence ─────────────────────────────────────────────────
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
const groups = ref<TaskGroup[]>([])
|
||||
const priorities = ref<TaskPriority[]>([])
|
||||
const users = ref<UserData[]>([])
|
||||
const loadingGroups = ref(false)
|
||||
|
||||
const projectOptions = computed(() =>
|
||||
projects.value.map(p => ({ label: p.name, value: p.id })),
|
||||
)
|
||||
const projectOptions = computed(() => projects.value.map(p => ({ label: p.name, value: p.id })))
|
||||
const groupOptions = computed(() => groups.value.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id })))
|
||||
const userOptions = computed(() => users.value.map(u => ({ label: u.username, value: u.id })))
|
||||
|
||||
const groupOptions = computed(() =>
|
||||
groups.value.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id })),
|
||||
const selectedProject = computed(() => projects.value.find(p => p.id === projectId.value) ?? null)
|
||||
const statusOptions = computed(() =>
|
||||
(selectedProject.value?.workflow?.statuses ?? []).map(s => ({ label: s.label, value: s.id })),
|
||||
)
|
||||
|
||||
const priorityOptions = computed(() =>
|
||||
priorities.value.map(p => ({ label: p.label, value: p.id })),
|
||||
)
|
||||
|
||||
// ─── Chargement initial ───────────────────────────────────────────────────
|
||||
|
||||
onMounted(async () => {
|
||||
const [projs, prios] = await Promise.all([
|
||||
const [projs, us] = await Promise.all([
|
||||
projectService.getAll({ archived: false }),
|
||||
priorityService.getAll(),
|
||||
userService.getAll(),
|
||||
])
|
||||
projects.value = projs
|
||||
priorities.value = prios
|
||||
users.value = us
|
||||
})
|
||||
|
||||
// Recharger les groupes quand le projet change
|
||||
watch(projectId, async (pid) => {
|
||||
taskGroupId.value = null
|
||||
statusId.value = selectedProject.value?.workflow?.statuses?.[0]?.id ?? null
|
||||
groups.value = []
|
||||
if (!pid) return
|
||||
loadingGroups.value = true
|
||||
@@ -81,18 +70,16 @@ watch(projectId, async (pid) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Reset formulaire à l'ouverture
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
projectId.value = null
|
||||
taskGroupId.value = null
|
||||
priorityId.value = null
|
||||
statusId.value = null
|
||||
assigneeId.value = auth.user?.id ?? null
|
||||
touchedProject.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Actions ──────────────────────────────────────────────────────────────
|
||||
|
||||
function close(): void {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
@@ -100,13 +87,13 @@ function close(): void {
|
||||
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,
|
||||
assigneeId: assigneeId.value ?? undefined,
|
||||
statusId: statusId.value ?? undefined,
|
||||
})
|
||||
emit('created', task)
|
||||
close()
|
||||
@@ -117,135 +104,41 @@ async function handleSubmit(): Promise<void> {
|
||||
</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>
|
||||
<AppModal
|
||||
:model-value="modelValue"
|
||||
width="lg"
|
||||
:title="t('mail.createTaskModal.title')"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="space-y-5">
|
||||
<div v-if="messageDetail" class="rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-3 text-sm">
|
||||
<p class="truncate font-medium text-neutral-800">{{ messageDetail.header.subject ?? t('mail.noSubject') }}</p>
|
||||
<p class="mt-0.5 truncate text-xs text-neutral-500">{{ messageDetail.header.fromName ?? messageDetail.header.fromEmail }}</p>
|
||||
<p class="mt-2 text-xs italic text-neutral-400">{{ t('mail.createTaskModal.titleHint') }}</p>
|
||||
<p class="text-xs italic text-neutral-400">{{ t('mail.createTaskModal.descriptionHint') }}</p>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<div>
|
||||
<MalioSelect v-model="projectId" :options="projectOptions" :label="t('mail.createTaskModal.projectLabel')" :empty-option-label="t('mail.createTaskModal.projectPlaceholder')" group-class="w-full" />
|
||||
<p v-if="touchedProject && !projectId" class="mt-1 text-xs text-red-500">{{ t('mail.createTaskModal.projectLabel').replace(' *', '') }} requis</p>
|
||||
</div>
|
||||
|
||||
<div v-if="projectId">
|
||||
<MalioSelect v-model="taskGroupId" :options="groupOptions" :label="t('mail.createTaskModal.groupLabel')" :empty-option-label="t('mail.createTaskModal.groupPlaceholder')" group-class="w-full" :disabled="loadingGroups" />
|
||||
</div>
|
||||
|
||||
<div v-if="projectId">
|
||||
<MalioSelect v-model="statusId" :options="statusOptions" :label="t('mail.createTaskModal.statusLabel')" group-class="w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<MalioSelect v-model="assigneeId" :options="userOptions" :label="t('mail.createTaskModal.assigneeLabel')" :empty-option-label="t('mail.createTaskModal.assigneePlaceholder')" group-class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<MalioButton variant="tertiary" label="Annuler" button-class="w-auto px-4" @click="close" />
|
||||
<MalioButton :label="t('mail.createTaskModal.submit')" button-class="w-auto px-6" :disabled="isSubmitting" @click="handleSubmit" />
|
||||
</template>
|
||||
</AppModal>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -147,7 +147,7 @@ async function handleSubmit(): Promise<void> {
|
||||
:options="projectFilterOptions"
|
||||
:label="t('mail.linkTaskModal.projectFilter')"
|
||||
:empty-option-label="t('mail.linkTaskModal.projectAll')"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
|
||||
<!-- Recherche tâche -->
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { MailMessageDetailDto, MailAddressDto } from '~/services/dto/mail'
|
||||
import type { MailMessageDetailDto, MailAddressDto, MailAttachmentDto } from '~/services/dto/mail'
|
||||
import { sanitizeMailHtml } from '~/utils/sanitizeMailHtml'
|
||||
import { useMailService } from '~/services/mail'
|
||||
|
||||
@@ -24,22 +24,101 @@ const sanitizedBody = computed((): string => {
|
||||
return sanitizeMailHtml(props.detail.bodyHtml, { allowImages: showImages.value })
|
||||
})
|
||||
|
||||
// ─── Pièces jointes : aperçu / téléchargement ──────────────────────────────
|
||||
|
||||
function isImage(mime: string): boolean {
|
||||
return mime.startsWith('image/')
|
||||
}
|
||||
|
||||
function isPdf(mime: string): boolean {
|
||||
return mime === 'application/pdf'
|
||||
}
|
||||
|
||||
function isPreviewable(mime: string): boolean {
|
||||
return isImage(mime) || isPdf(mime)
|
||||
}
|
||||
|
||||
function attachmentIcon(mime: string): string {
|
||||
if (isImage(mime)) return 'material-symbols:image-outline'
|
||||
if (isPdf(mime)) return 'material-symbols:picture-as-pdf-outline'
|
||||
return 'material-symbols:attach-file'
|
||||
}
|
||||
|
||||
const previewOpen = ref(false)
|
||||
const previewLoading = ref(false)
|
||||
const previewAtt = ref<MailAttachmentDto | null>(null)
|
||||
const previewUrl = ref<string | null>(null)
|
||||
let previewBlob: Blob | null = null
|
||||
|
||||
function revokePreview(): void {
|
||||
if (previewUrl.value) {
|
||||
URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = null
|
||||
}
|
||||
previewBlob = null
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.detail?.header.id,
|
||||
() => {
|
||||
showImages.value = false
|
||||
previewOpen.value = false
|
||||
revokePreview()
|
||||
},
|
||||
)
|
||||
|
||||
watch(previewOpen, (open) => {
|
||||
if (!open) revokePreview()
|
||||
})
|
||||
|
||||
onBeforeUnmount(revokePreview)
|
||||
|
||||
async function handleAttachmentClick(att: MailAttachmentDto): Promise<void> {
|
||||
if (!isPreviewable(att.mimeType)) {
|
||||
await handleDownload(att.downloadId, att.filename)
|
||||
return
|
||||
}
|
||||
|
||||
previewAtt.value = att
|
||||
previewUrl.value = null
|
||||
previewLoading.value = true
|
||||
previewOpen.value = true
|
||||
|
||||
try {
|
||||
const { data } = await mailService.downloadAttachment(att.downloadId)
|
||||
previewBlob = data
|
||||
previewUrl.value = URL.createObjectURL(data)
|
||||
} catch {
|
||||
// useApi affiche déjà le toast — on referme la visionneuse.
|
||||
previewOpen.value = false
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFromPreview(): void {
|
||||
const att = previewAtt.value
|
||||
if (!att) return
|
||||
if (previewBlob) {
|
||||
triggerBlobDownload(previewBlob, att.filename)
|
||||
} else {
|
||||
void handleDownload(att.downloadId, att.filename)
|
||||
}
|
||||
}
|
||||
|
||||
function triggerBlobDownload(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
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)
|
||||
triggerBlobDownload(data, filename)
|
||||
} catch {
|
||||
// L'erreur est gérée par useApi (toast automatique)
|
||||
}
|
||||
@@ -169,15 +248,31 @@ function joinAddresses(addresses: MailAddressDto[]): string {
|
||||
: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)"
|
||||
:title="isPreviewable(att.mimeType) ? t('mail.preview.open') : t('mail.actions.download')"
|
||||
@click="handleAttachmentClick(att)"
|
||||
>
|
||||
<Icon name="material-symbols:attach-file" size="14" class="flex-shrink-0 text-neutral-400" />
|
||||
<Icon :name="attachmentIcon(att.mimeType)" size="14" class="flex-shrink-0 text-neutral-400" />
|
||||
<span class="max-w-[180px] truncate">{{ att.filename }}</span>
|
||||
<span class="text-neutral-400">({{ Math.round(att.size / 1024) }} Ko)</span>
|
||||
<Icon
|
||||
v-if="isPreviewable(att.mimeType)"
|
||||
name="material-symbols:visibility-outline"
|
||||
size="13"
|
||||
class="flex-shrink-0 text-neutral-400"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MailAttachmentPreview
|
||||
v-if="previewAtt"
|
||||
v-model="previewOpen"
|
||||
:filename="previewAtt.filename"
|
||||
:mime-type="previewAtt.mimeType"
|
||||
:url="previewUrl"
|
||||
:loading="previewLoading"
|
||||
@download="downloadFromPreview"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { MailMessageHeaderDto } from '~/services/dto/mail'
|
||||
import { useMailService } from '~/services/mail'
|
||||
import { useMailStore } from '~/stores/mail'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
/** ID de la tâche cible (destinataire du lien) */
|
||||
taskId: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
/** Émis après liaison réussie — payload = id du message lié */
|
||||
linked: [messageId: number]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const mailService = useMailService()
|
||||
const mailStore = useMailStore()
|
||||
|
||||
// ─── État ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const searchQuery = ref('')
|
||||
const allMessages = ref<MailMessageHeaderDto[]>([])
|
||||
const selectedMessage = ref<MailMessageHeaderDto | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
// ─── Filtrage local (pas d'appel API par frappe — les messages sont déjà chargés) ──
|
||||
|
||||
const filteredMessages = computed(() => {
|
||||
const q = searchQuery.value.toLowerCase().trim()
|
||||
if (!q) return allMessages.value
|
||||
return allMessages.value.filter(
|
||||
(m) =>
|
||||
(m.subject ?? '').toLowerCase().includes(q)
|
||||
|| (m.fromName ?? '').toLowerCase().includes(q)
|
||||
|| (m.fromEmail ?? '').toLowerCase().includes(q),
|
||||
)
|
||||
})
|
||||
|
||||
// ─── Chargement à l'ouverture ─────────────────────────────────────────────
|
||||
|
||||
watch(() => props.modelValue, async (open) => {
|
||||
if (!open) return
|
||||
searchQuery.value = ''
|
||||
selectedMessage.value = null
|
||||
isLoading.value = true
|
||||
try {
|
||||
// Utiliser le dossier actuellement sélectionné dans le store si disponible,
|
||||
// sinon fallback sur INBOX.
|
||||
const folderPath = mailStore.selectedFolderPath ?? 'INBOX'
|
||||
const page = await mailService.listMessages(folderPath, undefined, 50)
|
||||
allMessages.value = page.items
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Actions ──────────────────────────────────────────────────────────────
|
||||
|
||||
function close(): void {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function selectMessage(msg: MailMessageHeaderDto): void {
|
||||
selectedMessage.value = msg
|
||||
}
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
if (!selectedMessage.value) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await mailService.linkTask(selectedMessage.value.id, props.taskId)
|
||||
emit('linked', selectedMessage.value.id)
|
||||
close()
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Formatage ────────────────────────────────────────────────────────────
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleDateString('fr', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport v-if="modelValue" to="body">
|
||||
<Transition name="mail-modal" appear>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
@click="close"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="relative z-10 w-full max-w-lg rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 overflow-hidden"
|
||||
style="max-height: min(90vh, 640px)"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-neutral-100 bg-neutral-50/80 px-6 py-4">
|
||||
<h2 class="text-base font-bold text-neutral-900">
|
||||
{{ t('mail.pickerModal.title') }}
|
||||
</h2>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:close"
|
||||
aria-label="Fermer"
|
||||
variant="ghost"
|
||||
icon-size="20"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Corps -->
|
||||
<div class="overflow-y-auto px-6 py-5 space-y-4">
|
||||
<!-- Recherche locale -->
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('mail.pickerModal.searchPlaceholder')"
|
||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
|
||||
<!-- Résultats -->
|
||||
<div class="max-h-80 overflow-y-auto rounded-md border border-neutral-200 divide-y divide-neutral-100">
|
||||
<!-- Chargement -->
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center py-8 text-sm text-neutral-400"
|
||||
>
|
||||
<Icon name="material-symbols:progress-activity" size="18" class="mr-2 animate-spin" />
|
||||
{{ t('mail.pickerModal.loading') }}
|
||||
</div>
|
||||
|
||||
<!-- Vide -->
|
||||
<div
|
||||
v-else-if="filteredMessages.length === 0"
|
||||
class="py-8 text-center text-sm text-neutral-400 italic"
|
||||
>
|
||||
{{ t('mail.pickerModal.empty') }}
|
||||
</div>
|
||||
|
||||
<!-- Liste -->
|
||||
<button
|
||||
v-for="msg in filteredMessages"
|
||||
:key="msg.id"
|
||||
type="button"
|
||||
class="flex w-full items-start gap-3 px-4 py-3 text-left text-sm transition-colors hover:bg-neutral-50"
|
||||
:class="selectedMessage?.id === msg.id
|
||||
? 'bg-primary-50 border-l-2 border-primary-500'
|
||||
: 'border-l-2 border-transparent'"
|
||||
@click="selectMessage(msg)"
|
||||
>
|
||||
<Icon
|
||||
name="material-symbols:mail-outline"
|
||||
size="16"
|
||||
class="mt-0.5 flex-shrink-0 text-neutral-400"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate font-medium text-neutral-800">
|
||||
{{ msg.subject ?? t('mail.noSubject') }}
|
||||
</p>
|
||||
<p class="flex items-center gap-2 text-xs text-neutral-500">
|
||||
<span class="truncate">{{ msg.fromName ?? msg.fromEmail }}</span>
|
||||
<span class="flex-shrink-0">·</span>
|
||||
<span class="flex-shrink-0">{{ formatDate(msg.sentAt ?? msg.receivedAt) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<Icon
|
||||
v-if="selectedMessage?.id === msg.id"
|
||||
name="material-symbols:check-circle"
|
||||
size="16"
|
||||
class="flex-shrink-0 text-primary-500"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex justify-end gap-3 border-t border-neutral-100 px-6 py-4">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
label="Annuler"
|
||||
button-class="w-auto px-4"
|
||||
@click="close"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('mail.pickerModal.submit')"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="!selectedMessage || isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-modal-enter-active,
|
||||
.mail-modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.mail-modal-enter-active > div:last-child,
|
||||
.mail-modal-leave-active > div:last-child {
|
||||
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.mail-modal-enter-from,
|
||||
.mail-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.mail-modal-enter-from > div:last-child {
|
||||
transform: scale(0.95) translateY(8px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -106,19 +106,7 @@ function handleClick(notif: Notification) {
|
||||
if (!notif.isRead) {
|
||||
markAsRead(notif.id)
|
||||
}
|
||||
|
||||
if (notif.relatedTicket) {
|
||||
const auth = useAuthStore()
|
||||
const isClient = auth.user?.roles?.includes('ROLE_CLIENT')
|
||||
|
||||
if (isClient) {
|
||||
navigateTo(`/portal`)
|
||||
} else {
|
||||
navigateTo(`/admin?tab=tickets`)
|
||||
}
|
||||
|
||||
isOpen.value = false
|
||||
}
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
async function handleMarkAllRead() {
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('projects.editProject') : $t('projects.addProject')">
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('projects.editProject') : $t('projects.addProject') }}</h2>
|
||||
</template>
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.code"
|
||||
v-model="codeProxy"
|
||||
label="Code"
|
||||
input-class="w-full uppercase"
|
||||
input-class="w-full"
|
||||
:max-length="10"
|
||||
:disabled="isEditing"
|
||||
:error="touched.code && !form.code.trim() ? 'Le code est requis' : touched.code && !/^[A-Z]{2,10}$/.test(form.code.trim()) ? '2 à 10 lettres majuscules' : ''"
|
||||
:error="touched.code && !form.code ? 'Le code est requis' : touched.code && !/^[A-Z]{2,10}$/.test(form.code) ? '2 à 10 lettres majuscules' : ''"
|
||||
@blur="touched.code = true"
|
||||
@input="form.code = form.code.toUpperCase().replace(/[^A-Z]/g, '')"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
@@ -27,7 +30,7 @@
|
||||
:options="clientOptions"
|
||||
label="Client"
|
||||
empty-option-label="Aucun client"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<ColorPicker v-model="form.color" />
|
||||
@@ -39,7 +42,7 @@
|
||||
:options="giteaRepoOptions"
|
||||
label="Dépôt Gitea"
|
||||
empty-option-label="Aucun dépôt"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -49,7 +52,7 @@
|
||||
:options="bookstackShelfOptions"
|
||||
label="Étagère BookStack"
|
||||
empty-option-label="Aucune étagère"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -186,6 +189,17 @@ const touched = reactive({
|
||||
name: false,
|
||||
})
|
||||
|
||||
// Source unique de vérité : on sanitise dans le setter (majuscules, lettres
|
||||
// uniquement, max 10) plutôt que via @input — sinon course entre la mutation
|
||||
// manuelle et l'émission update:modelValue de MalioInputText, qui laissait
|
||||
// form.code en minuscules et bloquait la création.
|
||||
const codeProxy = computed({
|
||||
get: () => form.code,
|
||||
set: (value: string) => {
|
||||
form.code = (value ?? '').toUpperCase().replace(/[^A-Z]/g, '').slice(0, 10)
|
||||
},
|
||||
})
|
||||
|
||||
const clientOptions = computed(() =>
|
||||
props.clients.map(c => ({ label: c.name, value: c.id }))
|
||||
)
|
||||
@@ -222,7 +236,7 @@ async function handleSubmit() {
|
||||
touched.name = true
|
||||
touched.code = true
|
||||
if (!form.name.trim()) return
|
||||
if (!isEditing.value && (!form.code.trim() || !/^[A-Z]{2,10}$/.test(form.code.trim()))) return
|
||||
if (!isEditing.value && !/^[A-Z]{2,10}$/.test(form.code)) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
@@ -254,7 +268,7 @@ async function handleSubmit() {
|
||||
if (isEditing.value && props.project) {
|
||||
await update(props.project.id, payload)
|
||||
} else {
|
||||
payload.code = form.code.trim()
|
||||
payload.code = form.code
|
||||
await create(payload)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
:options="targetOptions"
|
||||
:label="$t('workflows.switchTargetLabel')"
|
||||
empty-option-label="—"
|
||||
min-width="!w-full"
|
||||
group-class="!w-full"
|
||||
/>
|
||||
|
||||
<div v-if="targetWorkflow" class="flex flex-col gap-2">
|
||||
|
||||
35
frontend/components/task/StatusPickerPopover.vue
Normal file
35
frontend/components/task/StatusPickerPopover.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
|
||||
defineProps<{
|
||||
statuses: TaskStatus[]
|
||||
x: number
|
||||
y: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
pick: [status: TaskStatus]
|
||||
cancel: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed inset-0 z-[60]" @click="emit('cancel')" />
|
||||
<div
|
||||
class="fixed z-[61] min-w-44 rounded-lg border border-neutral-200 bg-white py-1 shadow-xl"
|
||||
:style="{ left: x + 'px', top: y + 'px' }"
|
||||
>
|
||||
<button
|
||||
v-for="s in statuses"
|
||||
:key="s.id"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-neutral-50"
|
||||
@click="emit('pick', s)"
|
||||
>
|
||||
<span class="h-3 w-3 shrink-0 rounded-full" :style="{ backgroundColor: s.color }" />
|
||||
{{ s.label }}
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -21,7 +21,7 @@
|
||||
:options="statusOptions"
|
||||
label="Status"
|
||||
empty-option-label="Status"
|
||||
min-width="!w-32"
|
||||
group-class="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'status', v)"
|
||||
@@ -39,7 +39,7 @@
|
||||
:options="userOptions"
|
||||
label="User"
|
||||
empty-option-label="User"
|
||||
min-width="!w-32"
|
||||
group-class="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'assignee', v)"
|
||||
@@ -50,7 +50,7 @@
|
||||
:options="priorityOptions"
|
||||
label="Priorité"
|
||||
empty-option-label="Priorité"
|
||||
min-width="!w-32"
|
||||
group-class="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'priority', v)"
|
||||
@@ -61,7 +61,7 @@
|
||||
:options="effortOptions"
|
||||
label="Effort"
|
||||
empty-option-label="Effort"
|
||||
min-width="!w-32"
|
||||
group-class="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'effort', v)"
|
||||
@@ -73,7 +73,7 @@
|
||||
:options="groupOptions"
|
||||
label="Groupe"
|
||||
empty-option-label="Groupe"
|
||||
min-width="!w-32"
|
||||
group-class="!w-32"
|
||||
text-field="text-xs"
|
||||
text-value="text-xs"
|
||||
@update:model-value="(v: number | null) => v && emit('bulk-update', 'group', v)"
|
||||
|
||||
@@ -20,14 +20,8 @@
|
||||
name="mdi:flag-variant"
|
||||
class="h-3.5 w-3.5 text-red-600"
|
||||
/>
|
||||
<Icon
|
||||
v-if="task.clientTicket"
|
||||
name="heroicons:user-circle"
|
||||
class="h-4 w-4 text-blue-400"
|
||||
:title="$t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') })"
|
||||
/>
|
||||
</div>
|
||||
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||
<h4 class="line-clamp-2 text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||
</div>
|
||||
<MalioButtonIcon
|
||||
:icon="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'"
|
||||
@@ -39,7 +33,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex items-center gap-1.5">
|
||||
<div class="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
v-if="showStatusBadge && task.status"
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
|
||||
@@ -50,14 +50,13 @@ import { useTaskDocumentService } from '~/services/task-documents'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId?: number
|
||||
clientTicketId?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
uploaded: []
|
||||
}>()
|
||||
|
||||
const { upload: uploadFile, uploadForTicket } = useTaskDocumentService()
|
||||
const { upload: uploadFile } = useTaskDocumentService()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -110,9 +109,7 @@ async function processFiles(files: File[]) {
|
||||
uploads.value.push(state)
|
||||
|
||||
try {
|
||||
if (props.clientTicketId) {
|
||||
await uploadForTicket(props.clientTicketId, file)
|
||||
} else if (props.taskId) {
|
||||
if (props.taskId) {
|
||||
await uploadFile(props.taskId, file)
|
||||
}
|
||||
state.uploading = false
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskEfforts.editEffort') : $t('taskEfforts.addEffort')">
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('taskEfforts.editEffort') : $t('taskEfforts.addEffort') }}</h2>
|
||||
</template>
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
v-model="branchForm.type"
|
||||
:options="typeOptions"
|
||||
:label="$t('gitea.branch.type')"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="branchForm.baseBranch"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskGroups.editGroup') : $t('taskGroups.addGroup')">
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('taskGroups.editGroup') : $t('taskGroups.addGroup') }}</h2>
|
||||
</template>
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.title"
|
||||
|
||||
@@ -35,27 +35,10 @@
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Client ticket link -->
|
||||
<div
|
||||
v-if="isEditing && task?.clientTicket"
|
||||
class="mt-2 flex items-center gap-2 rounded-lg bg-blue-50 px-3 py-2"
|
||||
>
|
||||
<Icon name="heroicons:user-circle" class="h-5 w-5 text-blue-500" />
|
||||
<span class="text-sm font-medium text-blue-700">
|
||||
{{ $t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') }) }}
|
||||
</span>
|
||||
<span
|
||||
class="ml-auto rounded-full px-2 py-0.5 text-xs font-semibold"
|
||||
:class="ticketStatusClass(task.clientTicket.status)"
|
||||
>
|
||||
{{ $t(`clientTicket.status.${task.clientTicket.status}`) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<form @submit.prevent="handleSubmit" class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
|
||||
<form @submit.prevent="handleSubmit" class="min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
|
||||
<!-- Tabs -->
|
||||
<div class="border-b border-neutral-100 -mx-4 px-4 sm:-mx-8 sm:px-8 mb-4">
|
||||
<nav class="flex gap-6">
|
||||
@@ -91,7 +74,7 @@
|
||||
:options="projectOptions"
|
||||
label="Projet *"
|
||||
empty-option-label="Sélectionner un projet"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<p v-if="touched.project && !form.projectId" class="mt-1 text-xs text-red-500">
|
||||
Le projet est requis
|
||||
@@ -105,43 +88,35 @@
|
||||
:options="statusOptions"
|
||||
label="Statut"
|
||||
empty-option-label="Aucun statut"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.assigneeId"
|
||||
:options="userOptions"
|
||||
label="User"
|
||||
empty-option-label="Aucun utilisateur"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.effortId"
|
||||
:options="effortOptions"
|
||||
label="Effort"
|
||||
empty-option-label="Aucun effort"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.priorityId"
|
||||
:options="priorityOptions"
|
||||
label="Priorité"
|
||||
empty-option-label="Aucune priorité"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.groupId"
|
||||
:options="groupOptions"
|
||||
label="Groupe"
|
||||
empty-option-label="Aucun groupe"
|
||||
min-width="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-if="clientTicketOptions.length"
|
||||
v-model="form.clientTicketId"
|
||||
:options="clientTicketOptions"
|
||||
label="Ticket client"
|
||||
empty-option-label="Aucun ticket client"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -481,73 +456,54 @@
|
||||
</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'"
|
||||
>
|
||||
</form>
|
||||
|
||||
<!-- Footer -->
|
||||
<div
|
||||
class="shrink-0 flex items-center border-t border-neutral-100 bg-white px-4 py-4 sm:px-8 sm:py-5"
|
||||
:class="isEditing ? 'justify-between' : 'justify-end'"
|
||||
>
|
||||
<MalioButton
|
||||
v-if="isEditing"
|
||||
variant="danger"
|
||||
label="Supprimer"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSubmitting"
|
||||
@click="confirmDeleteOpen = true"
|
||||
/>
|
||||
<div class="flex gap-3">
|
||||
<MalioButton
|
||||
v-if="isEditing"
|
||||
variant="danger"
|
||||
label="Supprimer"
|
||||
v-if="canArchive"
|
||||
variant="tertiary"
|
||||
:label="$t('archive.archiveButton')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSubmitting"
|
||||
@click="confirmDeleteOpen = true"
|
||||
@click="handleArchive"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="canUnarchive"
|
||||
variant="tertiary"
|
||||
:label="$t('archive.unarchiveButton')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleUnarchive"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
label="Annuler"
|
||||
button-class="w-auto px-4"
|
||||
@click="close"
|
||||
/>
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
<div class="flex gap-3">
|
||||
<MalioButton
|
||||
v-if="canArchive"
|
||||
variant="tertiary"
|
||||
:label="$t('archive.archiveButton')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleArchive"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="canUnarchive"
|
||||
variant="tertiary"
|
||||
:label="$t('archive.unarchiveButton')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleUnarchive"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
label="Annuler"
|
||||
button-class="w-auto px-4"
|
||||
@click="close"
|
||||
/>
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<ConfirmDeleteTaskModal
|
||||
v-model="confirmDeleteOpen"
|
||||
@@ -568,10 +524,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { Task, TaskWrite } from '~/services/dto/task'
|
||||
import type { TaskDocument } from '~/services/dto/task-document'
|
||||
import type { ClientTicket } from '~/services/dto/client-ticket'
|
||||
import { useGiteaService } from '~/services/gitea'
|
||||
import { useTaskDocumentService } from '~/services/task-documents'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
import ConfirmDeleteDocumentModal from '~/components/ui/ConfirmDeleteDocumentModal.vue'
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||
@@ -624,7 +578,6 @@ const activeTab = ref<'details' | 'planning' | 'mails'>('details')
|
||||
const mailService = useMailService()
|
||||
const linkedMails = ref<MailMessageHeaderDto[]>([])
|
||||
const mailsLoading = ref(false)
|
||||
const showMailPickerModal = ref(false)
|
||||
|
||||
const giteaUrl = ref('')
|
||||
const { getSettings: getGiteaSettings } = useGiteaService()
|
||||
@@ -647,7 +600,6 @@ const form = reactive({
|
||||
collaboratorIds: [] as number[],
|
||||
groupId: null as number | null,
|
||||
tagIds: [] as number[],
|
||||
clientTicketId: null as number | null,
|
||||
projectId: null as number | null,
|
||||
scheduledStart: '',
|
||||
scheduledEnd: '',
|
||||
@@ -671,10 +623,27 @@ const touched = reactive({
|
||||
project: false,
|
||||
})
|
||||
|
||||
const statusOptions = computed(() =>
|
||||
props.statuses.map(s => ({ label: s.label, value: s.id }))
|
||||
const showProjectSelect = computed(() => !!props.projects?.length && !isEditing.value)
|
||||
|
||||
const projectOptions = computed(() =>
|
||||
(props.projects ?? []).map(p => ({ label: p.name, value: p.id }))
|
||||
)
|
||||
|
||||
const resolvedProjectId = computed(() =>
|
||||
showProjectSelect.value ? form.projectId : props.projectId
|
||||
)
|
||||
|
||||
const statusOptions = computed(() => {
|
||||
const project = props.projects?.find(p => p.id === resolvedProjectId.value)
|
||||
const wfStatuses = project?.workflow?.statuses ?? props.statuses
|
||||
const opts = wfStatuses.map(s => ({ label: s.label, value: s.id }))
|
||||
const current = props.task?.status
|
||||
if (current && !wfStatuses.some(s => s.id === current.id)) {
|
||||
opts.unshift({ label: current.label, value: current.id })
|
||||
}
|
||||
return opts
|
||||
})
|
||||
|
||||
const effortOptions = computed(() =>
|
||||
props.efforts.map(e => ({ label: e.label, value: e.id }))
|
||||
)
|
||||
@@ -707,16 +676,6 @@ const groupOptions = computed(() => {
|
||||
return filtered.map(g => ({ label: g.title, value: g.id }))
|
||||
})
|
||||
|
||||
const showProjectSelect = computed(() => !!props.projects?.length && !isEditing.value)
|
||||
|
||||
const projectOptions = computed(() =>
|
||||
(props.projects ?? []).map(p => ({ label: p.name, value: p.id }))
|
||||
)
|
||||
|
||||
const resolvedProjectId = computed(() =>
|
||||
showProjectSelect.value ? form.projectId : props.projectId
|
||||
)
|
||||
|
||||
const canArchive = computed(() => {
|
||||
if (!isEditing.value || !props.task) return false
|
||||
if (props.task.archived) return false
|
||||
@@ -770,7 +729,6 @@ function populateForm(task: Task | 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
|
||||
form.scheduledStart = task.scheduledStart ? task.scheduledStart.slice(0, 16) : ''
|
||||
form.scheduledEnd = task.scheduledEnd ? task.scheduledEnd.slice(0, 16) : ''
|
||||
form.deadline = task.deadline ? task.deadline.slice(0, 10) : ''
|
||||
@@ -817,7 +775,6 @@ function populateForm(task: Task | null) {
|
||||
form.collaboratorIds = []
|
||||
form.groupId = null
|
||||
form.tagIds = []
|
||||
form.clientTicketId = null
|
||||
form.projectId = null
|
||||
form.scheduledStart = ''
|
||||
form.scheduledEnd = ''
|
||||
@@ -846,16 +803,6 @@ watch(() => props.modelValue, async (open) => {
|
||||
documentToDelete.value = null
|
||||
linkedMails.value = []
|
||||
populateForm(props.task)
|
||||
const pid = resolvedProjectId.value
|
||||
if (pid) {
|
||||
try {
|
||||
clientTickets.value = await clientTicketService.getAll({ project: pid })
|
||||
} catch {
|
||||
clientTickets.value = []
|
||||
}
|
||||
} else {
|
||||
clientTickets.value = []
|
||||
}
|
||||
if (props.task?.project?.giteaOwner && props.task?.project?.giteaRepo && !giteaUrl.value) {
|
||||
try {
|
||||
const settings = await getGiteaSettings()
|
||||
@@ -875,48 +822,26 @@ watch(() => props.task, (task) => {
|
||||
|
||||
const { create, update, remove } = useTaskService()
|
||||
const { remove: removeDocument, getByTask: getDocumentsByTask } = useTaskDocumentService()
|
||||
const clientTicketService = useClientTicketService()
|
||||
const { create: createRecurrence, update: updateRecurrence, remove: removeRecurrence } = useTaskRecurrenceService()
|
||||
const { t } = useI18n()
|
||||
|
||||
const clientTickets = ref<ClientTicket[]>([])
|
||||
const clientTicketOptions = computed(() =>
|
||||
clientTickets.value.map(ct => ({ label: `CT-${String(ct.number).padStart(3, '0')} — ${ct.title}`, value: ct.id }))
|
||||
)
|
||||
|
||||
// Reset group and reload client tickets when project changes in create mode
|
||||
watch(() => form.projectId, async (pid) => {
|
||||
// Reset group when project changes in create mode
|
||||
watch(() => form.projectId, () => {
|
||||
if (!showProjectSelect.value) return
|
||||
form.groupId = null
|
||||
form.clientTicketId = null
|
||||
if (pid) {
|
||||
try {
|
||||
clientTickets.value = await clientTicketService.getAll({ project: pid })
|
||||
} catch {
|
||||
clientTickets.value = []
|
||||
}
|
||||
} else {
|
||||
clientTickets.value = []
|
||||
}
|
||||
})
|
||||
|
||||
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')
|
||||
if (isEditing.value) base.push('mails')
|
||||
return base
|
||||
})
|
||||
|
||||
async function loadLinkedMails(): Promise<void> {
|
||||
if (!props.task || !isMailUser.value) return
|
||||
if (!props.task) return
|
||||
mailsLoading.value = true
|
||||
try {
|
||||
linkedMails.value = await mailService.listMailsForTask(props.task.id)
|
||||
@@ -933,11 +858,6 @@ watch(activeTab, async (tab) => {
|
||||
}
|
||||
})
|
||||
|
||||
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', {
|
||||
@@ -946,16 +866,6 @@ function formatMailDate(iso: string | null): string {
|
||||
})
|
||||
}
|
||||
|
||||
function ticketStatusClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'new': return 'bg-blue-100 text-blue-700'
|
||||
case 'in_progress': return 'bg-yellow-100 text-yellow-700'
|
||||
case 'done': return 'bg-green-100 text-green-700'
|
||||
case 'rejected': return 'bg-red-100 text-red-700'
|
||||
default: return 'bg-neutral-100 text-neutral-700'
|
||||
}
|
||||
}
|
||||
|
||||
const localDocuments = ref<TaskDocument[]>([])
|
||||
const previewDoc = ref<TaskDocument | null>(null)
|
||||
|
||||
@@ -1075,7 +985,6 @@ async function handleSubmit() {
|
||||
group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
|
||||
project: `/api/projects/${resolvedProjectId.value}`,
|
||||
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
||||
clientTicket: form.clientTicketId ? `/api/client_tickets/${form.clientTicketId}` : null,
|
||||
scheduledStart: form.scheduledStart || null,
|
||||
scheduledEnd: form.scheduledEnd || null,
|
||||
deadline: form.deadline || null,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskPriorities.editPriority') : $t('taskPriorities.addPriority')">
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('taskPriorities.editPriority') : $t('taskPriorities.addPriority') }}</h2>
|
||||
</template>
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskTags.editTag') : $t('taskTags.addTag')">
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('taskTags.editTag') : $t('taskTags.addTag') }}</h2>
|
||||
</template>
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('timeEntries.editEntry') : $t('timeEntries.addEntry')">
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('timeEntries.editEntry') : $t('timeEntries.addEntry') }}</h2>
|
||||
</template>
|
||||
<form class="space-y-4" @submit.prevent="onSubmit">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Titre</label>
|
||||
@@ -58,7 +61,7 @@
|
||||
v-model="form.userId"
|
||||
:options="userOptions"
|
||||
label="Utilisateur"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
|
||||
<MalioSelect
|
||||
@@ -66,7 +69,7 @@
|
||||
:options="projectOptions"
|
||||
label="Projet"
|
||||
empty-option-label="— Aucun —"
|
||||
min-width="w-full"
|
||||
group-class="w-full"
|
||||
/>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
v-for="day in days"
|
||||
:key="'header-' + day.dateStr"
|
||||
class="flex-1 border-r border-neutral-100 py-2 text-center"
|
||||
:class="{ 'bg-orange-50': day.holiday }"
|
||||
>
|
||||
<div class="text-lg font-bold" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-900'">
|
||||
{{ day.dayNum }}
|
||||
@@ -16,6 +17,14 @@
|
||||
<div class="text-xs" :class="isToday(day.date) ? 'text-orange-500' : 'text-neutral-500'">
|
||||
{{ day.label }}
|
||||
</div>
|
||||
<div
|
||||
v-if="day.holiday"
|
||||
class="flex items-center justify-center gap-0.5 truncate px-1 text-[10px] font-medium text-amber-600"
|
||||
:title="day.holiday"
|
||||
>
|
||||
<Icon name="mdi:star-four-points-outline" size="10" class="flex-shrink-0" />
|
||||
<span class="truncate">{{ day.holiday }}</span>
|
||||
</div>
|
||||
<div class="text-[10px] text-neutral-400">{{ day.totalFormatted }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,6 +49,7 @@
|
||||
:key="day.dateStr"
|
||||
:ref="(el) => { dayColumnEls[dayIndex] = el as HTMLElement }"
|
||||
class="relative flex-1 border-r border-neutral-100"
|
||||
:class="{ 'bg-orange-50': day.holiday }"
|
||||
@click="onClickGrid($event, day)"
|
||||
@contextmenu.prevent="onContextMenuGrid($event, day)"
|
||||
>
|
||||
@@ -141,8 +151,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
|
||||
const { t } = useI18n()
|
||||
const absenceService = useAbsenceService()
|
||||
|
||||
const props = defineProps<{
|
||||
entries: TimeEntry[]
|
||||
@@ -209,6 +221,23 @@ onMounted(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// --- Public holidays (computed server-side, shared with the absence calendar) ---
|
||||
const holidays = ref<Record<string, string>>({})
|
||||
|
||||
async function loadHolidays() {
|
||||
const count = props.viewMode === 'week' ? 7 : 1
|
||||
const start = new Date(props.startDate)
|
||||
const end = new Date(start)
|
||||
end.setDate(end.getDate() + count - 1)
|
||||
try {
|
||||
holidays.value = await absenceService.getPublicHolidays(toDateStr(start), toDateStr(end))
|
||||
} catch {
|
||||
holidays.value = {}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => [props.startDate, props.viewMode], loadHolidays, { immediate: true })
|
||||
|
||||
// --- Days computation ---
|
||||
const days = computed(() => {
|
||||
const count = props.viewMode === 'week' ? 7 : 1
|
||||
@@ -231,6 +260,7 @@ const days = computed(() => {
|
||||
dateStr,
|
||||
dayNum: d.getDate(),
|
||||
label: dayLabels[d.getDay()],
|
||||
holiday: holidays.value[dateStr] ?? null,
|
||||
totalFormatted: `${String(totalH).padStart(2, '0')}:${String(totalM).padStart(2, '0')}:${String(totalS).padStart(2, '0')}`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen" :title="$t('timeEntries.exportTitle')" drawer-class="max-w-lg">
|
||||
<MalioDrawer v-model="isOpen" drawer-class="max-w-lg">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ $t('timeEntries.exportTitle') }}</h2>
|
||||
</template>
|
||||
<div class="flex flex-col gap-6 p-4">
|
||||
<!-- Period presets -->
|
||||
<div>
|
||||
@@ -52,7 +55,7 @@
|
||||
:label="$t('timeEntries.exportUsers')"
|
||||
:display-tag="true"
|
||||
:display-select-all="true"
|
||||
min-width="!w-full"
|
||||
group-class="!w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -63,7 +66,7 @@
|
||||
:options="clientOptions"
|
||||
:label="$t('timeEntries.exportClient')"
|
||||
:empty-option-label="$t('timeEntries.exportAllClients')"
|
||||
min-width="!w-full"
|
||||
group-class="!w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -75,7 +78,7 @@
|
||||
:label="$t('timeEntries.exportProjects')"
|
||||
:display-tag="true"
|
||||
:display-select-all="true"
|
||||
min-width="!w-full"
|
||||
group-class="!w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -87,7 +90,7 @@
|
||||
:label="$t('timeEntries.exportTags')"
|
||||
:display-tag="true"
|
||||
:display-select-all="true"
|
||||
min-width="!w-full"
|
||||
group-class="!w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
87
frontend/components/ui/AppModal.vue
Normal file
87
frontend/components/ui/AppModal.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: boolean
|
||||
title?: string
|
||||
/** Largeur max du panneau */
|
||||
width?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
}>(), {
|
||||
title: '',
|
||||
width: 'md',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const WIDTH_CLASS: Record<NonNullable<typeof props.width>, string> = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport v-if="modelValue" to="body">
|
||||
<Transition name="app-modal" appear>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm" @click="close" />
|
||||
|
||||
<div
|
||||
class="relative z-10 flex max-h-[90vh] w-full flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5"
|
||||
:class="WIDTH_CLASS[width]"
|
||||
>
|
||||
<!-- Header (fixe) -->
|
||||
<div class="flex shrink-0 items-center justify-between border-b border-neutral-100 bg-neutral-50/80 px-6 py-4">
|
||||
<h2 class="text-base font-bold text-neutral-900">
|
||||
<slot name="title">{{ title }}</slot>
|
||||
</h2>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:close"
|
||||
aria-label="Fermer"
|
||||
variant="ghost"
|
||||
icon-size="20"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Body (scrollable) -->
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- Footer (sticky) -->
|
||||
<div
|
||||
v-if="$slots.footer"
|
||||
class="flex shrink-0 justify-end gap-3 border-t border-neutral-100 bg-white px-6 py-4"
|
||||
>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-modal-enter-active,
|
||||
.app-modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.app-modal-enter-active > div:last-child,
|
||||
.app-modal-leave-active > div:last-child {
|
||||
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
|
||||
}
|
||||
.app-modal-enter-from,
|
||||
.app-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
.app-modal-enter-from > div:last-child {
|
||||
transform: scale(0.95) translateY(8px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,22 +1,45 @@
|
||||
<template>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-neutral-700">Couleur</p>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
v-for="color in colors"
|
||||
v-for="color in presets"
|
||||
:key="color"
|
||||
type="button"
|
||||
class="h-10 w-10 rounded-full border-2 transition-transform hover:scale-110"
|
||||
:class="modelValue === color ? 'border-neutral-900 scale-110' : 'border-transparent'"
|
||||
:class="isSelected(color) ? 'border-neutral-900 scale-110' : 'border-transparent'"
|
||||
:style="{ backgroundColor: color }"
|
||||
@click="emit('update:modelValue', color)"
|
||||
:aria-label="`Choisir la couleur ${color}`"
|
||||
@click="select(color)"
|
||||
/>
|
||||
|
||||
<!-- Couleur personnalisée : input natif déguisé en pastille -->
|
||||
<label
|
||||
class="relative flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border-2 transition-transform hover:scale-110"
|
||||
:class="isCustom ? 'border-neutral-900 scale-110' : 'border-dashed border-neutral-400'"
|
||||
:style="isCustom ? { backgroundColor: modelValue } : {}"
|
||||
title="Couleur personnalisée"
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
class="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||
:value="modelValue"
|
||||
aria-label="Choisir une couleur personnalisée"
|
||||
@input="select(($event.target as HTMLInputElement).value)"
|
||||
>
|
||||
<Icon
|
||||
v-if="!isCustom"
|
||||
name="mdi:plus"
|
||||
class="text-neutral-500"
|
||||
size="20"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
}>()
|
||||
|
||||
@@ -24,8 +47,26 @@ const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
const colors = [
|
||||
'#222783', '#26A69A', '#E91E63', '#4A90D9',
|
||||
'#7E57C2', '#8BC34A', '#FDD835', '#80DEEA', '#FF7043',
|
||||
// Les 9 premières sont historiques (couleurs déjà en base) — ne pas réordonner
|
||||
// pour que les projets/tags existants restent associés à une pastille.
|
||||
const presets = [
|
||||
'#222783', '#26A69A', '#E91E63', '#4A90D9', '#7E57C2',
|
||||
'#8BC34A', '#FDD835', '#80DEEA', '#FF7043', '#EF4444',
|
||||
'#F97316', '#F59E0B', '#22C55E', '#10B981', '#06B6D4',
|
||||
'#3B82F6', '#8B5CF6', '#64748B',
|
||||
]
|
||||
|
||||
const norm = (value: string): string => (value ?? '').toUpperCase()
|
||||
|
||||
function isSelected(color: string): boolean {
|
||||
return norm(props.modelValue) === norm(color)
|
||||
}
|
||||
|
||||
const isCustom = computed(
|
||||
() => !!props.modelValue && !presets.some((c) => norm(c) === norm(props.modelValue)),
|
||||
)
|
||||
|
||||
function select(value: string): void {
|
||||
emit('update:modelValue', norm(value))
|
||||
}
|
||||
</script>
|
||||
|
||||
32
frontend/components/ui/StatusBadge.vue
Normal file
32
frontend/components/ui/StatusBadge.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium whitespace-nowrap"
|
||||
:class="variantClass"
|
||||
>
|
||||
<Icon v-if="icon" :name="icon" size="14" />
|
||||
{{ label }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
type Variant = 'neutral' | 'info' | 'success' | 'warning' | 'danger'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
label: string
|
||||
variant?: Variant
|
||||
icon?: string
|
||||
}>(), {
|
||||
variant: 'neutral',
|
||||
icon: '',
|
||||
})
|
||||
|
||||
const VARIANT_CLASSES: Record<Variant, string> = {
|
||||
neutral: 'bg-neutral-100 text-neutral-700',
|
||||
info: 'bg-blue-100 text-blue-800',
|
||||
success: 'bg-green-100 text-green-800',
|
||||
warning: 'bg-amber-100 text-amber-800',
|
||||
danger: 'bg-red-100 text-red-800',
|
||||
}
|
||||
|
||||
const variantClass = computed(() => VARIANT_CLASSES[props.variant])
|
||||
</script>
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('users.editUser') : $t('users.addUser')">
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('users.editUser') : $t('users.addUser') }}</h2>
|
||||
</template>
|
||||
<form class="flex flex-col gap-2" @submit.prevent="handleSubmit">
|
||||
<MalioInputText
|
||||
v-model="form.username"
|
||||
@@ -35,37 +38,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<MalioSelect
|
||||
v-model="form.clientId"
|
||||
label="Client"
|
||||
:options="clientOptions"
|
||||
placeholder="Aucun client"
|
||||
class="w-full"
|
||||
@update:model-value="onClientChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="form.clientId !== null" class="mt-2">
|
||||
<label class="text-sm font-semibold text-neutral-700">Projets autorisés</label>
|
||||
<div class="mt-2 flex flex-col gap-2">
|
||||
<label
|
||||
v-for="project in filteredProjects"
|
||||
:key="project.id"
|
||||
class="flex items-center gap-2 text-sm text-neutral-700"
|
||||
>
|
||||
<input
|
||||
v-model="form.allowedProjectIds"
|
||||
type="checkbox"
|
||||
:value="project.id"
|
||||
class="rounded border-neutral-300"
|
||||
/>
|
||||
{{ project.name }}
|
||||
</label>
|
||||
<span v-if="filteredProjects.length === 0" class="text-sm text-neutral-400">
|
||||
Aucun projet pour ce client.
|
||||
</span>
|
||||
</div>
|
||||
<!-- RH / Absences -->
|
||||
<div class="mt-6 border-t border-neutral-200 pt-4">
|
||||
<MalioCheckbox v-model="form.isEmployee" label="Employé (soumis à la gestion des absences)" />
|
||||
<p v-if="form.isEmployee" class="mt-2 text-xs text-neutral-500">
|
||||
Les informations RH (contrat, dates, CP…) se gèrent dans Absences équipe → onglet Employés.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
@@ -83,12 +61,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { UserData, UserWrite } from '~/services/dto/user-data'
|
||||
import { useUserService } from '~/services/users'
|
||||
import { useClientService } from '~/services/clients'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
import type { Client } from '~/services/dto/client'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -105,32 +77,16 @@ const isOpen = computed({
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const availableRoles = ['ROLE_ADMIN', 'ROLE_USER', 'ROLE_CLIENT']
|
||||
const availableRoles = ['ROLE_ADMIN', 'ROLE_USER']
|
||||
|
||||
const isEditing = computed(() => !!props.item)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const clients = ref<Client[]>([])
|
||||
const allProjects = ref<Project[]>([])
|
||||
|
||||
const clientOptions = computed(() => [
|
||||
{ label: t('common.noClient'), value: null as number | null },
|
||||
...clients.value.map((c) => ({ label: c.name, value: c.id as number | null })),
|
||||
])
|
||||
|
||||
const filteredProjects = computed(() => {
|
||||
if (form.clientId === null) return []
|
||||
return allProjects.value.filter(
|
||||
(p) => p.client && typeof p.client === 'object' && 'id' in p.client && p.client.id === form.clientId,
|
||||
)
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
roles: [] as string[],
|
||||
clientId: null as number | null,
|
||||
allowedProjectIds: [] as number[],
|
||||
isEmployee: false,
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
@@ -138,45 +94,21 @@ const touched = reactive({
|
||||
password: false,
|
||||
})
|
||||
|
||||
function onClientChange(value: number | null) {
|
||||
form.clientId = value
|
||||
form.allowedProjectIds = []
|
||||
if (value !== null && !form.roles.includes('ROLE_CLIENT')) {
|
||||
form.roles = [...form.roles.filter((r) => r !== 'ROLE_USER'), 'ROLE_CLIENT']
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => form.roles, (roles) => {
|
||||
if (!roles.includes('ROLE_CLIENT')) {
|
||||
form.clientId = null
|
||||
form.allowedProjectIds = []
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, async (open) => {
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
if (props.item) {
|
||||
form.username = props.item.username ?? ''
|
||||
form.password = ''
|
||||
form.roles = [...props.item.roles]
|
||||
form.clientId = props.item.client?.id ?? null
|
||||
form.allowedProjectIds = props.item.allowedProjects?.map((p) => p.id) ?? []
|
||||
form.isEmployee = props.item.isEmployee ?? false
|
||||
} else {
|
||||
form.username = ''
|
||||
form.password = ''
|
||||
form.roles = ['ROLE_USER']
|
||||
form.clientId = null
|
||||
form.allowedProjectIds = []
|
||||
form.isEmployee = false
|
||||
}
|
||||
touched.username = false
|
||||
touched.password = false
|
||||
|
||||
const [loadedClients, loadedProjects] = await Promise.all([
|
||||
useClientService().getAll(),
|
||||
useProjectService().getAll({ archived: false }),
|
||||
])
|
||||
clients.value = loadedClients
|
||||
allProjects.value = loadedProjects
|
||||
}
|
||||
})
|
||||
|
||||
@@ -193,10 +125,7 @@ async function handleSubmit() {
|
||||
const payload: UserWrite = {
|
||||
username: form.username.trim(),
|
||||
roles: form.roles,
|
||||
client: form.clientId !== null ? `/api/clients/${form.clientId}` : null,
|
||||
allowedProjects: form.clientId !== null
|
||||
? form.allowedProjectIds.map((id) => `/api/projects/${id}`)
|
||||
: [],
|
||||
isEmployee: form.isEmployee,
|
||||
}
|
||||
if (form.password) {
|
||||
payload.plainPassword = form.password
|
||||
|
||||
94
frontend/composables/useAbsenceHelpers.ts
Normal file
94
frontend/composables/useAbsenceHelpers.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { AbsenceRequest, AbsenceStatus, AbsenceType, HalfDay } from '~/services/dto/absence'
|
||||
|
||||
export type BadgeVariant = 'neutral' | 'info' | 'success' | 'warning' | 'danger'
|
||||
|
||||
const STATUS_VARIANTS: Record<AbsenceStatus, BadgeVariant> = {
|
||||
pending: 'warning',
|
||||
approved: 'success',
|
||||
rejected: 'danger',
|
||||
cancelled: 'neutral',
|
||||
}
|
||||
|
||||
const STATUS_ICONS: Record<AbsenceStatus, string> = {
|
||||
pending: 'mdi:clock-outline',
|
||||
approved: 'mdi:check-circle-outline',
|
||||
rejected: 'mdi:close-circle-outline',
|
||||
cancelled: 'mdi:cancel',
|
||||
}
|
||||
|
||||
// Colours used for the calendar bars, keyed by absence type.
|
||||
const TYPE_COLORS: Record<AbsenceType, string> = {
|
||||
cp: '#4A90D9',
|
||||
mariage_pacs: '#E91E63',
|
||||
naissance: '#26A69A',
|
||||
conge_parental: '#9C27B0',
|
||||
deces: '#607D8B',
|
||||
maladie: '#C62828',
|
||||
}
|
||||
|
||||
export function useAbsenceHelpers() {
|
||||
const { t } = useI18n()
|
||||
|
||||
function statusLabel(status: AbsenceStatus): string {
|
||||
return t(`absences.status.${status}`)
|
||||
}
|
||||
|
||||
function statusVariant(status: AbsenceStatus): BadgeVariant {
|
||||
return STATUS_VARIANTS[status] ?? 'neutral'
|
||||
}
|
||||
|
||||
function statusIcon(status: AbsenceStatus): string {
|
||||
return STATUS_ICONS[status] ?? 'mdi:help-circle-outline'
|
||||
}
|
||||
|
||||
function typeLabel(type: AbsenceType): string {
|
||||
return t(`absences.types.${type}`)
|
||||
}
|
||||
|
||||
function typeColor(type: AbsenceType): string {
|
||||
return TYPE_COLORS[type] ?? '#9CA3AF'
|
||||
}
|
||||
|
||||
function halfDayLabel(half: HalfDay): string {
|
||||
return t(`absences.halfDay.${half}`)
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d.getTime())) return ''
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
return `${day}/${month}/${d.getFullYear()}`
|
||||
}
|
||||
|
||||
/** Human-readable period with half-day annotations. */
|
||||
function formatRange(req: Pick<AbsenceRequest, 'startDate' | 'endDate' | 'startHalfDay' | 'endHalfDay'>): string {
|
||||
const start = formatDate(req.startDate)
|
||||
const end = formatDate(req.endDate)
|
||||
const startSuffix = req.startHalfDay ? ` (${halfDayLabel(req.startHalfDay)})` : ''
|
||||
const endSuffix = req.endHalfDay ? ` (${halfDayLabel(req.endHalfDay)})` : ''
|
||||
if (start === end) {
|
||||
return `${start}${startSuffix}`
|
||||
}
|
||||
return `${start}${startSuffix} → ${end}${endSuffix}`
|
||||
}
|
||||
|
||||
function formatDays(days: number): string {
|
||||
const rounded = Math.round(days * 2) / 2
|
||||
const unit = rounded > 1 ? t('absences.daysPlural') : t('absences.daySingular')
|
||||
return `${rounded} ${unit}`
|
||||
}
|
||||
|
||||
return {
|
||||
statusLabel,
|
||||
statusVariant,
|
||||
statusIcon,
|
||||
typeLabel,
|
||||
typeColor,
|
||||
halfDayLabel,
|
||||
formatDate,
|
||||
formatRange,
|
||||
formatDays,
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { ClientTicketStatus } from '~/services/dto/client-ticket'
|
||||
|
||||
export function useClientTicketHelpers() {
|
||||
function typeBadgeClass(type: string): string {
|
||||
switch (type) {
|
||||
case 'bug': return 'bg-red-500'
|
||||
case 'improvement': return 'bg-blue-500'
|
||||
default: return 'bg-neutral-500'
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadgeClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'new': return 'bg-blue-100 text-blue-700'
|
||||
case 'in_progress': return 'bg-yellow-100 text-yellow-700'
|
||||
case 'done': return 'bg-green-100 text-green-700'
|
||||
case 'rejected': return 'bg-red-100 text-red-700'
|
||||
default: return 'bg-neutral-100 text-neutral-700'
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function getAvailableStatusTransitions(
|
||||
current: ClientTicketStatus,
|
||||
t: (key: string) => string,
|
||||
): { label: string; value: ClientTicketStatus }[] {
|
||||
const allStatuses: { label: string; value: ClientTicketStatus }[] = [
|
||||
{ label: t('clientTicket.status.new'), value: 'new' },
|
||||
{ label: t('clientTicket.status.in_progress'), value: 'in_progress' },
|
||||
{ label: t('clientTicket.status.done'), value: 'done' },
|
||||
{ label: t('clientTicket.status.rejected'), value: 'rejected' },
|
||||
]
|
||||
return allStatuses.filter(s => {
|
||||
if (s.value === current) return false
|
||||
if ((current === 'done' || current === 'rejected') && s.value === 'new') return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
return { typeBadgeClass, statusBadgeClass, formatDate, getAvailableStatusTransitions }
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
# Bienvenue dans Lesstime
|
||||
|
||||
Lesstime est un outil de **gestion de projets** qui combine 4 grandes capacités :
|
||||
Lesstime est un outil de **gestion de projets** qui combine plusieurs 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
|
||||
|
||||
@@ -13,7 +12,6 @@ Lesstime est un outil de **gestion de projets** qui combine 4 grandes capacité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
|
||||
|
||||
@@ -22,6 +20,5 @@ Lesstime est un outil de **gestion de projets** qui combine 4 grandes capacités
|
||||
- **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.
|
||||
|
||||
@@ -51,10 +51,6 @@ Si le projet a un repo Gitea lié, tu peux :
|
||||
- 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)
|
||||
|
||||
76
frontend/content/help/06-absences.md
Normal file
76
frontend/content/help/06-absences.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Absences
|
||||
|
||||
Le module **Absences** gère les congés des salariés : demande, validation, et suivi du **solde de congés payés (CP)**. Les congés pour événements familiaux (mariage/PACS, naissance, décès) sont des **droits par événement** : ils sont enregistrés et validés mais **ne se déduisent pas d'un solde**. Le congé parental et l'arrêt maladie sont des suspensions, sans impact sur les soldes.
|
||||
|
||||
> Convention de référence pour les valeurs par défaut : **Syntec (IDCC 1486)** — à confirmer selon le code APE de l'entreprise (une CCN ne se déduit pas de la seule activité).
|
||||
|
||||
Il y a deux espaces :
|
||||
|
||||
- **Mes absences** (`/absences`) — accessible à tout salarié : poser une demande et consulter ses soldes.
|
||||
- **Absences équipe** (`/team-absences`) — réservé aux **administrateurs** : valider les demandes, voir le calendrier d'équipe, ajuster les soldes et gérer la fiche RH des salariés.
|
||||
|
||||
> 🛡️ La gestion des salariés (onglet *Employés*) et la validation des demandes sont aujourd'hui réservées au rôle **ROLE_ADMIN**.
|
||||
|
||||
## Poser une demande (tout salarié)
|
||||
|
||||
Depuis **Mes absences** → bouton *Nouvelle demande* :
|
||||
|
||||
1. **Type** : Congés payés, Mariage/PACS, Naissance, Congé parental, Décès, ou Maladie.
|
||||
2. **Dates** : début et fin. Une **demi-journée** (matin / après-midi) peut être posée sur le premier ou le dernier jour (décompte −0,5, uniquement si ce jour-borne est un jour décompté).
|
||||
3. **Motif** et **justificatif** (selon le type). Le **motif est obligatoire pour le décès** : il sert à préciser le lien de parenté, qui détermine le nombre de jours légal.
|
||||
|
||||
La demande passe au statut **En attente**, puis un administrateur la valide ou la refuse. Pour les CP uniquement, les jours sont immédiatement **réservés** dans le solde « en attente » pour éviter de poser deux fois les mêmes congés.
|
||||
|
||||
> **Congés pour événements familiaux — minimums légaux (rappel).** Mariage/PACS : 4 jours. Naissance : 3 jours (hors congé paternité). Décès : selon le lien — **enfant : au moins 5 jours + 8 jours de congé de deuil**, conjoint/partenaire/parent/frère/sœur : 3 jours. L'administrateur accorde le nombre de jours légal en validant les dates. La convention Syntec peut prévoir des durées supérieures.
|
||||
|
||||
## Lire ses soldes
|
||||
|
||||
Chaque solde de CP se lit sur une **période de référence** (par défaut **1er juin → 31 mai**) et se décompose en :
|
||||
|
||||
| Bucket | Signification |
|
||||
|---|---|
|
||||
| **Acquis (N-1)** | Jours déjà acquis sur la période précédente, **disponibles** maintenant |
|
||||
| **En cours d'acquisition (N)** | Jours qui s'accumulent ce mois-ci, disponibles à la prochaine période |
|
||||
| **En attente** | Jours réservés par des demandes non encore validées |
|
||||
| **Pris** | Jours de demandes validées |
|
||||
|
||||
Le **disponible** = acquis + acquis-en-cours − en attente − pris.
|
||||
|
||||
## Comment les congés payés sont comptés
|
||||
|
||||
L'acquisition est **mensuelle**, créditée par une tâche planifiée (commande `app:accrue-leave`, lancée par un cron en début de mois) :
|
||||
|
||||
```
|
||||
acquisition mensuelle = (jours de CP annuels ÷ 12) × quotité de travail
|
||||
```
|
||||
|
||||
Pour un temps plein à **25 jours/an**, cela fait **≈ 2,08 jour/mois** crédités dans « en cours d'acquisition (N) ». À chaque changement de période de référence, le « en cours (N) » bascule automatiquement en « acquis (N-1) ».
|
||||
|
||||
Le décompte d'une absence ne compte que les **jours ouvrés** (lundi→vendredi) ; les week-ends sont ignorés. Les arrêts **maladie** ne sont pas déduits du solde de CP.
|
||||
|
||||
## Ajouter un salarié
|
||||
|
||||
> Aujourd'hui réalisé par un **administrateur** depuis **Absences équipe → onglet *Employés* → *Ajouter / Modifier***.
|
||||
|
||||
Cocher *Employé* sur la fiche d'un utilisateur l'intègre à la gestion des absences. La fiche RH demande :
|
||||
|
||||
| Champ | Rôle | Valeur typique |
|
||||
|---|---|---|
|
||||
| **Date d'embauche** | Début du contrat | date réelle |
|
||||
| **Type de contrat** | CDI, CDD, Stage, Alternance, Autre | — |
|
||||
| **Quotité de travail** | Temps plein = `1.0`, mi-temps = `0.5` | `1.0` |
|
||||
| **CP annuels** | Jours de CP acquis par an | `25` |
|
||||
| **Début de période de référence** | Format `MM-JJ` | `06-01` |
|
||||
| **Solde initial de CP** | Jours déjà acquis et disponibles à l'activation | voir ci-dessous |
|
||||
|
||||
### Nouveau salarié qui arrive
|
||||
|
||||
Renseigner date d'embauche, contrat, quotité, `25` CP annuels et `06-01` de période. Laisser le **solde initial à `0`** : le salarié commence à acquérir ses CP au prochain passage mensuel.
|
||||
|
||||
### Salarié déjà présent avant l'activation du module
|
||||
|
||||
C'est le **solde initial de CP** qui sert à reprendre l'existant. Y saisir le **nombre de jours de CP déjà acquis et disponibles** par le salarié au moment où on active le module.
|
||||
|
||||
Au premier calcul mensuel, cette valeur amorce le bucket **« acquis (N-1) »** (donc immédiatement disponible), puis l'acquisition normale (~2,08 j/mois) reprend par-dessus. Les salariés déjà présents sont ainsi « comptés » sans repartir de zéro — il suffit de connaître leur solde de départ (depuis l'ancien suivi : tableur, fiches de paie, etc.).
|
||||
|
||||
> 💡 En cas d'erreur de reprise, un administrateur peut **ajuster un solde** à la main depuis l'onglet *Soldes* (régularisation).
|
||||
@@ -1,43 +0,0 @@
|
||||
# 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
|
||||
@@ -40,12 +40,9 @@ L'admin (`/admin`) est divisé en plusieurs onglets, chacun gérant une ressourc
|
||||
## 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*
|
||||
- Rôles : `ROLE_ADMIN`, `ROLE_USER`
|
||||
- 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
|
||||
@@ -64,3 +61,13 @@ L'admin (`/admin`) est divisé en plusieurs onglets, chacun gérant une ressourc
|
||||
- Configure le calendrier CalDav par défaut
|
||||
- Test de connexion intégré
|
||||
- Active la **sync calendrier** sur les tâches planifiées
|
||||
|
||||
## Onglet *Mail*
|
||||
|
||||
Configure la **boîte mail partagée** (OVH) lue dans la section *Messagerie*.
|
||||
|
||||
- **Réception (IMAP)** : hôte, port (993 par défaut), chiffrement (SSL / TLS / aucun)
|
||||
- **Envoi (SMTP)** : hôte, port (465 par défaut), chiffrement
|
||||
- **Identifiants** : nom d'utilisateur + mot de passe (chiffré côté serveur, jamais réaffiché — un indicateur signale qu'un mot de passe est déjà enregistré), et chemin du dossier *Envoyés*
|
||||
- **Toggle `enabled`** : active la messagerie
|
||||
- **Test de connexion** intégré (vérifie l'accès IMAP et compte les dossiers)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Intégrations
|
||||
|
||||
Lesstime s'intègre avec **3 outils externes** pour fluidifier le workflow dev.
|
||||
Lesstime s'intègre avec **4 outils externes** pour fluidifier le workflow dev.
|
||||
|
||||
## 🌳 Gitea
|
||||
|
||||
@@ -64,3 +64,29 @@ Sur une tâche avec **scheduled start + end** :
|
||||
|
||||
- **Pas de retour Zimbra → Lesstime** : si tu modifies l'événement dans Zimbra, Lesstime ne le voit pas
|
||||
- **Récurrences** : les patterns RRULE basiques sont supportés (daily, weekly avec jours, monthly)
|
||||
|
||||
## 📧 Messagerie (Mail OVH)
|
||||
|
||||
Boîte mail partagée OVH (IMAP) lue directement dans Lesstime.
|
||||
|
||||
### Configuration
|
||||
|
||||
1. **Admin → Mail** :
|
||||
- Réception **IMAP** (hôte, port, chiffrement) et envoi **SMTP** (hôte, port, chiffrement)
|
||||
- Identifiants (mot de passe chiffré côté serveur) + dossier *Envoyés*
|
||||
- **Test de connexion** intégré
|
||||
2. Active la config (toggle `enabled`)
|
||||
|
||||
### Utilisation
|
||||
|
||||
- La section **Messagerie** (barre latérale) affiche dossiers, messages et lecteur
|
||||
- **Synchronisation IMAP à la demande** via le bouton *Rafraîchir* (traitée en asynchrone par Messenger)
|
||||
- Depuis un mail : **créer une tâche** pré-remplie ou **lier à une tâche** existante
|
||||
- Badge de non-lus dans la barre latérale, rafraîchi automatiquement (toutes les 30 s)
|
||||
|
||||
> 📖 Le guide complet de la messagerie est dans la section *Messagerie*.
|
||||
|
||||
### Limites
|
||||
|
||||
- **Lecture seule** : pas de rédaction / réponse / suppression de mail depuis l'interface
|
||||
- Réservée aux rôles **admin** et **user** (pas les clients)
|
||||
|
||||
40
frontend/content/help/10-messaging.md
Normal file
40
frontend/content/help/10-messaging.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Messagerie
|
||||
|
||||
Lesstime intègre une **boîte mail partagée** (OVH, protocole IMAP) directement dans l'application. Tu lis les mails de l'équipe et tu les transformes en tâches sans quitter Lesstime.
|
||||
|
||||
> 📥 La messagerie est accessible depuis l'entrée **Messagerie** de la barre latérale (icône enveloppe). Un **badge** y affiche le nombre de mails non lus, toutes boîtes confondues.
|
||||
|
||||
> 🛡️ Réservée aux rôles **ROLE_ADMIN** et **ROLE_USER**.
|
||||
|
||||
## L'interface
|
||||
|
||||
L'écran est organisé en **3 colonnes** :
|
||||
|
||||
1. **Dossiers** — l'arborescence de la boîte (INBOX, Envoyés, sous-dossiers…), avec le compteur de non-lus par dossier. INBOX est sélectionné par défaut.
|
||||
2. **Messages** — la liste du dossier sélectionné (expéditeur, objet, date). Les mails non lus sont mis en avant. Un bouton **Charger plus** récupère les messages suivants (pagination).
|
||||
3. **Lecteur** — le mail sélectionné : en-tête (expéditeur, destinataires, date), corps du message et **pièces jointes**.
|
||||
|
||||
## Lire un message
|
||||
|
||||
- Clique sur un message dans la liste : son détail s'affiche et il est **automatiquement marqué comme lu**.
|
||||
- Tu peux le repasser **non lu** ou l'**étoiler** (flag) pour le retrouver plus tard.
|
||||
- Les **pièces jointes** sont listées dans le lecteur : clique pour les télécharger, les images peuvent être prévisualisées.
|
||||
|
||||
## Synchronisation
|
||||
|
||||
- Le bouton **Rafraîchir** (en haut de l'écran) déclenche une **synchronisation IMAP à la demande** : Lesstime va chercher les nouveaux mails sur le serveur. Le traitement est asynchrone, la liste se met à jour quelques secondes après.
|
||||
- En arrière-plan, le **compteur de non-lus** de la barre latérale se rafraîchit automatiquement (toutes les 30 s).
|
||||
|
||||
## Transformer un mail en action
|
||||
|
||||
Depuis le lecteur, deux boutons relient un mail au suivi de projet :
|
||||
|
||||
- **Créer une tâche** — ouvre une tâche pré-remplie à partir du mail (objet, contenu). Tu choisis le projet et les métadonnées, le mail reste lié à la tâche.
|
||||
- **Lier à une tâche** — rattache le mail à une tâche **existante**.
|
||||
|
||||
> 💡 C'est le pont entre la boîte mail de l'équipe et le kanban : une demande reçue par mail devient une tâche traçable en deux clics.
|
||||
|
||||
## Limites
|
||||
|
||||
- **Lecture seule** : l'interface ne permet pas (encore) de **rédiger, répondre ou transférer** un mail, ni de supprimer un message.
|
||||
- La configuration du serveur (IMAP/SMTP, identifiants) se fait dans **Admin → Mail** — voir la section *Administration*.
|
||||
@@ -236,7 +236,8 @@
|
||||
"sortBy": "Trier par",
|
||||
"sortDefault": "Par défaut",
|
||||
"sortDeadline": "Échéance",
|
||||
"sortScheduledStart": "Date planifiée"
|
||||
"sortScheduledStart": "Date planifiée",
|
||||
"dropRefused": "Aucun statut de cette colonne dans le workflow de ce projet"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
@@ -350,63 +351,6 @@
|
||||
"error": "Erreur de connexion à Gitea.",
|
||||
"notConfigured": "Gitea non configuré pour ce projet."
|
||||
},
|
||||
"portal": {
|
||||
"title": "Portail client",
|
||||
"projects": "Vos projets",
|
||||
"noProjects": "Aucun projet disponible.",
|
||||
"openTickets": "tickets ouverts",
|
||||
"newTicket": "Nouveau ticket",
|
||||
"ticketDetail": "Détail du ticket",
|
||||
"backToProject": "Retour au projet",
|
||||
"submitTicket": "Soumettre le ticket",
|
||||
"ticketCreated": "Ticket soumis avec succès."
|
||||
},
|
||||
"clientTicket": {
|
||||
"title": "Tickets",
|
||||
"new": "Nouveau ticket",
|
||||
"created": "Ticket créé avec succès.",
|
||||
"deleted": "Ticket supprimé avec succès.",
|
||||
"updated": "Ticket mis à jour avec succès.",
|
||||
"statusUpdated": "Statut du ticket mis à jour.",
|
||||
"type": {
|
||||
"bug": "Bug",
|
||||
"improvement": "Amélioration",
|
||||
"other": "Autre"
|
||||
},
|
||||
"status": {
|
||||
"new": "Nouveau",
|
||||
"in_progress": "En cours",
|
||||
"done": "Terminé",
|
||||
"rejected": "Rejeté"
|
||||
},
|
||||
"fields": {
|
||||
"title": "Titre",
|
||||
"description": "Description",
|
||||
"url": "URL de la page",
|
||||
"urlPlaceholder": "https://example.com/page-concernee",
|
||||
"type": "Type",
|
||||
"project": "Projet"
|
||||
},
|
||||
"confirmDelete": "Êtes-vous sûr de vouloir supprimer ce ticket ?",
|
||||
"rejectComment": "Commentaire de rejet",
|
||||
"rejectCommentRequired": "Un commentaire est requis pour rejeter un ticket.",
|
||||
"linkedTicket": "Lié au ticket client CT-{number}",
|
||||
"description": "Description",
|
||||
"url": "URL (page concernée)",
|
||||
"statusComment": "Commentaire de statut",
|
||||
"statusChanged": "Statut mis à jour",
|
||||
"confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible.",
|
||||
"linkedTooltip": "Lié au ticket client {number}",
|
||||
"rejectionRequired": "Un commentaire est requis pour rejeter un ticket",
|
||||
"noTickets": "Aucun ticket.",
|
||||
"allStatuses": "Tous les statuts",
|
||||
"allProjects": "Tous les projets",
|
||||
"submittedBy": "Soumis par",
|
||||
"createdAt": "Créé le",
|
||||
"adminTab": "Tickets client",
|
||||
"selectType": "Type de ticket",
|
||||
"changeStatus": "Changer le statut"
|
||||
},
|
||||
"notification": {
|
||||
"title": "Notifications",
|
||||
"markAllRead": "Tout marquer comme lu",
|
||||
@@ -527,6 +471,12 @@
|
||||
"list": "Aucun message dans ce dossier.",
|
||||
"viewer": "Sélectionnez un message pour le lire."
|
||||
},
|
||||
"preview": {
|
||||
"open": "Prévisualiser",
|
||||
"close": "Fermer l'aperçu",
|
||||
"loading": "Chargement de l'aperçu…",
|
||||
"unavailable": "Aperçu indisponible pour ce type de fichier."
|
||||
},
|
||||
"folderTree": {
|
||||
"expand": "Déplier le dossier",
|
||||
"collapse": "Replier le dossier"
|
||||
@@ -570,8 +520,9 @@
|
||||
"projectPlaceholder": "Sélectionner un projet",
|
||||
"groupLabel": "Groupe (optionnel)",
|
||||
"groupPlaceholder": "Aucun groupe",
|
||||
"priorityLabel": "Priorité (optionnelle)",
|
||||
"priorityPlaceholder": "Aucune priorité",
|
||||
"statusLabel": "Statut",
|
||||
"assigneeLabel": "Assigné à",
|
||||
"assigneePlaceholder": "Aucun",
|
||||
"titleHint": "Le titre sera rempli depuis le sujet du mail.",
|
||||
"descriptionHint": "La description sera remplie depuis le corps du mail."
|
||||
},
|
||||
@@ -584,17 +535,9 @@
|
||||
"empty": "Aucune tâche correspondante.",
|
||||
"loading": "Recherche en cours…"
|
||||
},
|
||||
"pickerModal": {
|
||||
"title": "Lier un mail à cette tâche",
|
||||
"searchPlaceholder": "Rechercher un mail (sujet, expéditeur)…",
|
||||
"empty": "Aucun mail correspondant.",
|
||||
"loading": "Chargement des mails…",
|
||||
"submit": "Lier ce mail"
|
||||
},
|
||||
"taskTab": {
|
||||
"title": "Mails",
|
||||
"empty": "Aucun mail lié à cette tâche.",
|
||||
"linkButton": "Lier un mail",
|
||||
"openInMailer": "Ouvrir dans la messagerie",
|
||||
"unlinkConfirm": "Délier ce mail ?"
|
||||
},
|
||||
@@ -614,5 +557,187 @@
|
||||
"hasAttachments": "Pièces jointes",
|
||||
"unread": "non lu | non lus",
|
||||
"remoteImagesBlocked": "Les images distantes sont masquées pour votre sécurité."
|
||||
},
|
||||
"absences": {
|
||||
"title": "Mes absences",
|
||||
"teamTitle": "Absences de l'équipe",
|
||||
"newRequest": "Nouvelle demande",
|
||||
"daySingular": "jour",
|
||||
"daysPlural": "jours",
|
||||
"noBalance": "Aucun solde à afficher.",
|
||||
"noRequests": "Aucune demande.",
|
||||
"remaining": "restants",
|
||||
"acquired": "acquis",
|
||||
"acquiredN1": "Acquis (N-1)",
|
||||
"acquiringN": "En cours d'acquisition (N)",
|
||||
"acquiringHint": "posables par anticipation",
|
||||
"taken": "pris",
|
||||
"pending": "en attente",
|
||||
"available": "disponible",
|
||||
"types": {
|
||||
"cp": "Congés payés",
|
||||
"mariage_pacs": "Mariage / PACS",
|
||||
"naissance": "Naissance",
|
||||
"conge_parental": "Congé parental",
|
||||
"deces": "Décès proche",
|
||||
"maladie": "Arrêt maladie"
|
||||
},
|
||||
"status": {
|
||||
"pending": "En attente",
|
||||
"approved": "Approuvée",
|
||||
"rejected": "Refusée",
|
||||
"cancelled": "Annulée"
|
||||
},
|
||||
"halfDay": {
|
||||
"matin": "Matin",
|
||||
"apres_midi": "Après-midi"
|
||||
},
|
||||
"table": {
|
||||
"type": "Type",
|
||||
"period": "Période",
|
||||
"days": "Jours",
|
||||
"status": "Statut",
|
||||
"employee": "Salarié",
|
||||
"year": "Année",
|
||||
"requestedAt": "Demandé le",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"filters": {
|
||||
"allStatuses": "Tous les statuts",
|
||||
"allTypes": "Tous les types",
|
||||
"allYears": "Toutes les années",
|
||||
"allEmployees": "Tous les salariés"
|
||||
},
|
||||
"form": {
|
||||
"type": "Type d'absence",
|
||||
"startDate": "Date de début",
|
||||
"endDate": "Date de fin",
|
||||
"startHalfDay": "Demi-journée (début)",
|
||||
"endHalfDay": "Demi-journée (fin)",
|
||||
"halfDayCheckbox": "Demi-journée",
|
||||
"reason": "Motif",
|
||||
"reasonPlaceholder": "Précisez le motif si nécessaire…",
|
||||
"justification": "Justificatif",
|
||||
"computed": "{days} décompté(s)",
|
||||
"balanceAfter": "Solde restant après cette demande : {value}",
|
||||
"negativeWarning": "Cette demande dépasse votre solde disponible.",
|
||||
"noticeWarning": "Le délai de prévenance ({days} jours) n'est pas respecté.",
|
||||
"submit": "Soumettre la demande",
|
||||
"justificationRequired": "Un justificatif est requis pour ce type d'absence.",
|
||||
"fullDay": "Journée entière",
|
||||
"balanceAt": "Solde au {date}",
|
||||
"balanceAfterValidation": "Solde après validation",
|
||||
"duration": "Durée de la demande",
|
||||
"commentPlaceholder": "Écrire un commentaire…",
|
||||
"serverError": "La demande n'a pas pu être enregistrée.",
|
||||
"errors": {
|
||||
"typeRequired": "Veuillez choisir un type d'absence.",
|
||||
"startRequired": "Veuillez indiquer une date de début.",
|
||||
"endRequired": "Veuillez indiquer une date de fin.",
|
||||
"endBeforeStart": "La date de fin doit être après la date de début.",
|
||||
"zeroDays": "La période sélectionnée ne décompte aucun jour.",
|
||||
"justificationRequired": "Un justificatif est obligatoire pour ce type d'absence."
|
||||
}
|
||||
},
|
||||
"detail": {
|
||||
"title": "Détail de la demande",
|
||||
"timeline": "Historique",
|
||||
"created": "Demande créée",
|
||||
"reviewed": "Traitée par {name}",
|
||||
"rejectionReason": "Motif du refus",
|
||||
"downloadJustification": "Télécharger le justificatif",
|
||||
"cancel": "Annuler ma demande",
|
||||
"cancelConfirm": "Annuler cette demande ?"
|
||||
},
|
||||
"review": {
|
||||
"approve": "Valider",
|
||||
"reject": "Refuser",
|
||||
"rejectTitle": "Refuser la demande",
|
||||
"rejectReasonLabel": "Motif du refus",
|
||||
"rejectReasonPlaceholder": "Expliquez la raison du refus…",
|
||||
"confirm": "Confirmer"
|
||||
},
|
||||
"admin": {
|
||||
"tabs": {
|
||||
"requests": "Demandes",
|
||||
"calendar": "Calendrier",
|
||||
"balances": "Soldes",
|
||||
"employees": "Employés"
|
||||
},
|
||||
"kpis": {
|
||||
"pending": "En attente",
|
||||
"todayAbsent": "Absents aujourd'hui",
|
||||
"weekAbsent": "Absents cette semaine"
|
||||
},
|
||||
"balancesTable": {
|
||||
"employee": "Salarié",
|
||||
"type": "Type",
|
||||
"period": "Période",
|
||||
"acquired": "Acquis (N-1)",
|
||||
"acquiring": "En cours (N)",
|
||||
"taken": "Pris",
|
||||
"pending": "En attente",
|
||||
"available": "Disponible",
|
||||
"adjust": "Ajuster"
|
||||
},
|
||||
"adjust": {
|
||||
"title": "Ajuster le solde",
|
||||
"acquired": "Acquis (N-1)",
|
||||
"acquiring": "En cours d'acquisition (N)",
|
||||
"taken": "Pris",
|
||||
"save": "Enregistrer"
|
||||
},
|
||||
"employees": {
|
||||
"columns": {
|
||||
"name": "Nom",
|
||||
"contract": "Contrat",
|
||||
"cpTaken": "CP pris",
|
||||
"cpRemaining": "CP restants"
|
||||
},
|
||||
"empty": "Aucun employé. Cochez « Employé » sur un utilisateur dans l'administration.",
|
||||
"noContract": "—",
|
||||
"drawer": {
|
||||
"title": "Informations employé",
|
||||
"save": "Enregistrer"
|
||||
},
|
||||
"fields": {
|
||||
"hireDate": "Date d'embauche",
|
||||
"endDate": "Date de sortie",
|
||||
"contractType": "Type de contrat",
|
||||
"workTimeRatio": "Temps de travail (ex : 1.0)",
|
||||
"annualLeaveDays": "CP annuels (jours)",
|
||||
"referencePeriodStart": "Début période réf. (MM-DD)",
|
||||
"initialLeaveBalance": "Solde CP initial"
|
||||
},
|
||||
"contract": {
|
||||
"cdi": "CDI",
|
||||
"cdd": "CDD",
|
||||
"stage": "Stage",
|
||||
"alternance": "Alternance",
|
||||
"autre": "Autre"
|
||||
}
|
||||
}
|
||||
},
|
||||
"policies": {
|
||||
"title": "Politiques d'absence",
|
||||
"subtitle": "Réglez les défauts par type d'absence — convention de référence : Syntec (IDCC 1486), à confirmer selon le code APE.",
|
||||
"type": "Type",
|
||||
"daysPerYear": "Jours / an",
|
||||
"daysPerEvent": "Jours / événement",
|
||||
"justificationRequired": "Justificatif requis",
|
||||
"noticeDays": "Délai prévenance (j)",
|
||||
"countWorkingDaysOnly": "Jours ouvrés",
|
||||
"active": "Actif",
|
||||
"save": "Enregistrer"
|
||||
},
|
||||
"toast": {
|
||||
"created": "Demande d'absence créée.",
|
||||
"approved": "Demande validée.",
|
||||
"rejected": "Demande refusée.",
|
||||
"cancelled": "Demande annulée.",
|
||||
"justificationUploaded": "Justificatif ajouté.",
|
||||
"balanceAdjusted": "Solde ajusté.",
|
||||
"policyUpdated": "Politique mise à jour."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,12 @@
|
||||
:class="sidebarIsCollapsed ? 'mt-4' : 'border-t border-secondary-500 pt-6'"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
|
||||
<!-- Section : Gestion de projet -->
|
||||
<p v-if="!sidebarIsCollapsed" class="px-4 pt-5 pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
|
||||
Gestion de projet
|
||||
</p>
|
||||
<div v-else class="mx-2 my-3 border-t border-secondary-500" />
|
||||
<SidebarLink
|
||||
to="/my-tasks"
|
||||
icon="mdi:clipboard-check-outline"
|
||||
@@ -53,23 +59,6 @@
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<div v-if="isMailVisible" class="relative">
|
||||
<SidebarLink
|
||||
to="/mail"
|
||||
icon="mdi:email-outline"
|
||||
:label="$t('mail.sidebar.title')"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<span
|
||||
v-if="mailStore.globalUnreadCount > 0"
|
||||
class="pointer-events-none absolute right-3 top-1/2 flex h-5 min-w-5 -translate-y-1/2 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
|
||||
:class="{ 'right-1 top-1 translate-y-0': sidebarIsCollapsed }"
|
||||
:aria-label="`${mailStore.globalUnreadCount} messages non lus`"
|
||||
>
|
||||
{{ mailStore.globalUnreadCount > 99 ? '99+' : mailStore.globalUnreadCount }}
|
||||
</span>
|
||||
</div>
|
||||
<SidebarLink
|
||||
to="/projects"
|
||||
icon="mdi:folder-outline"
|
||||
@@ -103,14 +92,6 @@
|
||||
sub
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<SidebarLink
|
||||
:to="`/projects/${currentProjectId}/client-tickets`"
|
||||
icon="mdi:ticket-outline"
|
||||
label="Tickets client"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
sub
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
</template>
|
||||
<SidebarLink
|
||||
to="/time-tracking"
|
||||
@@ -119,13 +100,59 @@
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<div v-if="isMailVisible" class="relative">
|
||||
<SidebarLink
|
||||
to="/mail"
|
||||
icon="mdi:email-outline"
|
||||
:label="$t('mail.sidebar.title')"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<span
|
||||
v-if="mailStore.globalUnreadCount > 0"
|
||||
class="pointer-events-none absolute right-3 top-1/2 flex h-5 min-w-5 -translate-y-1/2 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
|
||||
:class="{ 'right-1 top-1 translate-y-0': sidebarIsCollapsed }"
|
||||
:aria-label="`${mailStore.globalUnreadCount} messages non lus`"
|
||||
>
|
||||
{{ mailStore.globalUnreadCount > 99 ? '99+' : mailStore.globalUnreadCount }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Section : Absences -->
|
||||
<p v-if="!sidebarIsCollapsed" class="px-4 pt-5 pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
|
||||
Absences
|
||||
</p>
|
||||
<div v-else class="mx-2 my-3 border-t border-secondary-500" />
|
||||
<SidebarLink
|
||||
to="/admin"
|
||||
icon="mdi:cog-outline"
|
||||
label="Administration"
|
||||
to="/absences"
|
||||
icon="mdi:umbrella-beach-outline"
|
||||
label="Mes absences"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<SidebarLink
|
||||
v-if="isAdmin"
|
||||
to="/team-absences"
|
||||
icon="mdi:calendar-account-outline"
|
||||
label="Absences équipe"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
|
||||
<!-- Section : Administration (admin only) -->
|
||||
<template v-if="isAdmin">
|
||||
<p v-if="!sidebarIsCollapsed" class="px-4 pt-5 pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
|
||||
Administration
|
||||
</p>
|
||||
<div v-else class="mx-2 my-3 border-t border-secondary-500" />
|
||||
<SidebarLink
|
||||
to="/admin"
|
||||
icon="mdi:cog-outline"
|
||||
label="Administration"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<div class="px-4 py-3">
|
||||
@@ -183,12 +210,11 @@ const mailStore = useMailStore()
|
||||
const {version} = useAppVersion()
|
||||
const route = useRoute()
|
||||
|
||||
const isAdmin = computed(() => (auth.user?.roles ?? []).includes('ROLE_ADMIN'))
|
||||
|
||||
const isMailVisible = computed(() => {
|
||||
const roles: string[] = auth.user?.roles ?? []
|
||||
const isClientOnly = roles.includes('ROLE_CLIENT')
|
||||
&& !roles.includes('ROLE_ADMIN')
|
||||
&& !roles.includes('ROLE_USER')
|
||||
return !isClientOnly && (roles.includes('ROLE_USER') || roles.includes('ROLE_ADMIN'))
|
||||
return roles.includes('ROLE_USER') || roles.includes('ROLE_ADMIN')
|
||||
})
|
||||
|
||||
// On mobile, sidebar is always expanded (not collapsed icon mode)
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
<template>
|
||||
<div class="h-screen overflow-hidden">
|
||||
<div class="flex h-full">
|
||||
<!-- Mobile sidebar overlay -->
|
||||
<Transition name="sidebar-overlay">
|
||||
<div
|
||||
v-if="ui.sidebarOpen"
|
||||
class="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<aside
|
||||
class="fixed inset-y-0 left-0 z-50 flex h-full w-64 flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500 transition-transform duration-300 lg:static lg:z-auto lg:translate-x-0"
|
||||
:class="ui.sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<img src="/malio.png" alt="Logo" class="w-auto" />
|
||||
<button
|
||||
class="mr-2 rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:hidden"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
>
|
||||
<Icon name="mdi:close" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
<nav class="flex-1 px-4 pb-6">
|
||||
<SidebarLink
|
||||
to="/portal"
|
||||
icon="mdi:folder-outline"
|
||||
label="Mes projets"
|
||||
:collapsed="false"
|
||||
class="border-t border-secondary-500 pt-6"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<SidebarLink
|
||||
v-if="isAdmin"
|
||||
to="/"
|
||||
icon="mdi:shield-crown-outline"
|
||||
label="Administration"
|
||||
:collapsed="false"
|
||||
class="mt-2"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<div class="flex flex-col gap-2 items-center p-4">
|
||||
<p class="font-bold">v {{ version }}</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="h-full flex-1 flex flex-col min-h-0">
|
||||
<AppTopNav :user="auth.user" />
|
||||
<main class="flex flex-1 flex-col overflow-y-auto bg-white px-4 pb-24 sm:px-8 lg:px-16">
|
||||
<div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12" />
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAppVersion } from '~/composables/useAppVersion'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
const route = useRoute()
|
||||
const { version } = useAppVersion()
|
||||
|
||||
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||
|
||||
// Close mobile sidebar on route change
|
||||
watch(() => route.path, () => {
|
||||
ui.closeMobileSidebar()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar-overlay-enter-active,
|
||||
.sidebar-overlay-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.sidebar-overlay-enter-from,
|
||||
.sidebar-overlay-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -10,16 +10,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
return navigateTo('/login')
|
||||
}
|
||||
|
||||
const isClientOnly = auth.isAuthenticated
|
||||
&& auth.user?.roles?.includes('ROLE_CLIENT')
|
||||
&& !auth.user?.roles?.includes('ROLE_ADMIN')
|
||||
|
||||
if (isLogin && auth.isAuthenticated) {
|
||||
return navigateTo(isClientOnly ? '/portal' : '/')
|
||||
}
|
||||
|
||||
const isProfileRoute = to.path === '/profile'
|
||||
if (isClientOnly && !to.path.startsWith('/portal') && !isProfileRoute) {
|
||||
return navigateTo('/portal')
|
||||
return navigateTo('/')
|
||||
}
|
||||
})
|
||||
|
||||
8
frontend/package-lock.json
generated
8
frontend/package-lock.json
generated
@@ -7,7 +7,7 @@
|
||||
"name": "nuxt-app",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.4.8",
|
||||
"@malio/layer-ui": "^1.6.0",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
@@ -2210,9 +2210,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@malio/layer-ui": {
|
||||
"version": "1.4.8",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.4.8/layer-ui-1.4.8.tgz",
|
||||
"integrity": "sha512-ABQmfMqJqKGGnx6kf5KK/XVuKAPWSpRHmLpS9XMg6pUH8kww8o3JoywlrlFkk9xA30zNFaehAtzV7S19E4JTlg==",
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.6.0/layer-ui-1.6.0.tgz",
|
||||
"integrity": "sha512-2sN4mL1Jf984oeE4N4yEv6XFgSz0Gc+uSG+HLGfRrdzjAsMcU9hbb7HSAo3Q6MBvQHZn3ZBr1cK+VUM0kXY4NA==",
|
||||
"dependencies": {
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@malio/layer-ui": "^1.4.8",
|
||||
"@malio/layer-ui": "^1.6.0",
|
||||
"@nuxt/icon": "^2.2.1",
|
||||
"@nuxtjs/i18n": "^10.2.3",
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
168
frontend/pages/absences.vue
Normal file
168
frontend/pages/absences.vue
Normal file
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-neutral-900">{{ $t('absences.title') }}</h1>
|
||||
<MalioButton
|
||||
:label="$t('absences.newRequest')"
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
@click="requestDrawerOpen = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AbsenceBalanceCards :balances="balances" />
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<MalioSelect
|
||||
v-model="filters.status"
|
||||
:label="$t('absences.table.status')"
|
||||
:options="statusOptions"
|
||||
:empty-option-label="$t('absences.filters.allStatuses')"
|
||||
group-class="w-52"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="filters.type"
|
||||
:label="$t('absences.table.type')"
|
||||
:options="typeOptions"
|
||||
:empty-option-label="$t('absences.filters.allTypes')"
|
||||
group-class="w-52"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="filters.year"
|
||||
:label="$t('absences.table.year')"
|
||||
:options="yearOptions"
|
||||
:empty-option-label="$t('absences.filters.allYears')"
|
||||
group-class="w-40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="rows"
|
||||
:total-items="rows.length"
|
||||
:row-clickable="true"
|
||||
:empty-message="$t('absences.noRequests')"
|
||||
@row-click="openDetail"
|
||||
>
|
||||
<template #cell-status="{ item }">
|
||||
<StatusBadge
|
||||
:label="statusLabel((item as Row).status)"
|
||||
:variant="statusVariant((item as Row).status)"
|
||||
:icon="statusIcon((item as Row).status)"
|
||||
/>
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
|
||||
<AbsenceRequestDrawer
|
||||
v-model="requestDrawerOpen"
|
||||
:policies="policies"
|
||||
@created="reload"
|
||||
/>
|
||||
<AbsenceDetailDrawer
|
||||
v-model="detailDrawerOpen"
|
||||
:request="selected"
|
||||
:can-cancel="selected?.status === 'pending'"
|
||||
@cancelled="reload"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsenceBalance, AbsencePolicy, AbsenceRequest, AbsenceStatus, AbsenceType } from '~/services/dto/absence'
|
||||
import { useAbsenceService, type AbsenceRequestFilters } from '~/services/absences'
|
||||
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||
|
||||
type Row = AbsenceRequest & { typeLabelText: string; periodText: string; daysText: string; createdAtText: string }
|
||||
|
||||
const { t } = useI18n()
|
||||
const service = useAbsenceService()
|
||||
const { statusLabel, statusVariant, statusIcon, formatRange, formatDays, formatDate } = useAbsenceHelpers()
|
||||
|
||||
useHead({ title: t('absences.title') })
|
||||
|
||||
const balances = ref<AbsenceBalance[]>([])
|
||||
const requests = ref<AbsenceRequest[]>([])
|
||||
const policies = ref<AbsencePolicy[]>([])
|
||||
|
||||
const requestDrawerOpen = ref(false)
|
||||
const detailDrawerOpen = ref(false)
|
||||
const selected = ref<AbsenceRequest | null>(null)
|
||||
|
||||
// Empty option of MalioSelect has value null, so filters default to null.
|
||||
const filters = reactive<{ status: AbsenceStatus | null; type: AbsenceType | null; year: number | null }>({
|
||||
status: null,
|
||||
type: null,
|
||||
year: null,
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ key: 'typeLabelText', label: t('absences.table.type') },
|
||||
{ key: 'periodText', label: t('absences.table.period') },
|
||||
{ key: 'daysText', label: t('absences.table.days') },
|
||||
{ key: 'status', label: t('absences.table.status') },
|
||||
{ key: 'createdAtText', label: t('absences.table.requestedAt') },
|
||||
]
|
||||
|
||||
const statusOptions = [
|
||||
{ label: t('absences.status.pending'), value: 'pending' },
|
||||
{ label: t('absences.status.approved'), value: 'approved' },
|
||||
{ label: t('absences.status.rejected'), value: 'rejected' },
|
||||
{ label: t('absences.status.cancelled'), value: 'cancelled' },
|
||||
]
|
||||
|
||||
const typeOptions = computed(() => policies.value.map(p => ({ label: p.label, value: p.type })))
|
||||
|
||||
const yearOptions = computed(() => {
|
||||
const current = new Date().getFullYear()
|
||||
return [current + 1, current, current - 1, current - 2].map(y => ({ label: String(y), value: y }))
|
||||
})
|
||||
|
||||
const rows = computed<Row[]>(() =>
|
||||
requests.value.map(r => ({
|
||||
...r,
|
||||
typeLabelText: r.label,
|
||||
periodText: formatRange(r),
|
||||
daysText: formatDays(r.countedDays),
|
||||
createdAtText: formatDate(r.createdAt),
|
||||
})),
|
||||
)
|
||||
|
||||
function openDetail(item: Record<string, unknown>) {
|
||||
selected.value = item as Row
|
||||
detailDrawerOpen.value = true
|
||||
}
|
||||
|
||||
async function loadRequests() {
|
||||
// Scope to the current user: the collection endpoint returns every user's
|
||||
// requests for admins, which would leak the whole team into "Mes absences".
|
||||
const userId = useAuthStore().user?.id
|
||||
if (!userId) {
|
||||
requests.value = []
|
||||
return
|
||||
}
|
||||
const f: AbsenceRequestFilters = { user: userId }
|
||||
if (filters.status) f.status = filters.status
|
||||
if (filters.type) f.type = filters.type
|
||||
if (filters.year) f.year = filters.year
|
||||
requests.value = await service.getRequests(f)
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
// Scope balances to the current user: the collection endpoint returns every
|
||||
// user's balance for admins, which would pollute the personal "Mes absences" view.
|
||||
const userId = useAuthStore().user?.id
|
||||
const [bal] = await Promise.all([
|
||||
userId ? service.getBalances({ user: userId }) : Promise.resolve([]),
|
||||
loadRequests(),
|
||||
])
|
||||
balances.value = bal
|
||||
}
|
||||
|
||||
watch(() => [filters.status, filters.type, filters.year], loadRequests)
|
||||
|
||||
onMounted(async () => {
|
||||
policies.value = await service.getPolicies()
|
||||
await reload()
|
||||
})
|
||||
</script>
|
||||
@@ -31,6 +31,7 @@
|
||||
<AdminBookStackTab v-if="activeTab === 'bookstack'" />
|
||||
<AdminZimbraTab v-if="activeTab === 'zimbra'" />
|
||||
<AdminMailTab v-if="activeTab === 'mail'" />
|
||||
<AdminAbsencePolicyTab v-if="activeTab === 'absences'" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -50,6 +51,7 @@ const tabs = [
|
||||
{ key: 'bookstack', label: 'BookStack' },
|
||||
{ key: 'zimbra', label: 'Zimbra' },
|
||||
{ key: 'mail', label: 'Mail' },
|
||||
{ key: 'absences', label: 'Absences' },
|
||||
] as const
|
||||
|
||||
type TabKey = typeof tabs[number]['key']
|
||||
|
||||
@@ -20,10 +20,11 @@ const META: Record<string, { title: string, icon: string, accent: string, roles:
|
||||
'03-my-tasks': { title: 'Mes tâches', icon: 'mdi:checkbox-marked-circle-outline', accent: 'from-sky-500 to-cyan-500', roles: ['admin', 'user'] },
|
||||
'04-time-tracking': { title: 'Time tracking', icon: 'mdi:timer-outline', accent: 'from-emerald-500 to-teal-500', roles: ['admin', 'user'] },
|
||||
'05-tasks-detail': { title: 'Tâches en détail', icon: 'mdi:file-document-edit-outline', accent: 'from-violet-500 to-purple-600', roles: ['admin', 'user'] },
|
||||
'06-client-portal': { title: 'Portal client', icon: 'mdi:account-tie-outline', accent: 'from-orange-500 to-amber-500', roles: ['admin', 'client'] },
|
||||
'06-absences': { title: 'Absences', icon: 'mdi:calendar-account-outline', accent: 'from-lime-500 to-green-600', roles: ['admin', 'user'] },
|
||||
'07-admin': { title: 'Administration', icon: 'mdi:shield-crown-outline', accent: 'from-rose-500 to-pink-600', roles: ['admin'] },
|
||||
'08-integrations': { title: 'Intégrations', icon: 'mdi:puzzle-outline', accent: 'from-blue-500 to-indigo-500', roles: ['admin', 'user'] },
|
||||
'09-mcp-api': { title: 'Token MCP & API', icon: 'mdi:robot-outline', accent: 'from-slate-700 to-slate-900', roles: ['admin', 'user'] },
|
||||
'10-messaging': { title: 'Messagerie', icon: 'mdi:email-outline', accent: 'from-teal-500 to-cyan-600', roles: ['admin', 'user'] },
|
||||
}
|
||||
|
||||
const sections = computed<Section[]>(() => {
|
||||
@@ -39,7 +40,6 @@ const auth = useAuthStore()
|
||||
const userRole = computed<'admin' | 'user' | 'client'>(() => {
|
||||
const roles = auth.user?.roles ?? []
|
||||
if (roles.includes('ROLE_ADMIN')) return 'admin'
|
||||
if (roles.includes('ROLE_CLIENT')) return 'client'
|
||||
return 'user'
|
||||
})
|
||||
|
||||
|
||||
@@ -515,7 +515,7 @@ const lineOptions = {
|
||||
v-model="selectedPeriod"
|
||||
:options="periodOptions"
|
||||
:label="$t('dashboard.filters.period')"
|
||||
min-width="!w-48"
|
||||
group-class="!w-48"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -524,7 +524,7 @@ const lineOptions = {
|
||||
:options="projectOptions"
|
||||
:label="$t('dashboard.filters.project')"
|
||||
:empty-option-label="$t('dashboard.filters.allProjects')"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -533,7 +533,7 @@ const lineOptions = {
|
||||
:options="userOptions"
|
||||
:label="$t('dashboard.filters.user')"
|
||||
:empty-option-label="$t('dashboard.filters.allUsers')"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
|
||||
@@ -54,9 +54,7 @@ async function handleSubmit() {
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await auth.login(username.value, password.value)
|
||||
|
||||
const isClient = auth.user?.roles?.includes('ROLE_CLIENT') ?? false
|
||||
await navigateTo(isClient ? '/portal' : '/')
|
||||
await navigateTo('/')
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
|
||||
@@ -1,28 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import { useMailStore } from '~/stores/mail'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
|
||||
useHead({ title: t('mail.title') })
|
||||
|
||||
// ─── Contrôle d'accès ROLE_CLIENT ─────────────────────────────────────────
|
||||
// Le middleware global gère auth + ROLE_CLIENT → /portal. Ici : double check
|
||||
// en SPA car la session peut être hydratée après le rendu initial.
|
||||
|
||||
const isClientOnly = computed(() =>
|
||||
auth.user?.roles?.includes('ROLE_CLIENT') === true
|
||||
&& auth.user?.roles?.includes('ROLE_ADMIN') !== true,
|
||||
)
|
||||
|
||||
if (isClientOnly.value) {
|
||||
await navigateTo('/portal')
|
||||
}
|
||||
|
||||
// ─── Store ────────────────────────────────────────────────────────────────
|
||||
|
||||
const store = useMailStore()
|
||||
@@ -40,11 +25,6 @@ const {
|
||||
// ─── Init : charge les dossiers + deep-link ───────────────────────────────
|
||||
|
||||
onMounted(async () => {
|
||||
if (isClientOnly.value) {
|
||||
router.replace('/portal')
|
||||
return
|
||||
}
|
||||
|
||||
if (folderTree.value.length === 0) {
|
||||
await store.fetchFolders()
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { StatusCategory } from '~/services/dto/workflow'
|
||||
import { STATUS_CATEGORY_LABEL } from '~/services/dto/workflow'
|
||||
import { STATUS_CATEGORY_LABEL, STATUS_CATEGORY_COLOR, contrastText } from '~/services/dto/workflow'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
import { useTaskStatusService } from '~/services/task-statuses'
|
||||
import { useTaskEffortService } from '~/services/task-efforts'
|
||||
@@ -71,6 +71,48 @@ const selectedTask = ref<Task | null>(null)
|
||||
// Timer
|
||||
const timerStore = useTimerStore()
|
||||
|
||||
// Toast
|
||||
const toast = useToast()
|
||||
|
||||
// Drag & drop
|
||||
const dragOverCategory = ref<StatusCategory | null>(null)
|
||||
const pendingPicker = ref<{ statuses: TaskStatus[], task: Task, x: number, y: number } | null>(null)
|
||||
|
||||
function statusesForTaskCategory(task: Task, category: StatusCategory): TaskStatus[] {
|
||||
// GET /tasks n'embarque que l'IRI du workflow ; on résout depuis la liste projects chargée (qui embarque workflow.statuses).
|
||||
const project = projects.value.find(p => p.id === task.project?.id)
|
||||
const wf = project?.workflow
|
||||
if (!wf || typeof wf === 'string') return []
|
||||
return wf.statuses.filter(s => s.category === category)
|
||||
}
|
||||
|
||||
async function applyStatus(task: Task, status: TaskStatus): Promise<void> {
|
||||
await taskService.update(task.id, { status: `/api/task_statuses/${status.id}` })
|
||||
await loadTasks()
|
||||
}
|
||||
|
||||
function onDrop(category: StatusCategory, event: DragEvent): void {
|
||||
dragOverCategory.value = null
|
||||
const taskId = Number(event.dataTransfer?.getData('text/plain'))
|
||||
const task = tasks.value.find(t => t.id === taskId)
|
||||
if (!task) return
|
||||
const candidates = statusesForTaskCategory(task, category)
|
||||
if (candidates.length === 0) {
|
||||
toast.error({ message: t('myTasks.dropRefused') })
|
||||
return
|
||||
}
|
||||
if (candidates.length === 1) {
|
||||
void applyStatus(task, candidates[0])
|
||||
return
|
||||
}
|
||||
pendingPicker.value = { statuses: candidates, task, x: event.clientX, y: event.clientY }
|
||||
}
|
||||
|
||||
function onPickerChoice(status: TaskStatus): void {
|
||||
if (pendingPicker.value) void applyStatus(pendingPicker.value.task, status)
|
||||
pendingPicker.value = null
|
||||
}
|
||||
|
||||
function isTimerOnTask(task: Task): boolean {
|
||||
const entry = timerStore.activeEntry
|
||||
if (!entry?.task) return false
|
||||
@@ -330,7 +372,7 @@ onMounted(async () => {
|
||||
:options="projectOptions"
|
||||
label="Projet"
|
||||
:empty-option-label="$t('myTasks.allProjects')"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -339,7 +381,7 @@ onMounted(async () => {
|
||||
:options="groupOptions"
|
||||
label="Groupe"
|
||||
:empty-option-label="$t('myTasks.allGroups')"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -348,7 +390,7 @@ onMounted(async () => {
|
||||
:options="tagOptions"
|
||||
label="Type"
|
||||
:empty-option-label="$t('myTasks.allTypes')"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -357,7 +399,7 @@ onMounted(async () => {
|
||||
:options="priorityOptions"
|
||||
label="Priorité"
|
||||
:empty-option-label="$t('myTasks.allPriorities')"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -366,7 +408,7 @@ onMounted(async () => {
|
||||
:options="effortOptions"
|
||||
label="Effort"
|
||||
:empty-option-label="$t('myTasks.allEfforts')"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -375,7 +417,7 @@ onMounted(async () => {
|
||||
:options="assigneeOptions"
|
||||
label="Assigné"
|
||||
:empty-option-label="$t('myTasks.allAssignees')"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -384,7 +426,7 @@ onMounted(async () => {
|
||||
:options="sortOptions"
|
||||
:label="$t('myTasks.sortBy')"
|
||||
:empty-option-label="$t('myTasks.sortDefault')"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -397,9 +439,16 @@ onMounted(async () => {
|
||||
<div
|
||||
v-for="cat in CATEGORIES"
|
||||
:key="cat"
|
||||
class="flex min-w-40 flex-1 flex-col rounded-lg bg-neutral-50"
|
||||
class="flex min-w-40 flex-1 flex-col rounded-lg bg-neutral-50 transition"
|
||||
:class="dragOverCategory === cat ? 'ring-2 ring-primary-400' : ''"
|
||||
@dragover.prevent="dragOverCategory = cat"
|
||||
@dragleave="dragOverCategory = null"
|
||||
@drop="onDrop(cat, $event)"
|
||||
>
|
||||
<div class="shrink-0 rounded-t-lg bg-neutral-200 px-4 py-3 text-sm font-bold text-neutral-800">
|
||||
<div
|
||||
class="shrink-0 rounded-t-lg px-4 py-3 text-sm font-bold"
|
||||
:style="{ backgroundColor: STATUS_CATEGORY_COLOR[cat], color: contrastText(STATUS_CATEGORY_COLOR[cat]) }"
|
||||
>
|
||||
{{ STATUS_CATEGORY_LABEL[cat] }} ({{ tasksByCategory(cat).length }})
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
@@ -481,6 +530,16 @@ onMounted(async () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- StatusPickerPopover (D&D ambiguity resolution) -->
|
||||
<StatusPickerPopover
|
||||
v-if="pendingPicker"
|
||||
:statuses="pendingPicker.statuses"
|
||||
:x="pendingPicker.x"
|
||||
:y="pendingPicker.y"
|
||||
@pick="onPickerChoice"
|
||||
@cancel="pendingPicker = null"
|
||||
/>
|
||||
|
||||
<!-- TaskModal -->
|
||||
<TaskModal
|
||||
v-model="taskModalOpen"
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('portal.projects') }}</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="py-8 text-center text-sm text-neutral-400">
|
||||
{{ $t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="projects.length === 0" class="py-8 text-center text-sm text-neutral-400">
|
||||
{{ $t('portal.noProjects') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<NuxtLink
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
:to="`/portal/projects/${project.id}`"
|
||||
class="rounded-lg border border-neutral-200 bg-white p-5 shadow-sm transition hover:shadow-md"
|
||||
>
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ project.name }}</h3>
|
||||
<p class="mt-2 text-sm text-neutral-500">
|
||||
{{ ticketCountByProject[project.id] ?? 0 }} {{ $t('portal.openTickets') }}
|
||||
</p>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { ClientTicket } from '~/services/dto/client-ticket'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'portal',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
useHead({ title: t('portal.title') })
|
||||
|
||||
const auth = useAuthStore()
|
||||
const clientTicketService = useClientTicketService()
|
||||
const projectService = useProjectService()
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
const tickets = ref<ClientTicket[]>([])
|
||||
const isLoading = ref(true)
|
||||
|
||||
const ticketCountByProject = computed(() => {
|
||||
const counts: Record<number, number> = {}
|
||||
for (const ticket of tickets.value) {
|
||||
if (ticket.status === 'new' || ticket.status === 'in_progress') {
|
||||
const projectId = extractIdFromIri(ticket.project)
|
||||
if (projectId) {
|
||||
counts[projectId] = (counts[projectId] ?? 0) + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return counts
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
if (auth.user?.roles?.includes('ROLE_ADMIN')) {
|
||||
projects.value = await projectService.getAll({ archived: false })
|
||||
} else {
|
||||
// allowedProjects are embedded objects from /api/me (with me:read group)
|
||||
projects.value = (auth.user?.allowedProjects ?? []) as Project[]
|
||||
}
|
||||
|
||||
tickets.value = await clientTicketService.getAll()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
@@ -1,282 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<NuxtLink
|
||||
to="/portal"
|
||||
class="text-sm text-neutral-400 hover:text-primary-500"
|
||||
>
|
||||
{{ $t('portal.backToProject') }}
|
||||
</NuxtLink>
|
||||
<h1 class="mt-1 text-xl font-bold text-primary-500 sm:text-2xl">{{ projectName }}</h1>
|
||||
</div>
|
||||
<NuxtLink
|
||||
v-if="isClient"
|
||||
:to="`/portal/projects/${projectId}/new-ticket`"
|
||||
class="shrink-0 rounded-md bg-primary-500 px-3 py-2 text-xs font-semibold text-white hover:bg-secondary-500 sm:px-4 sm:text-sm"
|
||||
>
|
||||
<span class="hidden sm:inline">+ {{ $t('portal.newTicket') }}</span>
|
||||
<span class="sm:hidden">+ Ticket</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="py-8 text-center text-sm text-neutral-400">
|
||||
{{ $t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="tickets.length === 0" class="py-8 text-center text-sm text-neutral-400">
|
||||
{{ $t('clientTicket.noTickets') }}
|
||||
</div>
|
||||
|
||||
<!-- Kanban board -->
|
||||
<div v-else class="mt-4 flex h-[calc(100vh-200px)] flex-col gap-4 sm:flex-row sm:overflow-x-auto sm:pb-4">
|
||||
<div
|
||||
v-for="col in columns"
|
||||
:key="col.status"
|
||||
class="flex min-w-0 flex-1 flex-col sm:min-w-[280px]"
|
||||
>
|
||||
<div class="mb-3 flex shrink-0 items-center gap-2">
|
||||
<div class="h-2 w-2 rounded-full" :class="col.dotClass" />
|
||||
<h3 class="text-sm font-bold text-neutral-700">{{ col.label }}</h3>
|
||||
<span class="ml-auto rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-semibold text-neutral-500">
|
||||
{{ col.tickets.length }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="min-h-0 flex-1 space-y-2 overflow-y-auto rounded-lg border-2 border-transparent p-1 transition-colors"
|
||||
:class="dragOverStatus === col.status ? 'border-primary-300 bg-primary-50/50' : ''"
|
||||
@dragover.prevent="onDragOver(col.status)"
|
||||
@dragleave="onDragLeave"
|
||||
@drop.prevent="onDrop(col.status)"
|
||||
>
|
||||
<div
|
||||
v-for="ticket in col.tickets"
|
||||
:key="ticket.id"
|
||||
class="cursor-pointer rounded-lg border border-neutral-200 bg-white p-3 shadow-sm transition hover:shadow-md"
|
||||
:class="isAdmin ? 'cursor-grab active:cursor-grabbing' : ''"
|
||||
:draggable="isAdmin"
|
||||
@dragstart="onDragStart(ticket)"
|
||||
@dragend="onDragEnd"
|
||||
@click="openDetail(ticket)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-bold text-primary-500">CT-{{ String(ticket.number).padStart(3, '0') }}</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:class="typeBadgeClass(ticket.type)"
|
||||
>
|
||||
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
||||
</span>
|
||||
</div>
|
||||
<h4 class="mt-1.5 text-sm font-semibold leading-snug text-neutral-900">{{ ticket.title }}</h4>
|
||||
<p class="mt-1.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
|
||||
</div>
|
||||
<p
|
||||
v-if="col.tickets.length === 0"
|
||||
class="py-4 text-center text-xs text-neutral-400"
|
||||
>
|
||||
{{ $t('clientTicket.noTickets') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ticket detail modal -->
|
||||
<ClientTicketDetailModal
|
||||
v-model="detailOpen"
|
||||
:ticket="selectedTicket"
|
||||
@refresh="loadTickets"
|
||||
/>
|
||||
|
||||
<!-- Reject comment modal -->
|
||||
<Teleport v-if="rejectModalOpen" to="body">
|
||||
<div class="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||
<div class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm" @click="cancelReject" />
|
||||
<div class="relative z-10 w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.changeStatus') }}</h3>
|
||||
<p class="mt-2 text-sm text-neutral-600">{{ $t('clientTicket.rejectionRequired') }}</p>
|
||||
<textarea
|
||||
v-model="rejectComment"
|
||||
rows="3"
|
||||
class="mt-3 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"
|
||||
:placeholder="$t('clientTicket.rejectComment')"
|
||||
/>
|
||||
<div class="mt-4 flex justify-end gap-3">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('common.cancel')"
|
||||
button-class="w-auto px-4"
|
||||
@click="cancelReject"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
:label="$t('clientTicket.status.rejected')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="!rejectComment.trim()"
|
||||
@click="confirmReject"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ClientTicket, ClientTicketStatus } from '~/services/dto/client-ticket'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'portal',
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const projectId = computed(() => Number(route.params.id))
|
||||
|
||||
useHead({ title: t('portal.title') })
|
||||
|
||||
const clientTicketService = useClientTicketService()
|
||||
const projectService = useProjectService()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const tickets = ref<ClientTicket[]>([])
|
||||
const projectName = ref('')
|
||||
const isLoading = ref(true)
|
||||
const detailOpen = ref(false)
|
||||
const selectedTicket = ref<ClientTicket | null>(null)
|
||||
|
||||
const isClient = computed(() => auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN'))
|
||||
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||
const { typeBadgeClass, formatDate } = useClientTicketHelpers()
|
||||
|
||||
const allStatuses: ClientTicketStatus[] = ['new', 'in_progress', 'done', 'rejected']
|
||||
|
||||
function statusDotClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'new': return 'bg-blue-500'
|
||||
case 'in_progress': return 'bg-yellow-500'
|
||||
case 'done': return 'bg-green-500'
|
||||
case 'rejected': return 'bg-red-500'
|
||||
default: return 'bg-neutral-400'
|
||||
}
|
||||
}
|
||||
|
||||
const columns = computed(() => allStatuses.map(status => ({
|
||||
status,
|
||||
label: t(`clientTicket.status.${status}`),
|
||||
dotClass: statusDotClass(status),
|
||||
tickets: tickets.value.filter(tk => tk.status === status),
|
||||
})))
|
||||
|
||||
// Drag & drop (admin only)
|
||||
const draggedTicket = ref<ClientTicket | null>(null)
|
||||
const dragOverStatus = ref<ClientTicketStatus | null>(null)
|
||||
|
||||
function onDragStart(ticket: ClientTicket) {
|
||||
draggedTicket.value = ticket
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
draggedTicket.value = null
|
||||
dragOverStatus.value = null
|
||||
}
|
||||
|
||||
function onDragOver(status: ClientTicketStatus) {
|
||||
if (!draggedTicket.value) return
|
||||
dragOverStatus.value = status
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
dragOverStatus.value = null
|
||||
}
|
||||
|
||||
async function onDrop(newStatus: ClientTicketStatus) {
|
||||
dragOverStatus.value = null
|
||||
const ticket = draggedTicket.value
|
||||
draggedTicket.value = null
|
||||
|
||||
if (!ticket || ticket.status === newStatus) return
|
||||
|
||||
// Rejected requires a comment
|
||||
if (newStatus === 'rejected') {
|
||||
pendingRejectTicket.value = ticket
|
||||
rejectComment.value = ''
|
||||
rejectModalOpen.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// Optimistic update
|
||||
const oldStatus = ticket.status
|
||||
ticket.status = newStatus
|
||||
try {
|
||||
await clientTicketService.updateStatus(ticket.id, { status: newStatus })
|
||||
await loadTickets()
|
||||
} catch {
|
||||
ticket.status = oldStatus
|
||||
}
|
||||
}
|
||||
|
||||
// Reject modal
|
||||
const rejectModalOpen = ref(false)
|
||||
const rejectComment = ref('')
|
||||
const pendingRejectTicket = ref<ClientTicket | null>(null)
|
||||
|
||||
function cancelReject() {
|
||||
rejectModalOpen.value = false
|
||||
pendingRejectTicket.value = null
|
||||
rejectComment.value = ''
|
||||
}
|
||||
|
||||
async function confirmReject() {
|
||||
const ticket = pendingRejectTicket.value
|
||||
if (!ticket || !rejectComment.value.trim()) return
|
||||
|
||||
const oldStatus = ticket.status
|
||||
ticket.status = 'rejected'
|
||||
rejectModalOpen.value = false
|
||||
|
||||
try {
|
||||
await clientTicketService.updateStatus(ticket.id, {
|
||||
status: 'rejected',
|
||||
statusComment: rejectComment.value.trim(),
|
||||
})
|
||||
await loadTickets()
|
||||
} catch {
|
||||
ticket.status = oldStatus
|
||||
}
|
||||
|
||||
pendingRejectTicket.value = null
|
||||
rejectComment.value = ''
|
||||
}
|
||||
|
||||
function openDetail(ticket: ClientTicket) {
|
||||
selectedTicket.value = ticket
|
||||
detailOpen.value = true
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const [ticketList, project] = await Promise.all([
|
||||
clientTicketService.getAll({ project: projectId.value }),
|
||||
projectService.getById(projectId.value),
|
||||
])
|
||||
tickets.value = ticketList
|
||||
projectName.value = project.name
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTickets() {
|
||||
tickets.value = await clientTicketService.getAll({ project: projectId.value })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
@@ -1,133 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<NuxtLink
|
||||
:to="`/portal/projects/${projectId}`"
|
||||
class="text-sm text-neutral-400 hover:text-primary-500"
|
||||
>
|
||||
{{ $t('portal.backToProject') }}
|
||||
</NuxtLink>
|
||||
<h1 class="mt-1 text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('portal.newTicket') }}</h1>
|
||||
</div>
|
||||
|
||||
<form class="mt-4 max-w-2xl" @submit.prevent="handleSubmit">
|
||||
<!-- Type -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('clientTicket.selectType') }}</label>
|
||||
<select
|
||||
v-model="form.type"
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="bug">{{ $t('clientTicket.type.bug') }}</option>
|
||||
<option value="improvement">{{ $t('clientTicket.type.improvement') }}</option>
|
||||
<option value="other">{{ $t('clientTicket.type.other') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<div class="mt-4">
|
||||
<MalioInputText
|
||||
v-model="form.title"
|
||||
:label="$t('clientTicket.title')"
|
||||
input-class="w-full"
|
||||
:error="touched.title && !form.title.trim() ? $t('clientTicket.title') + ' requis' : ''"
|
||||
@blur="touched.title = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mt-4">
|
||||
<MalioInputRichText
|
||||
v-model="form.description"
|
||||
:label="$t('clientTicket.description')"
|
||||
min-height="180px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- URL (only for bug type) -->
|
||||
<div v-if="form.type === 'bug'" class="mt-4">
|
||||
<MalioInputText
|
||||
v-model="form.url"
|
||||
:label="$t('clientTicket.url')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Document upload (only after ticket is created) -->
|
||||
<div class="mt-4 rounded-lg border border-dashed border-neutral-300 p-4">
|
||||
<p class="text-sm text-neutral-500">
|
||||
<Icon name="heroicons:information-circle" class="mr-1 inline h-4 w-4" />
|
||||
Les documents pourront être ajoutés après la soumission du ticket.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="mt-6 flex items-center gap-3">
|
||||
<NuxtLink
|
||||
:to="`/portal/projects/${projectId}`"
|
||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</NuxtLink>
|
||||
<MalioButton
|
||||
:label="$t('portal.submitTicket')"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ClientTicketType } from '~/services/dto/client-ticket'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'portal',
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const projectId = computed(() => Number(route.params.id))
|
||||
|
||||
useHead({ title: t('portal.newTicket') })
|
||||
|
||||
const clientTicketService = useClientTicketService()
|
||||
|
||||
const form = reactive({
|
||||
type: 'bug' as ClientTicketType | string,
|
||||
title: '',
|
||||
description: '',
|
||||
url: '',
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
title: false,
|
||||
})
|
||||
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.title = true
|
||||
if (!form.title.trim()) return
|
||||
if (!form.description.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await clientTicketService.create({
|
||||
type: form.type as ClientTicketType,
|
||||
title: form.title.trim(),
|
||||
description: form.description.trim(),
|
||||
url: form.type === 'bug' && form.url.trim() ? form.url.trim() : null,
|
||||
project: `/api/projects/${projectId.value}`,
|
||||
})
|
||||
await navigateTo(`/portal/projects/${projectId.value}`)
|
||||
} catch {
|
||||
// Toast already shown by useApi
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<NuxtLayout :name="isClientOnly ? 'portal' : 'default'">
|
||||
<NuxtLayout name="default">
|
||||
<div class="mx-auto max-w-lg px-4 py-10">
|
||||
<h1 class="mb-8 text-2xl font-bold text-neutral-900">{{ $t('profile.title') }}</h1>
|
||||
|
||||
@@ -14,17 +14,18 @@
|
||||
<p class="text-lg font-semibold text-neutral-800">{{ auth.user?.username }}</p>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<label
|
||||
class="cursor-pointer rounded-lg bg-primary-500 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500"
|
||||
<MalioButton
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('profile.changeAvatar')"
|
||||
@click="avatarInput?.click()"
|
||||
/>
|
||||
<input
|
||||
ref="avatarInput"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
class="hidden"
|
||||
@change="onFileSelect"
|
||||
>
|
||||
{{ $t('profile.changeAvatar') }}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
class="hidden"
|
||||
@change="onFileSelect"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<MalioButton
|
||||
v-if="auth.user?.avatarUrl"
|
||||
@@ -39,7 +40,6 @@
|
||||
|
||||
<!-- API Token MCP (interne uniquement) -->
|
||||
<div
|
||||
v-if="!isClientOnly"
|
||||
class="mt-8 rounded-xl border border-neutral-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
<h2 class="mb-1 text-lg font-bold text-neutral-900">{{ $t('profile.apiToken.title') }}</h2>
|
||||
@@ -134,10 +134,6 @@ const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isClientOnly = computed(() =>
|
||||
auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN')
|
||||
)
|
||||
|
||||
definePageMeta({
|
||||
layout: false,
|
||||
})
|
||||
@@ -145,6 +141,7 @@ const { upload, remove } = useAvatarService()
|
||||
const { regenerate } = useApiTokenService()
|
||||
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const avatarInput = ref<HTMLInputElement | null>(null)
|
||||
const removing = ref(false)
|
||||
const regenerating = ref(false)
|
||||
const showConfirm = ref(false)
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
:options="groupFilterOptions"
|
||||
label="Groupe"
|
||||
empty-option-label="Tous les groupes"
|
||||
min-width="w-64"
|
||||
group-class="w-64"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
||||
Tickets client
|
||||
<span v-if="project" class="text-neutral-400">— {{ project.name }}</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center gap-3">
|
||||
<select
|
||||
v-model="filterStatus"
|
||||
class="rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
|
||||
>
|
||||
<option :value="null">Tous les statuts</option>
|
||||
<option value="new">{{ $t('clientTicket.status.new') }}</option>
|
||||
<option value="in_progress">{{ $t('clientTicket.status.in_progress') }}</option>
|
||||
<option value="done">{{ $t('clientTicket.status.done') }}</option>
|
||||
<option value="rejected">{{ $t('clientTicket.status.rejected') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="py-12 text-center text-sm text-neutral-400">
|
||||
{{ $t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredTickets.length === 0" class="py-12 text-center text-sm text-neutral-400">
|
||||
{{ $t('clientTicket.noTickets') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 space-y-3">
|
||||
<div
|
||||
v-for="ticket in filteredTickets"
|
||||
:key="ticket.id"
|
||||
class="rounded-lg border border-neutral-200 bg-white"
|
||||
>
|
||||
<div
|
||||
class="flex cursor-pointer items-start justify-between gap-3 p-4 transition-colors hover:bg-neutral-50"
|
||||
@click="toggleExpand(ticket.id)"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-xs font-bold text-primary-500">CT-{{ String(ticket.number).padStart(3, '0') }}</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:class="typeBadgeClass(ticket.type)"
|
||||
>
|
||||
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold"
|
||||
:class="statusBadgeClass(ticket.status)"
|
||||
>
|
||||
{{ $t(`clientTicket.status.${ticket.status}`) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-sm font-semibold text-neutral-900">{{ ticket.title }}</p>
|
||||
<p class="mt-0.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:swap-horizontal"
|
||||
:aria-label="$t('clientTicket.changeStatus')"
|
||||
variant="ghost"
|
||||
icon-size="18"
|
||||
@click.stop="openStatusChange(ticket)"
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:delete-outline"
|
||||
aria-label="Supprimer"
|
||||
variant="ghost"
|
||||
icon-size="18"
|
||||
@click.stop="onDelete(ticket)"
|
||||
/>
|
||||
<Icon
|
||||
:name="expandedId === ticket.id ? 'mdi:chevron-up' : 'mdi:chevron-down'"
|
||||
size="20"
|
||||
class="text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded details -->
|
||||
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-4 py-3">
|
||||
<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"
|
||||
target="_blank"
|
||||
class="text-xs text-primary-500 underline hover:text-primary-600"
|
||||
>
|
||||
{{ ticket.url }}
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="ticket.statusComment" class="mt-2 rounded-lg bg-neutral-50 p-2 text-xs text-neutral-500">
|
||||
{{ ticket.statusComment }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status change modal -->
|
||||
<Teleport v-if="statusModalOpen" to="body">
|
||||
<div class="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||
<div
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
@click="statusModalOpen = false"
|
||||
/>
|
||||
<div class="relative z-10 w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.changeStatus') }}</h3>
|
||||
<p v-if="statusTarget" class="mt-1 text-sm text-neutral-500">
|
||||
CT-{{ String(statusTarget.number).padStart(3, '0') }} — {{ statusTarget.title }}
|
||||
</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">Nouveau statut</label>
|
||||
<select
|
||||
v-model="newStatus"
|
||||
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"
|
||||
>
|
||||
<option :value="null" disabled>—</option>
|
||||
<option
|
||||
v-for="s in availableStatusTransitions"
|
||||
:key="s.value"
|
||||
:value="s.value"
|
||||
>
|
||||
{{ s.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="newStatus === 'rejected'" class="mt-4">
|
||||
<MalioInputTextArea
|
||||
v-model="statusComment"
|
||||
:label="$t('clientTicket.statusComment')"
|
||||
:size="3"
|
||||
/>
|
||||
<p v-if="rejectionError" class="mt-1 text-xs text-red-500">
|
||||
{{ $t('clientTicket.rejectionRequired') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('common.cancel')"
|
||||
button-class="w-auto px-4"
|
||||
@click="statusModalOpen = false"
|
||||
/>
|
||||
<MalioButton
|
||||
label="Confirmer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isUpdatingStatus"
|
||||
@click="confirmStatusChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ClientTicket, ClientTicketStatus } from '~/services/dto/client-ticket'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const projectId = computed(() => Number(route.params.id))
|
||||
|
||||
useHead({ title: 'Tickets client' })
|
||||
|
||||
const clientTicketService = useClientTicketService()
|
||||
const projectService = useProjectService()
|
||||
const { typeBadgeClass, statusBadgeClass, formatDate, getAvailableStatusTransitions } = useClientTicketHelpers()
|
||||
|
||||
const project = ref<Project | null>(null)
|
||||
const tickets = ref<ClientTicket[]>([])
|
||||
const isLoading = ref(true)
|
||||
const filterStatus = ref<string | null>(null)
|
||||
const expandedId = ref<number | null>(null)
|
||||
|
||||
const filteredTickets = computed(() => {
|
||||
if (!filterStatus.value) return tickets.value
|
||||
return tickets.value.filter(t => t.status === filterStatus.value)
|
||||
})
|
||||
|
||||
// Status change
|
||||
const statusModalOpen = ref(false)
|
||||
const statusTarget = ref<ClientTicket | null>(null)
|
||||
const newStatus = ref<string | null>(null)
|
||||
const statusComment = ref('')
|
||||
const rejectionError = ref(false)
|
||||
const isUpdatingStatus = ref(false)
|
||||
|
||||
const availableStatusTransitions = computed(() => {
|
||||
if (!statusTarget.value) return []
|
||||
return getAvailableStatusTransitions(statusTarget.value.status, t)
|
||||
})
|
||||
|
||||
function toggleExpand(id: number) {
|
||||
expandedId.value = expandedId.value === id ? null : id
|
||||
}
|
||||
|
||||
function openStatusChange(ticket: ClientTicket) {
|
||||
statusTarget.value = ticket
|
||||
newStatus.value = null
|
||||
statusComment.value = ''
|
||||
rejectionError.value = false
|
||||
statusModalOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmStatusChange() {
|
||||
if (!statusTarget.value || !newStatus.value) return
|
||||
|
||||
if (newStatus.value === 'rejected' && !statusComment.value.trim()) {
|
||||
rejectionError.value = true
|
||||
return
|
||||
}
|
||||
|
||||
isUpdatingStatus.value = true
|
||||
try {
|
||||
await clientTicketService.updateStatus(statusTarget.value.id, {
|
||||
status: newStatus.value as ClientTicketStatus,
|
||||
statusComment: newStatus.value === 'rejected' ? statusComment.value.trim() : null,
|
||||
})
|
||||
statusModalOpen.value = false
|
||||
await loadTickets()
|
||||
} finally {
|
||||
isUpdatingStatus.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(ticket: ClientTicket) {
|
||||
await clientTicketService.remove(ticket.id)
|
||||
await loadTickets()
|
||||
}
|
||||
|
||||
async function loadTickets() {
|
||||
tickets.value = await clientTicketService.getAll({ project: projectId.value })
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const [p, t] = await Promise.all([
|
||||
projectService.getById(projectId.value),
|
||||
clientTicketService.getAll({ project: projectId.value }),
|
||||
])
|
||||
project.value = p
|
||||
tickets.value = t
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
@@ -38,7 +38,7 @@
|
||||
:options="groupFilterOptions"
|
||||
label="Groupe"
|
||||
empty-option-label="Tous les groupes"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -47,7 +47,7 @@
|
||||
:options="tagFilterOptions"
|
||||
label="Tags"
|
||||
empty-option-label="Tous les tags"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -56,7 +56,7 @@
|
||||
:options="userFilterOptions"
|
||||
label="User"
|
||||
empty-option-label="Tous les users"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -66,7 +66,7 @@
|
||||
:options="statusFilterOptions"
|
||||
label="Status"
|
||||
empty-option-label="Tous les status"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -75,7 +75,7 @@
|
||||
:options="priorityFilterOptions"
|
||||
label="Priorité"
|
||||
empty-option-label="Toutes"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -84,7 +84,7 @@
|
||||
:options="effortFilterOptions"
|
||||
label="Effort"
|
||||
empty-option-label="Tous"
|
||||
min-width="!w-40"
|
||||
group-class="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
|
||||
479
frontend/pages/team-absences.vue
Normal file
479
frontend/pages/team-absences.vue
Normal file
@@ -0,0 +1,479 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<h1 class="text-2xl font-bold text-neutral-900">
|
||||
{{ $t("absences.teamTitle") }}
|
||||
</h1>
|
||||
|
||||
<!-- KPIs -->
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-4">
|
||||
<p class="text-sm text-neutral-500">
|
||||
{{ $t("absences.admin.kpis.pending") }}
|
||||
</p>
|
||||
<p class="mt-1 text-3xl font-bold text-amber-600">
|
||||
{{ kpis.pending }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-4">
|
||||
<p class="text-sm text-neutral-500">
|
||||
{{ $t("absences.admin.kpis.todayAbsent") }}
|
||||
</p>
|
||||
<p class="mt-1 text-3xl font-bold text-primary-500">
|
||||
{{ kpis.today }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-4">
|
||||
<p class="text-sm text-neutral-500">
|
||||
{{ $t("absences.admin.kpis.weekAbsent") }}
|
||||
</p>
|
||||
<p class="mt-1 text-3xl font-bold text-primary-500">
|
||||
{{ kpis.week }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs">
|
||||
<!-- Requests -->
|
||||
<template #requests>
|
||||
<div class="flex min-h-[30rem] flex-col gap-4 pt-10">
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<MalioSelect
|
||||
v-model="filters.status"
|
||||
:label="$t('absences.table.status')"
|
||||
:options="statusOptions"
|
||||
:empty-option-label="
|
||||
$t('absences.filters.allStatuses')
|
||||
"
|
||||
group-class="w-48"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="filters.type"
|
||||
:label="$t('absences.table.type')"
|
||||
:options="typeOptions"
|
||||
:empty-option-label="
|
||||
$t('absences.filters.allTypes')
|
||||
"
|
||||
group-class="w-48"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="filters.user"
|
||||
:label="$t('absences.table.employee')"
|
||||
:options="employeeOptions"
|
||||
:empty-option-label="
|
||||
$t('absences.filters.allEmployees')
|
||||
"
|
||||
group-class="w-48"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MalioDataTable
|
||||
:columns="requestColumns"
|
||||
:items="requestRows"
|
||||
:total-items="requestRows.length"
|
||||
:empty-message="$t('absences.noRequests')"
|
||||
@row-click="openDetail"
|
||||
>
|
||||
<template #cell-status="{ item }">
|
||||
<StatusBadge
|
||||
:label="
|
||||
statusLabel((item as RequestRow).status)
|
||||
"
|
||||
:variant="
|
||||
statusVariant((item as RequestRow).status)
|
||||
"
|
||||
:icon="statusIcon((item as RequestRow).status)"
|
||||
/>
|
||||
</template>
|
||||
<template #cell-actions="{ item }">
|
||||
<div
|
||||
v-if="(item as RequestRow).status === 'pending'"
|
||||
class="flex gap-1"
|
||||
@click.stop
|
||||
>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:check"
|
||||
:aria-label="$t('absences.review.approve')"
|
||||
button-class="!bg-green-100 !text-green-700"
|
||||
:icon-size="18"
|
||||
@click="approve(item as RequestRow)"
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:close"
|
||||
:aria-label="$t('absences.review.reject')"
|
||||
button-class="!bg-red-100 !text-red-700"
|
||||
:icon-size="18"
|
||||
@click="openReject(item as RequestRow)"
|
||||
/>
|
||||
</div>
|
||||
<span v-else class="text-neutral-300">—</span>
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Calendar -->
|
||||
<template #calendar>
|
||||
<div class="min-h-[30rem] pt-10">
|
||||
<AbsenceCalendar
|
||||
:absences="calendarAbsences"
|
||||
@range-change="loadCalendar"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Balances -->
|
||||
<template #balances>
|
||||
<div class="min-h-[30rem] pt-10">
|
||||
<MalioDataTable
|
||||
:columns="balanceColumns"
|
||||
:items="balanceRows"
|
||||
:total-items="balanceRows.length"
|
||||
:row-clickable="false"
|
||||
:empty-message="$t('absences.noBalance')"
|
||||
>
|
||||
<template #cell-actions="{ item }">
|
||||
<div class="flex justify-end">
|
||||
<MalioButton
|
||||
:label="
|
||||
$t(
|
||||
'absences.admin.balancesTable.adjust',
|
||||
)
|
||||
"
|
||||
variant="secondary"
|
||||
icon-name="mdi:pencil"
|
||||
icon-position="left"
|
||||
button-class="w-auto"
|
||||
@click="openAdjust(item as BalanceRow)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Employees -->
|
||||
<template #employees>
|
||||
<div class="min-h-[30rem] pt-10">
|
||||
<MalioDataTable
|
||||
:columns="employeeColumns"
|
||||
:items="employeeRows"
|
||||
:total-items="employeeRows.length"
|
||||
:empty-message="$t('absences.admin.employees.empty')"
|
||||
@row-click="openEmployee"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</MalioTabList>
|
||||
|
||||
<AbsenceDetailDrawer
|
||||
v-model="detailOpen"
|
||||
:request="selectedRequest"
|
||||
:can-cancel="
|
||||
selectedRequest?.status === 'pending' ||
|
||||
selectedRequest?.status === 'approved'
|
||||
"
|
||||
@cancelled="reloadRequests"
|
||||
/>
|
||||
<AbsenceRejectDrawer
|
||||
v-model="rejectOpen"
|
||||
:request="selectedRequest"
|
||||
@rejected="reloadRequests"
|
||||
/>
|
||||
<AbsenceBalanceAdjustDrawer
|
||||
v-model="adjustOpen"
|
||||
:balance="selectedBalance"
|
||||
@adjusted="loadBalances"
|
||||
/>
|
||||
<EmployeeDrawer
|
||||
v-model="employeeDrawerOpen"
|
||||
:user="selectedEmployee"
|
||||
@saved="loadEmployees"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
AbsenceBalance,
|
||||
AbsenceRequest,
|
||||
AbsenceStatus,
|
||||
AbsenceType,
|
||||
} from "~/services/dto/absence";
|
||||
import {
|
||||
useAbsenceService,
|
||||
type AbsenceRequestFilters,
|
||||
} from "~/services/absences";
|
||||
import { useAbsenceHelpers } from "~/composables/useAbsenceHelpers";
|
||||
import { useUserService } from "~/services/users";
|
||||
import type { UserData } from "~/services/dto/user-data";
|
||||
|
||||
definePageMeta({ middleware: ["admin"] });
|
||||
|
||||
type RequestRow = AbsenceRequest & {
|
||||
employeeText: string;
|
||||
typeLabelText: string;
|
||||
periodText: string;
|
||||
daysText: string;
|
||||
createdAtText: string;
|
||||
};
|
||||
type BalanceRow = AbsenceBalance & {
|
||||
employeeText: string;
|
||||
availableText: string;
|
||||
};
|
||||
type EmployeeRow = UserData & {
|
||||
contractText: string;
|
||||
cpTakenText: string;
|
||||
cpRemainingText: string;
|
||||
};
|
||||
|
||||
const { t } = useI18n();
|
||||
const service = useAbsenceService();
|
||||
const {
|
||||
statusLabel,
|
||||
statusVariant,
|
||||
statusIcon,
|
||||
formatRange,
|
||||
formatDays,
|
||||
formatDate,
|
||||
} = useAbsenceHelpers();
|
||||
|
||||
useHead({ title: t("absences.teamTitle") });
|
||||
|
||||
const activeTab = ref("requests");
|
||||
const tabs = [
|
||||
{
|
||||
key: "requests",
|
||||
label: t("absences.admin.tabs.requests"),
|
||||
icon: "mdi:format-list-bulleted",
|
||||
},
|
||||
{
|
||||
key: "calendar",
|
||||
label: t("absences.admin.tabs.calendar"),
|
||||
icon: "mdi:calendar-month",
|
||||
},
|
||||
{
|
||||
key: "balances",
|
||||
label: t("absences.admin.tabs.balances"),
|
||||
icon: "mdi:scale-balance",
|
||||
},
|
||||
{
|
||||
key: "employees",
|
||||
label: t("absences.admin.tabs.employees"),
|
||||
icon: "mdi:account-group",
|
||||
},
|
||||
];
|
||||
|
||||
const requests = ref<AbsenceRequest[]>([]);
|
||||
const balances = ref<AbsenceBalance[]>([]);
|
||||
const calendarAbsences = ref<AbsenceRequest[]>([]);
|
||||
|
||||
const employees = ref<UserData[]>([]);
|
||||
const employeeDrawerOpen = ref(false);
|
||||
const selectedEmployee = ref<UserData | null>(null);
|
||||
|
||||
const detailOpen = ref(false);
|
||||
const rejectOpen = ref(false);
|
||||
const adjustOpen = ref(false);
|
||||
const selectedRequest = ref<AbsenceRequest | null>(null);
|
||||
const selectedBalance = ref<AbsenceBalance | null>(null);
|
||||
|
||||
// Empty option of MalioSelect has value null, so filters default to null.
|
||||
const filters = reactive<{
|
||||
status: AbsenceStatus | null;
|
||||
type: AbsenceType | null;
|
||||
user: number | null;
|
||||
}>({
|
||||
status: null,
|
||||
type: null,
|
||||
user: null,
|
||||
});
|
||||
|
||||
const statusOptions = [
|
||||
{ label: t("absences.status.pending"), value: "pending" },
|
||||
{ label: t("absences.status.approved"), value: "approved" },
|
||||
{ label: t("absences.status.rejected"), value: "rejected" },
|
||||
{ label: t("absences.status.cancelled"), value: "cancelled" },
|
||||
];
|
||||
|
||||
const typeOptions = [
|
||||
{ label: t("absences.types.cp"), value: "cp" },
|
||||
{ label: t("absences.types.mariage_pacs"), value: "mariage_pacs" },
|
||||
{ label: t("absences.types.conge_parental"), value: "conge_parental" },
|
||||
{ label: t("absences.types.deces"), value: "deces" },
|
||||
{ label: t("absences.types.maladie"), value: "maladie" },
|
||||
];
|
||||
|
||||
const employeeOptions = computed(() => {
|
||||
const map = new Map<number, string>();
|
||||
for (const r of requests.value) map.set(r.user.id, r.user.username);
|
||||
for (const b of balances.value) map.set(b.user.id, b.user.username);
|
||||
return [...map.entries()].map(([value, label]) => ({ value, label }));
|
||||
});
|
||||
|
||||
const requestColumns = [
|
||||
{ key: "employeeText", label: t("absences.table.employee") },
|
||||
{ key: "typeLabelText", label: t("absences.table.type") },
|
||||
{ key: "periodText", label: t("absences.table.period") },
|
||||
{ key: "daysText", label: t("absences.table.days") },
|
||||
{ key: "status", label: t("absences.table.status") },
|
||||
{ key: "createdAtText", label: t("absences.table.requestedAt") },
|
||||
{ key: "actions", label: t("absences.table.actions") },
|
||||
];
|
||||
|
||||
const requestRows = computed<RequestRow[]>(() =>
|
||||
requests.value.map((r) => ({
|
||||
...r,
|
||||
employeeText: r.user.username,
|
||||
typeLabelText: r.label,
|
||||
periodText: formatRange(r),
|
||||
daysText: formatDays(r.countedDays),
|
||||
createdAtText: formatDate(r.createdAt),
|
||||
})),
|
||||
);
|
||||
|
||||
const balanceColumns = [
|
||||
{ key: "employeeText", label: t("absences.admin.balancesTable.employee") },
|
||||
{ key: "label", label: t("absences.admin.balancesTable.type") },
|
||||
{ key: "period", label: t("absences.admin.balancesTable.period") },
|
||||
{ key: "acquired", label: t("absences.admin.balancesTable.acquired") },
|
||||
{ key: "acquiring", label: t("absences.admin.balancesTable.acquiring") },
|
||||
{ key: "taken", label: t("absences.admin.balancesTable.taken") },
|
||||
{ key: "pending", label: t("absences.admin.balancesTable.pending") },
|
||||
{
|
||||
key: "availableText",
|
||||
label: t("absences.admin.balancesTable.available"),
|
||||
},
|
||||
{ key: "actions", label: "" },
|
||||
];
|
||||
|
||||
const balanceRows = computed<BalanceRow[]>(() =>
|
||||
balances.value.map((b) => ({
|
||||
...b,
|
||||
employeeText: b.user.username,
|
||||
availableText: formatDays(b.available),
|
||||
})),
|
||||
);
|
||||
|
||||
const employeeColumns = [
|
||||
{ key: "username", label: t("absences.admin.employees.columns.name") },
|
||||
{ key: "contractText", label: t("absences.admin.employees.columns.contract") },
|
||||
{ key: "cpTakenText", label: t("absences.admin.employees.columns.cpTaken") },
|
||||
{ key: "cpRemainingText", label: t("absences.admin.employees.columns.cpRemaining") },
|
||||
];
|
||||
|
||||
const employeeRows = computed<EmployeeRow[]>(() => {
|
||||
// Map user.id -> solde CP de la période courante.
|
||||
const cpByUser = new Map<number, AbsenceBalance>();
|
||||
for (const b of balances.value) {
|
||||
if (b.type === "cp") cpByUser.set(b.user.id, b);
|
||||
}
|
||||
const dash = t("absences.admin.employees.noContract");
|
||||
return employees.value.map((u) => {
|
||||
const cp = cpByUser.get(u.id);
|
||||
return {
|
||||
...u,
|
||||
contractText: u.contractType ?? dash,
|
||||
cpTakenText: cp ? formatDays(cp.taken) : dash,
|
||||
cpRemainingText: cp ? formatDays(cp.available) : dash,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const kpis = computed(() => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const now = new Date();
|
||||
const day = (now.getDay() + 6) % 7;
|
||||
const monday = new Date(now);
|
||||
monday.setDate(now.getDate() - day);
|
||||
const sunday = new Date(monday);
|
||||
sunday.setDate(monday.getDate() + 6);
|
||||
const mondayStr = monday.toISOString().slice(0, 10);
|
||||
const sundayStr = sunday.toISOString().slice(0, 10);
|
||||
|
||||
const approved = requests.value.filter((r) => r.status === "approved");
|
||||
const todayUsers = new Set(
|
||||
approved
|
||||
.filter(
|
||||
(r) =>
|
||||
r.startDate.slice(0, 10) <= today &&
|
||||
r.endDate.slice(0, 10) >= today,
|
||||
)
|
||||
.map((r) => r.user.id),
|
||||
);
|
||||
const weekUsers = new Set(
|
||||
approved
|
||||
.filter(
|
||||
(r) =>
|
||||
r.startDate.slice(0, 10) <= sundayStr &&
|
||||
r.endDate.slice(0, 10) >= mondayStr,
|
||||
)
|
||||
.map((r) => r.user.id),
|
||||
);
|
||||
|
||||
return {
|
||||
pending: requests.value.filter((r) => r.status === "pending").length,
|
||||
today: todayUsers.size,
|
||||
week: weekUsers.size,
|
||||
};
|
||||
});
|
||||
|
||||
function openDetail(item: Record<string, unknown>) {
|
||||
selectedRequest.value = item as RequestRow;
|
||||
detailOpen.value = true;
|
||||
}
|
||||
|
||||
function openReject(row: RequestRow) {
|
||||
selectedRequest.value = row;
|
||||
rejectOpen.value = true;
|
||||
}
|
||||
|
||||
function openAdjust(row: BalanceRow) {
|
||||
selectedBalance.value = row;
|
||||
adjustOpen.value = true;
|
||||
}
|
||||
|
||||
async function approve(row: RequestRow) {
|
||||
await service.approve(row.id);
|
||||
await reloadRequests();
|
||||
}
|
||||
|
||||
async function reloadRequests() {
|
||||
const f: AbsenceRequestFilters = {};
|
||||
if (filters.status) f.status = filters.status;
|
||||
if (filters.type) f.type = filters.type;
|
||||
if (filters.user) f.user = filters.user;
|
||||
requests.value = await service.getRequests(f);
|
||||
}
|
||||
|
||||
async function loadBalances() {
|
||||
balances.value = await service.getBalances();
|
||||
}
|
||||
|
||||
async function loadEmployees() {
|
||||
const all = await useUserService().getAll();
|
||||
employees.value = all.filter((u) => u.isEmployee);
|
||||
}
|
||||
|
||||
function openEmployee(item: Record<string, unknown>) {
|
||||
selectedEmployee.value = item as EmployeeRow;
|
||||
employeeDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
async function loadCalendar(from: string, to: string) {
|
||||
calendarAbsences.value = await service.getCalendar(from, to);
|
||||
}
|
||||
|
||||
watch(() => [filters.status, filters.type, filters.user], reloadRequests);
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([reloadRequests(), loadBalances(), loadEmployees()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* MalioTabList (lib) : aère les onglets verticalement (espace haut/bas du texte) */
|
||||
:deep([role="tab"]) {
|
||||
padding-top: 0.9rem;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
@@ -52,7 +52,7 @@
|
||||
<MalioSelect
|
||||
v-model="selectedUserId"
|
||||
:options="userOptions"
|
||||
min-width="!w-36 sm:!w-44"
|
||||
group-class="!w-36 sm:!w-44"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
label="User"
|
||||
@@ -66,7 +66,7 @@
|
||||
:options="projectOptions"
|
||||
empty-option-label="Tous"
|
||||
label="Projet"
|
||||
min-width="!w-36 sm:!w-44"
|
||||
group-class="!w-36 sm:!w-44"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
@@ -78,7 +78,7 @@
|
||||
:options="tagOptions"
|
||||
empty-option-label="Tous"
|
||||
label="Tag"
|
||||
min-width="!w-36 sm:!w-44"
|
||||
group-class="!w-36 sm:!w-44"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
|
||||
137
frontend/services/absences.ts
Normal file
137
frontend/services/absences.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type {
|
||||
AbsenceBalance,
|
||||
AbsencePolicy,
|
||||
AbsencePolicyWrite,
|
||||
AbsencePreviewPayload,
|
||||
AbsencePreviewResult,
|
||||
AbsenceRequest,
|
||||
AbsenceRequestWrite,
|
||||
AbsenceStatus,
|
||||
AbsenceType,
|
||||
} from './dto/absence'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
export type AbsenceRequestFilters = {
|
||||
status?: AbsenceStatus
|
||||
type?: AbsenceType
|
||||
year?: number
|
||||
user?: number
|
||||
}
|
||||
|
||||
export function useAbsenceService() {
|
||||
const api = useApi()
|
||||
|
||||
// --- Requests ---
|
||||
|
||||
async function getRequests(filters: AbsenceRequestFilters = {}): Promise<AbsenceRequest[]> {
|
||||
const query: Record<string, unknown> = {}
|
||||
if (filters.status) query.status = filters.status
|
||||
if (filters.type) query.type = filters.type
|
||||
if (filters.year) query.year = filters.year
|
||||
if (filters.user) query.user = `/api/users/${filters.user}`
|
||||
const data = await api.get<HydraCollection<AbsenceRequest>>('/absence_requests', query)
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function getRequest(id: number): Promise<AbsenceRequest> {
|
||||
return api.get<AbsenceRequest>(`/absence_requests/${id}`)
|
||||
}
|
||||
|
||||
async function create(payload: AbsenceRequestWrite): Promise<AbsenceRequest> {
|
||||
return api.post<AbsenceRequest>('/absence_requests', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'absences.toast.created',
|
||||
})
|
||||
}
|
||||
|
||||
async function preview(payload: AbsencePreviewPayload): Promise<AbsencePreviewResult> {
|
||||
return api.post<AbsencePreviewResult>('/absence_requests/preview', payload as Record<string, unknown>, {
|
||||
toast: false,
|
||||
})
|
||||
}
|
||||
|
||||
async function approve(id: number): Promise<AbsenceRequest> {
|
||||
return api.patch<AbsenceRequest>(`/absence_requests/${id}/approve`, {}, {
|
||||
toastSuccessKey: 'absences.toast.approved',
|
||||
})
|
||||
}
|
||||
|
||||
async function reject(id: number, rejectionReason: string): Promise<AbsenceRequest> {
|
||||
return api.patch<AbsenceRequest>(`/absence_requests/${id}/reject`, { rejectionReason }, {
|
||||
toastSuccessKey: 'absences.toast.rejected',
|
||||
})
|
||||
}
|
||||
|
||||
async function cancel(id: number): Promise<AbsenceRequest> {
|
||||
return api.patch<AbsenceRequest>(`/absence_requests/${id}/cancel`, {}, {
|
||||
toastSuccessKey: 'absences.toast.cancelled',
|
||||
})
|
||||
}
|
||||
|
||||
async function uploadJustification(id: number, file: File): Promise<AbsenceRequest> {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return api.post<AbsenceRequest>(`/absence_requests/${id}/justificatif`, form as unknown as Record<string, unknown>, {
|
||||
toastSuccessKey: 'absences.toast.justificationUploaded',
|
||||
})
|
||||
}
|
||||
|
||||
// --- Balances ---
|
||||
|
||||
async function getBalances(filters: { user?: number; period?: string; type?: AbsenceType } = {}): Promise<AbsenceBalance[]> {
|
||||
const query: Record<string, unknown> = {}
|
||||
if (filters.user) query.user = `/api/users/${filters.user}`
|
||||
if (filters.period) query.period = filters.period
|
||||
if (filters.type) query.type = filters.type
|
||||
const data = await api.get<HydraCollection<AbsenceBalance>>('/absence_balances', query)
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function adjustBalance(id: number, payload: { acquired?: number; acquiring?: number; taken?: number }): Promise<AbsenceBalance> {
|
||||
return api.patch<AbsenceBalance>(`/absence_balances/${id}`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'absences.toast.balanceAdjusted',
|
||||
})
|
||||
}
|
||||
|
||||
// --- Policies ---
|
||||
|
||||
async function getPolicies(): Promise<AbsencePolicy[]> {
|
||||
const data = await api.get<HydraCollection<AbsencePolicy>>('/absence_policies')
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function updatePolicy(id: number, payload: AbsencePolicyWrite): Promise<AbsencePolicy> {
|
||||
return api.patch<AbsencePolicy>(`/absence_policies/${id}`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'absences.toast.policyUpdated',
|
||||
})
|
||||
}
|
||||
|
||||
// --- Admin calendar ---
|
||||
|
||||
async function getCalendar(from: string, to: string): Promise<AbsenceRequest[]> {
|
||||
return api.get<AbsenceRequest[]>('/admin/absences/calendar', { from, to })
|
||||
}
|
||||
|
||||
// --- Public holidays (computed server-side) ---
|
||||
|
||||
async function getPublicHolidays(from: string, to: string): Promise<Record<string, string>> {
|
||||
return api.get<Record<string, string>>('/public_holidays', { from, to }, { toast: false })
|
||||
}
|
||||
|
||||
return {
|
||||
getRequests,
|
||||
getRequest,
|
||||
create,
|
||||
preview,
|
||||
approve,
|
||||
reject,
|
||||
cancel,
|
||||
uploadJustification,
|
||||
getBalances,
|
||||
adjustBalance,
|
||||
getPolicies,
|
||||
updatePolicy,
|
||||
getCalendar,
|
||||
getPublicHolidays,
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { ClientTicket, ClientTicketWrite, ClientTicketStatusUpdate } from './dto/client-ticket'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
export function useClientTicketService() {
|
||||
const api = useApi()
|
||||
|
||||
async function getAll(params?: { project?: number; status?: string; submittedBy?: number }): Promise<ClientTicket[]> {
|
||||
const query: Record<string, unknown> = {}
|
||||
if (params?.project) query.project = `/api/projects/${params.project}`
|
||||
if (params?.status) query.status = params.status
|
||||
if (params?.submittedBy) query.submittedBy = `/api/users/${params.submittedBy}`
|
||||
const data = await api.get<HydraCollection<ClientTicket>>('/client_tickets', query)
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function getById(id: number): Promise<ClientTicket> {
|
||||
return api.get<ClientTicket>(`/client_tickets/${id}`)
|
||||
}
|
||||
|
||||
async function create(payload: ClientTicketWrite): Promise<ClientTicket> {
|
||||
return api.post<ClientTicket>('/client_tickets', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'portal.ticketCreated',
|
||||
})
|
||||
}
|
||||
|
||||
async function updateStatus(id: number, payload: ClientTicketStatusUpdate): Promise<ClientTicket> {
|
||||
return api.patch<ClientTicket>(`/client_tickets/${id}`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'clientTicket.statusUpdated',
|
||||
})
|
||||
}
|
||||
|
||||
async function update(id: number, data: Partial<ClientTicketWrite>): Promise<ClientTicket> {
|
||||
return api.patch<ClientTicket>(`/client_tickets/${id}`, data as Record<string, unknown>, {
|
||||
toastSuccessKey: 'clientTicket.updated',
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/client_tickets/${id}`, {}, {
|
||||
toastSuccessKey: 'clientTicket.deleted',
|
||||
})
|
||||
}
|
||||
|
||||
return { getAll, getById, create, update, updateStatus, remove }
|
||||
}
|
||||
93
frontend/services/dto/absence.ts
Normal file
93
frontend/services/dto/absence.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
export type AbsenceType = 'cp' | 'mariage_pacs' | 'naissance' | 'conge_parental' | 'deces' | 'maladie'
|
||||
export type AbsenceStatus = 'pending' | 'approved' | 'rejected' | 'cancelled'
|
||||
export type HalfDay = 'matin' | 'apres_midi'
|
||||
|
||||
export type AbsenceUserRef = {
|
||||
'@id'?: string
|
||||
id: number
|
||||
username: string
|
||||
avatarUrl: string | null
|
||||
}
|
||||
|
||||
export type AbsenceRequest = {
|
||||
'@id'?: string
|
||||
id: number
|
||||
user: AbsenceUserRef
|
||||
type: AbsenceType
|
||||
label: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
startHalfDay: HalfDay | null
|
||||
endHalfDay: HalfDay | null
|
||||
countedDays: number
|
||||
reason: string | null
|
||||
justificationFileName: string | null
|
||||
justificationUrl: string | null
|
||||
status: AbsenceStatus
|
||||
rejectionReason: string | null
|
||||
createdAt: string
|
||||
reviewedAt: string | null
|
||||
reviewedBy: AbsenceUserRef | null
|
||||
}
|
||||
|
||||
export type AbsenceRequestWrite = {
|
||||
type: AbsenceType
|
||||
startDate: string
|
||||
endDate: string
|
||||
startHalfDay?: HalfDay | null
|
||||
endHalfDay?: HalfDay | null
|
||||
reason?: string | null
|
||||
}
|
||||
|
||||
export type AbsenceBalance = {
|
||||
'@id'?: string
|
||||
id: number
|
||||
user: AbsenceUserRef
|
||||
type: AbsenceType
|
||||
label: string
|
||||
period: string
|
||||
acquired: number
|
||||
acquiring: number
|
||||
acquiredTotal: number
|
||||
taken: number
|
||||
pending: number
|
||||
available: number
|
||||
}
|
||||
|
||||
export type AbsencePolicy = {
|
||||
'@id'?: string
|
||||
id: number
|
||||
type: AbsenceType
|
||||
label: string
|
||||
daysPerYear: number | null
|
||||
daysPerEvent: number | null
|
||||
justificationRequired: boolean
|
||||
noticeDays: number
|
||||
countWorkingDaysOnly: boolean
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export type AbsencePolicyWrite = {
|
||||
daysPerYear?: number | null
|
||||
daysPerEvent?: number | null
|
||||
justificationRequired?: boolean
|
||||
noticeDays?: number
|
||||
countWorkingDaysOnly?: boolean
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
export type AbsencePreviewPayload = {
|
||||
type: AbsenceType
|
||||
startDate: string
|
||||
endDate: string
|
||||
startHalfDay?: HalfDay | null
|
||||
endHalfDay?: HalfDay | null
|
||||
}
|
||||
|
||||
export type AbsencePreviewResult = {
|
||||
countedDays: number
|
||||
period: string | null
|
||||
available: number | null
|
||||
projectedAvailable: number | null
|
||||
justificationRequired: boolean
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { TaskDocument } from './task-document'
|
||||
|
||||
export type ClientTicketType = 'bug' | 'improvement' | 'other'
|
||||
export type ClientTicketStatus = 'new' | 'in_progress' | 'done' | 'rejected'
|
||||
|
||||
export type ClientTicket = {
|
||||
'@id'?: string
|
||||
id: number
|
||||
number: number
|
||||
type: ClientTicketType
|
||||
title: string
|
||||
description: string
|
||||
url: string | null
|
||||
status: ClientTicketStatus
|
||||
statusComment: string | null
|
||||
project: string
|
||||
submittedBy: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
documents?: TaskDocument[]
|
||||
}
|
||||
|
||||
export type ClientTicketWrite = {
|
||||
type: ClientTicketType
|
||||
title: string
|
||||
description: string
|
||||
url?: string | null
|
||||
project: string
|
||||
}
|
||||
|
||||
export type ClientTicketStatusUpdate = {
|
||||
status: ClientTicketStatus
|
||||
statusComment?: string | null
|
||||
}
|
||||
@@ -107,7 +107,8 @@ export type MailMessageFlagInput = {
|
||||
export type MailCreateTaskInput = {
|
||||
projectId: number
|
||||
taskGroupId?: number | null
|
||||
priority?: string | null
|
||||
assigneeId?: number
|
||||
statusId?: number
|
||||
}
|
||||
|
||||
// Input : lier une tâche existante à un mail
|
||||
|
||||
@@ -7,7 +7,6 @@ export type Notification = {
|
||||
type: NotificationType
|
||||
title: string
|
||||
message: string
|
||||
relatedTicket: string | null
|
||||
isRead: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
@@ -23,13 +23,6 @@ export type Task = {
|
||||
tags: TaskTag[]
|
||||
documents: TaskDocument[]
|
||||
archived: boolean
|
||||
clientTicket: {
|
||||
id: number
|
||||
number: number
|
||||
type: string
|
||||
status: string
|
||||
title: string
|
||||
} | null
|
||||
scheduledStart: string | null
|
||||
scheduledEnd: string | null
|
||||
deadline: string | null
|
||||
@@ -61,7 +54,6 @@ export type TaskWrite = {
|
||||
project: string
|
||||
tags: string[]
|
||||
archived?: boolean
|
||||
clientTicket?: string | null
|
||||
scheduledStart?: string | null
|
||||
scheduledEnd?: string | null
|
||||
deadline?: string | null
|
||||
|
||||
@@ -1,20 +1,34 @@
|
||||
import type { Project } from './project'
|
||||
export type ContractType = 'CDI' | 'CDD' | 'STAGE' | 'ALTERNANCE' | 'AUTRE'
|
||||
|
||||
export type UserData = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
username: string
|
||||
roles: string[]
|
||||
client?: { id: number; name: string } | null
|
||||
allowedProjects?: Project[]
|
||||
avatarUrl?: string | null
|
||||
apiToken?: string | null
|
||||
// HR / absence management
|
||||
isEmployee?: boolean
|
||||
hireDate?: string | null
|
||||
endDate?: string | null
|
||||
contractType?: ContractType | null
|
||||
workTimeRatio?: number
|
||||
annualLeaveDays?: number
|
||||
referencePeriodStart?: string
|
||||
initialLeaveBalance?: number
|
||||
}
|
||||
|
||||
export type UserWrite = {
|
||||
username: string
|
||||
plainPassword?: string
|
||||
roles: string[]
|
||||
client?: string | null
|
||||
allowedProjects?: string[]
|
||||
// HR / absence management
|
||||
isEmployee?: boolean
|
||||
hireDate?: string | null
|
||||
endDate?: string | null
|
||||
contractType?: ContractType | null
|
||||
workTimeRatio?: number
|
||||
annualLeaveDays?: number
|
||||
referencePeriodStart?: string
|
||||
initialLeaveBalance?: number
|
||||
}
|
||||
|
||||
@@ -10,6 +10,25 @@ export const STATUS_CATEGORY_LABEL: Record<StatusCategory, string> = {
|
||||
done: 'Terminé',
|
||||
}
|
||||
|
||||
/** Palette canonique des catégories (couleurs « classiques »), indépendante des workflows. */
|
||||
export const STATUS_CATEGORY_COLOR: Record<StatusCategory, string> = {
|
||||
todo: '#222783',
|
||||
in_progress: '#4A90D9',
|
||||
blocked: '#C62828',
|
||||
review: '#FF8F00',
|
||||
done: '#26A69A',
|
||||
}
|
||||
|
||||
/** Renvoie '#1f2937' (foncé) ou '#ffffff' (blanc) selon la luminance du fond, pour rester lisible. */
|
||||
export function contrastText(hex: string): string {
|
||||
const c = hex.replace('#', '')
|
||||
const r = parseInt(c.slice(0, 2), 16)
|
||||
const g = parseInt(c.slice(2, 4), 16)
|
||||
const b = parseInt(c.slice(4, 6), 16)
|
||||
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
return lum > 0.6 ? '#1f2937' : '#ffffff'
|
||||
}
|
||||
|
||||
export type Workflow = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
|
||||
@@ -31,17 +31,6 @@ export function useTaskDocumentService() {
|
||||
return uploadWithRelation('task', `/api/tasks/${taskId}`, file)
|
||||
}
|
||||
|
||||
async function uploadForTicket(clientTicketId: number, file: File): Promise<TaskDocument> {
|
||||
return uploadWithRelation('clientTicket', `/api/client_tickets/${clientTicketId}`, file)
|
||||
}
|
||||
|
||||
async function getByTicket(clientTicketId: number): Promise<TaskDocument[]> {
|
||||
const data = await api.get<HydraCollection<TaskDocument>>('/task_documents', {
|
||||
clientTicket: `/api/client_tickets/${clientTicketId}`,
|
||||
})
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/task_documents/${id}`, {}, {
|
||||
toastSuccessKey: 'taskDocuments.deleted',
|
||||
@@ -52,5 +41,5 @@ export function useTaskDocumentService() {
|
||||
return `${baseURL}/task_documents/${id}/download`
|
||||
}
|
||||
|
||||
return { getByTask, upload, uploadForTicket, getByTicket, remove, getDownloadUrl }
|
||||
return { getByTask, upload, remove, getDownloadUrl }
|
||||
}
|
||||
|
||||
@@ -15,6 +15,13 @@ JWT_COOKIE_SAMESITE=lax
|
||||
JWT_TOKEN_TTL=86400
|
||||
JWT_COOKIE_TTL=86400
|
||||
|
||||
# Mail (intégration IMAP/SMTP)
|
||||
# Clé hex 32 bytes chiffrant les credentials mail stockés en base.
|
||||
# Générer : php -r "echo bin2hex(random_bytes(32));" — doit rester STABLE.
|
||||
ENCRYPTION_KEY=change-me
|
||||
# Store de verrous Symfony pour la sync mail (anti-chevauchement du cron).
|
||||
LOCK_DSN=flock
|
||||
|
||||
# CORS
|
||||
CORS_ALLOW_ORIGIN='^https?://project\.malio-dev\.fr$'
|
||||
|
||||
|
||||
3
makefile
3
makefile
@@ -49,6 +49,9 @@ composer-install:
|
||||
|
||||
build-nuxtJS:
|
||||
# $(EXEC_PHP) cp -n frontend/.env.dist frontend/.env.local
|
||||
# Nettoie dist en root au cas où un ancien build l'aurait laissé en root:root
|
||||
# (sinon le `rm -rf dist` de `build:dist`, exécuté en www-data, échoue avec "Permission denied")
|
||||
-$(EXEC_PHP_ROOT) rm -rf frontend/dist
|
||||
$(EXEC_PHP) sh -lc "cd frontend && npm install && npm run build:dist"
|
||||
|
||||
dev-nuxt:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user