Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d55a088e41 | |||
| 95b192858b | |||
| 6fc6eee5b9 | |||
| 7fe427d676 | |||
| 617d70a754 | |||
| a7bf3101c5 | |||
| d68e3d42f3 | |||
| 1dd7053ebd | |||
| e59c5c510a | |||
| 6d95f9e782 | |||
| c766e76624 | |||
| 267cea76da | |||
| 6938616064 | |||
| 386242c84d | |||
| 41a98f93ee | |||
| aad949c10c | |||
| ad029f5c7d | |||
| 99626b89da |
@@ -91,6 +91,20 @@ ENCRYPTION_KEY=change_me_in_env_local
|
|||||||
# POSTGRES_PORT=5435
|
# POSTGRES_PORT=5435
|
||||||
# XDEBUG_CLIENT_HOST=host.docker.internal
|
# XDEBUG_CLIENT_HOST=host.docker.internal
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Error tracking — GlitchTip (compatible SDK Sentry)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
# DSN du projet GlitchTip "lesstime-api" (BACKEND, runtime).
|
||||||
|
# Actif uniquement en prod (bundle prod-only). Vide/absent => Sentry inerte.
|
||||||
|
# A definir dans infra/prod/.env (pas en dev). Ex : http://<cle>@glitchtip.interne:<port>/<id>
|
||||||
|
# SENTRY_DSN=
|
||||||
|
|
||||||
|
# NB : le DSN FRONT (lesstime-front) et l'upload des source maps sont fournis
|
||||||
|
# au BUILD de l'image, pas au runtime. Voir infra/prod/Dockerfile (ARG) et la
|
||||||
|
# CI .gitea/workflows/build-docker.yml (build-args depuis les secrets Gitea) :
|
||||||
|
# NUXT_PUBLIC_SENTRY_DSN, SENTRY_URL, SENTRY_ORG, SENTRY_PROJECT, SENTRY_AUTH_TOKEN
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# Frontend (frontend/.env)
|
# Frontend (frontend/.env)
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker build \
|
docker build \
|
||||||
-f infra/prod/Dockerfile \
|
-f infra/prod/Dockerfile \
|
||||||
|
--build-arg NUXT_PUBLIC_SENTRY_DSN="${{ secrets.SENTRY_FRONT_DSN }}" \
|
||||||
|
--build-arg SENTRY_URL="${{ secrets.SENTRY_URL }}" \
|
||||||
|
--build-arg SENTRY_ORG="${{ secrets.SENTRY_ORG }}" \
|
||||||
|
--build-arg SENTRY_PROJECT="${{ secrets.SENTRY_FRONT_PROJECT }}" \
|
||||||
|
--build-arg SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}" \
|
||||||
-t gitea.malio.fr/malio-dev/lesstime:${{ gitea.ref_name }} \
|
-t gitea.malio.fr/malio-dev/lesstime:${{ gitea.ref_name }} \
|
||||||
-t gitea.malio.fr/malio-dev/lesstime:latest \
|
-t gitea.malio.fr/malio-dev/lesstime:latest \
|
||||||
.
|
.
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"lesstime": {
|
|
||||||
"type": "http",
|
|
||||||
"url": "http://project.malio-dev.fr/_mcp",
|
|
||||||
"headers": {
|
|
||||||
"Authorization": "Bearer 7e8b410a5b79b5c0432951dcee3a3a81e0731e86d9f70d8784ec079a2b759c64"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"lesstime-local": {
|
"lesstime-local": {
|
||||||
"command": "docker",
|
"command": "docker",
|
||||||
"args": [
|
"args": [
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ Application de gestion de projet avec suivi du temps et portail client.
|
|||||||
- Intégration Gitea (issues, repos)
|
- Intégration Gitea (issues, repos)
|
||||||
- Intégration Mail IMAP (boîte partagée OVH, voir `docs/mail-integration.md`)
|
- Intégration Mail IMAP (boîte partagée OVH, voir `docs/mail-integration.md`)
|
||||||
- Serveur MCP pour assistants IA
|
- Serveur MCP pour assistants IA
|
||||||
|
- Error tracking centralisé back + front (GlitchTip / SDK Sentry, prod uniquement — voir « Error tracking »)
|
||||||
- Multi-langue (i18n)
|
- Multi-langue (i18n)
|
||||||
|
|
||||||
## Prérequis
|
## Prérequis
|
||||||
@@ -74,6 +75,7 @@ peuvent être surchargées dans `.env.local` (jamais committé). En prod, elles
|
|||||||
| `CORS_ALLOW_ORIGIN` | Origines CORS autorisées | localhost | ✅ (domaine prod) |
|
| `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 |
|
| **`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 |
|
| **`LOCK_DSN`** | **Store de verrous Symfony pour la sync mail (anti-chevauchement)** | `flock` | `flock` suffit |
|
||||||
|
| `SENTRY_DSN` | Error tracking **backend** → GlitchTip (projet `lesstime-api`) | _(vide)_ | ⚪ optionnel — active le tracking (voir « Error tracking ») |
|
||||||
|
|
||||||
> **Messagerie** : `ENCRYPTION_KEY` et `LOCK_DSN` sont introduites par l'intégration mail.
|
> **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`.
|
> Détails de config et cron de synchronisation : `docs/mail-integration.md` et `docs/mail-cron-setup.md`.
|
||||||
@@ -255,6 +257,131 @@ Le script active la maintenance, pull l'image, redémarre le container, lance le
|
|||||||
et vide le cache. Guide complet (première installation, BDD, Nginx, JWT, rollback) :
|
et vide le cache. Guide complet (première installation, BDD, Nginx, JWT, rollback) :
|
||||||
**`doc/deployment-docker.md`**.
|
**`doc/deployment-docker.md`**.
|
||||||
|
|
||||||
|
## Error tracking (GlitchTip)
|
||||||
|
|
||||||
|
Les erreurs **backend** et **frontend** sont remontées vers **GlitchTip** (instance auto-hébergée
|
||||||
|
interne, compatible SDK Sentry) qui les **groupe par projet** et compte les occurrences. Activé
|
||||||
|
**uniquement en prod** : en dev, sans DSN, le SDK est inerte (zéro impact). Ticket de référence :
|
||||||
|
INFRA #146.
|
||||||
|
|
||||||
|
### Pourquoi back et front se configurent différemment
|
||||||
|
|
||||||
|
| | Backend (Symfony) | Frontend (Nuxt SPA) |
|
||||||
|
|---|---|---|
|
||||||
|
| Nature | process PHP qui tourne en continu | fichiers JS/HTML **statiques** (`nuxt generate`) |
|
||||||
|
| Quand le DSN est lu | au **runtime** | **figé au build** (baké dans le JS) |
|
||||||
|
| Où mettre le DSN | `infra/prod/.env` (runtime) | **secrets Gitea** → build-args de la CI |
|
||||||
|
|
||||||
|
> Les erreurs partent **toujours vers GlitchTip**, jamais vers la CI. La CI ne sert qu'à *écrire*
|
||||||
|
> le DSN front dans le bundle au moment du build (il n'y a aucun process front en prod qui
|
||||||
|
> pourrait lire une variable d'environnement).
|
||||||
|
|
||||||
|
### Variables
|
||||||
|
|
||||||
|
**Backend — fichier `infra/prod/.env` du serveur** (chargé via `env_file`) :
|
||||||
|
```env
|
||||||
|
SENTRY_DSN=http://<clé>@glitchtip.interne:<port>/<id-projet-api>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend — secrets Gitea** (repo → Settings → Actions → Secrets), consommés par
|
||||||
|
`.gitea/workflows/build-docker.yml` :
|
||||||
|
|
||||||
|
| Secret Gitea | Rôle |
|
||||||
|
|---|---|
|
||||||
|
| `SENTRY_FRONT_DSN` | DSN du projet `lesstime-front` (public, baké dans le JS) |
|
||||||
|
| `SENTRY_URL` | URL de l'instance GlitchTip |
|
||||||
|
| `SENTRY_ORG` | slug de l'organisation GlitchTip |
|
||||||
|
| `SENTRY_FRONT_PROJECT` | slug du projet front |
|
||||||
|
| `SENTRY_AUTH_TOKEN` | token d'upload des **source maps** (vrai secret) |
|
||||||
|
|
||||||
|
> Sans source maps, seul `SENTRY_FRONT_DSN` est requis (les stacktraces front seront sur du JS
|
||||||
|
> minifié). Le build n'échoue pas si les autres secrets sont absents.
|
||||||
|
|
||||||
|
### Fichiers concernés
|
||||||
|
|
||||||
|
| Fichier | Rôle |
|
||||||
|
|---|---|
|
||||||
|
| `config/packages/sentry.yaml` | conf backend (prod-only, exceptions, 4xx ignorés, release = `app.version`) |
|
||||||
|
| `config/bundles.php` | `SentryBundle` enregistré `['prod' => true]` |
|
||||||
|
| `frontend/nuxt.config.ts` | module Sentry chargé **uniquement si DSN présent** + upload source maps |
|
||||||
|
| `frontend/sentry.client.config.ts` | init du SDK client (no-op si DSN vide) |
|
||||||
|
| `infra/prod/Dockerfile` | build-args front (`NUXT_PUBLIC_SENTRY_DSN`, `SENTRY_*`) |
|
||||||
|
| `.gitea/workflows/build-docker.yml` | injection des secrets Gitea en build-args |
|
||||||
|
|
||||||
|
### Activation (résumé)
|
||||||
|
|
||||||
|
1. Dans GlitchTip : créer les projets `lesstime-api` et `lesstime-front`, récupérer les 2 DSN
|
||||||
|
(+ un auth token pour les source maps).
|
||||||
|
2. Backend : ajouter `SENTRY_DSN` dans `infra/prod/.env` du serveur.
|
||||||
|
3. Frontend : ajouter les secrets Gitea ci-dessus.
|
||||||
|
4. Tagger une version (`v*`) → la CI build l'image avec le DSN front baké → `deploy.sh`.
|
||||||
|
|
||||||
|
### Certificat HTTPS interne (CA auto-signée)
|
||||||
|
|
||||||
|
GlitchTip est servi en **HTTPS** sur `https://logs.malio-dev.fr` (nginx devant), avec un certificat
|
||||||
|
**auto-signé** par une **CA interne** (« MALIO-DEV Local Root CA », cert serveur `*.malio-dev.fr`).
|
||||||
|
`malio-dev.fr` est un **domaine interne uniquement** (DNS local, pas de résolution publique).
|
||||||
|
|
||||||
|
> **Pourquoi pas Let's Encrypt ?** Une CA publique doit valider le domaine via Internet (challenge
|
||||||
|
> HTTP ou DNS public). Comme `malio-dev.fr` n'existe qu'en interne, aucune validation n'est
|
||||||
|
> possible → on reste sur la CA interne, qu'il faut faire **approuver partout** où la connexion TLS
|
||||||
|
> est établie. Tant que la CA n'est pas approuvée, **rien ne remonte** : le backend logue
|
||||||
|
> « Message not sent » (SDK Sentry) et le navigateur affiche « connexion non sécurisée » (le front
|
||||||
|
> n'envoie rien).
|
||||||
|
|
||||||
|
**Qui doit faire confiance à la CA ?** La connexion à `logs.malio-dev.fr` part de deux endroits
|
||||||
|
différents, donc deux fixes distincts :
|
||||||
|
|
||||||
|
| Émetteur des erreurs | Qui établit le TLS | Où approuver la CA |
|
||||||
|
|---|---|---|
|
||||||
|
| Backend (Symfony) | le **container PHP** | CA bakée dans l'**image Docker** (ci-dessous) |
|
||||||
|
| Frontend (SPA) | le **navigateur du poste** | CA poussée sur les **postes via GPO** (ci-dessous) |
|
||||||
|
|
||||||
|
#### Fix backend — CA bakée dans l'image
|
||||||
|
|
||||||
|
Le certificat **public** de la root CA est committé dans le repo (`infra/prod/malio-dev-root-ca.crt`,
|
||||||
|
aucune clé privée) et installé dans le trust store du container au build (`infra/prod/Dockerfile`,
|
||||||
|
stage production — `ca-certificates` est déjà installé) :
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
COPY infra/prod/malio-dev-root-ca.crt /usr/local/share/ca-certificates/malio-dev-root-ca.crt
|
||||||
|
RUN update-ca-certificates
|
||||||
|
```
|
||||||
|
|
||||||
|
Le container fait alors confiance à tout `*.malio-dev.fr` interne et le SDK Sentry backend peut
|
||||||
|
envoyer. Vérification :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --cacert infra/prod/malio-dev-root-ca.crt https://logs.malio-dev.fr/api/1/store/ # → HTTP 200
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Fix postes — CA poussée par GPO (Active Directory)
|
||||||
|
|
||||||
|
Le front est une SPA : c'est le **navigateur de l'utilisateur** qui contacte `logs.malio-dev.fr`,
|
||||||
|
donc c'est le **poste** qui doit faire confiance à la CA (la CA de l'image ne sert qu'au backend).
|
||||||
|
Sur le domaine Active Directory, on pousse la CA **une seule fois via GPO** plutôt que poste par poste :
|
||||||
|
|
||||||
|
1. Contrôleur de domaine → **Group Policy Management** → éditer une GPO.
|
||||||
|
2. `Configuration ordinateur → Stratégies → Paramètres Windows → Paramètres de sécurité → Stratégies
|
||||||
|
de clé publique → Autorités de certification racines de confiance`.
|
||||||
|
3. Clic droit → **Importer** → sélectionner `rootCA.crt` (« MALIO-DEV Local Root CA »).
|
||||||
|
4. Sur les postes : `gpupdate /force` (ou attendre le rafraîchissement), puis **redémarrer le navigateur**.
|
||||||
|
|
||||||
|
- Chrome / Edge utilisent le magasin Windows → confiance automatique.
|
||||||
|
- ⚠️ **Firefox** a son propre magasin : activer `security.enterprise_roots.enabled = true`
|
||||||
|
(`about:config` ou via policy) pour qu'il lise le magasin Windows.
|
||||||
|
|
||||||
|
> **Validation poste** : ouvrir `https://logs.malio-dev.fr` → cadenas vert sans avertissement = CA
|
||||||
|
> approuvée = le front peut envoyer.
|
||||||
|
|
||||||
|
#### Renouvellement / changement de CA
|
||||||
|
|
||||||
|
Si la CA interne change (rotation, expiration) :
|
||||||
|
|
||||||
|
1. Remplacer `infra/prod/malio-dev-root-ca.crt` par le nouveau certificat public, commit + **rebuild
|
||||||
|
de l'image** (re-tag `v*`) pour le backend.
|
||||||
|
2. **Re-pousser** la nouvelle CA via GPO (étapes ci-dessus) pour les postes.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
Propriétaire — Tous droits réservés.
|
Propriétaire — Tous droits réservés.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"phpoffice/phpspreadsheet": "^5.5",
|
"phpoffice/phpspreadsheet": "^5.5",
|
||||||
"phpstan/phpdoc-parser": "^2.3",
|
"phpstan/phpdoc-parser": "^2.3",
|
||||||
"sabre/vobject": "^4.5",
|
"sabre/vobject": "^4.5",
|
||||||
|
"sentry/sentry-symfony": "^5.10",
|
||||||
"symfony/asset": "8.0.*",
|
"symfony/asset": "8.0.*",
|
||||||
"symfony/console": "8.0.*",
|
"symfony/console": "8.0.*",
|
||||||
"symfony/doctrine-messenger": "^8.0",
|
"symfony/doctrine-messenger": "^8.0",
|
||||||
|
|||||||
Generated
+419
-1
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "eee87b9c0011fb88523cb5aea0de29ba",
|
"content-hash": "106755bef51fd069316cd7f3a7e1a0b6",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "api-platform/doctrine-common",
|
"name": "api-platform/doctrine-common",
|
||||||
@@ -2508,6 +2508,125 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-02-08T16:21:46+00:00"
|
"time": "2026-02-08T16:21:46+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "guzzlehttp/psr7",
|
||||||
|
"version": "2.12.3",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/guzzle/psr7.git",
|
||||||
|
"reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/guzzle/psr7/zipball/7ec62dc3f44aa218487dbed81a9bf9bc647be55d",
|
||||||
|
"reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.2.5 || ^8.0",
|
||||||
|
"psr/http-factory": "^1.0",
|
||||||
|
"psr/http-message": "^1.1 || ^2.0",
|
||||||
|
"ralouphie/getallheaders": "^3.0",
|
||||||
|
"symfony/deprecation-contracts": "^2.5 || ^3.0",
|
||||||
|
"symfony/polyfill-php80": "^1.25"
|
||||||
|
},
|
||||||
|
"provide": {
|
||||||
|
"psr/http-factory-implementation": "1.0",
|
||||||
|
"psr/http-message-implementation": "1.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"bamarni/composer-bin-plugin": "^1.8.2",
|
||||||
|
"http-interop/http-factory-tests": "1.1.0",
|
||||||
|
"jshttp/mime-db": "1.54.0.1",
|
||||||
|
"phpunit/phpunit": "^8.5.52 || ^9.6.34"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"bamarni-bin": {
|
||||||
|
"bin-links": true,
|
||||||
|
"forward-command": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"GuzzleHttp\\Psr7\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Graham Campbell",
|
||||||
|
"email": "hello@gjcampbell.co.uk",
|
||||||
|
"homepage": "https://github.com/GrahamCampbell"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Michael Dowling",
|
||||||
|
"email": "mtdowling@gmail.com",
|
||||||
|
"homepage": "https://github.com/mtdowling"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "George Mponos",
|
||||||
|
"email": "gmponos@gmail.com",
|
||||||
|
"homepage": "https://github.com/gmponos"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Tobias Nyholm",
|
||||||
|
"email": "tobias.nyholm@gmail.com",
|
||||||
|
"homepage": "https://github.com/Nyholm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Márk Sági-Kazár",
|
||||||
|
"email": "mark.sagikazar@gmail.com",
|
||||||
|
"homepage": "https://github.com/sagikazarmark"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Tobias Schultze",
|
||||||
|
"email": "webmaster@tubo-world.de",
|
||||||
|
"homepage": "https://github.com/Tobion"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Márk Sági-Kazár",
|
||||||
|
"email": "mark.sagikazar@gmail.com",
|
||||||
|
"homepage": "https://sagikazarmark.hu"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PSR-7 message implementation that also provides common utility methods",
|
||||||
|
"keywords": [
|
||||||
|
"http",
|
||||||
|
"message",
|
||||||
|
"psr-7",
|
||||||
|
"request",
|
||||||
|
"response",
|
||||||
|
"stream",
|
||||||
|
"uri",
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/guzzle/psr7/issues",
|
||||||
|
"source": "https://github.com/guzzle/psr7/tree/2.12.3"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/GrahamCampbell",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/Nyholm",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-06-23T15:21:08+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "icewind/smb",
|
"name": "icewind/smb",
|
||||||
"version": "3.8.1",
|
"version": "3.8.1",
|
||||||
@@ -2960,6 +3079,66 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-05-04T12:34:54+00:00"
|
"time": "2026-05-04T12:34:54+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "jean85/pretty-package-versions",
|
||||||
|
"version": "2.1.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/Jean85/pretty-package-versions.git",
|
||||||
|
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a",
|
||||||
|
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"composer-runtime-api": "^2.1.0",
|
||||||
|
"php": "^7.4|^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.2",
|
||||||
|
"jean85/composer-provided-replaced-stub-package": "^1.0",
|
||||||
|
"phpstan/phpstan": "^2.0",
|
||||||
|
"phpunit/phpunit": "^7.5|^8.5|^9.6",
|
||||||
|
"rector/rector": "^2.0",
|
||||||
|
"vimeo/psalm": "^4.3 || ^5.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "1.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Jean85\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Alessandro Lai",
|
||||||
|
"email": "alessandro.lai85@gmail.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "A library to get pretty versions strings of installed dependencies",
|
||||||
|
"keywords": [
|
||||||
|
"composer",
|
||||||
|
"package",
|
||||||
|
"release",
|
||||||
|
"versions"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/Jean85/pretty-package-versions/issues",
|
||||||
|
"source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1"
|
||||||
|
},
|
||||||
|
"time": "2025-03-19T14:43:43+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "lcobucci/jwt",
|
"name": "lcobucci/jwt",
|
||||||
"version": "5.6.0",
|
"version": "5.6.0",
|
||||||
@@ -4939,6 +5118,50 @@
|
|||||||
},
|
},
|
||||||
"time": "2021-10-29T13:26:27+00:00"
|
"time": "2021-10-29T13:26:27+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "ralouphie/getallheaders",
|
||||||
|
"version": "3.0.3",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/ralouphie/getallheaders.git",
|
||||||
|
"reference": "120b605dfeb996808c31b6477290a714d356e822"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822",
|
||||||
|
"reference": "120b605dfeb996808c31b6477290a714d356e822",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=5.6"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"php-coveralls/php-coveralls": "^2.1",
|
||||||
|
"phpunit/phpunit": "^5 || ^6.5"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"src/getallheaders.php"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Ralph Khattar",
|
||||||
|
"email": "ralph.khattar@gmail.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "A polyfill for getallheaders.",
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/ralouphie/getallheaders/issues",
|
||||||
|
"source": "https://github.com/ralouphie/getallheaders/tree/develop"
|
||||||
|
},
|
||||||
|
"time": "2019-03-08T08:55:37+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "sabre/uri",
|
"name": "sabre/uri",
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
@@ -5172,6 +5395,201 @@
|
|||||||
},
|
},
|
||||||
"time": "2024-09-06T08:00:55+00:00"
|
"time": "2024-09-06T08:00:55+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "sentry/sentry",
|
||||||
|
"version": "4.28.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/getsentry/sentry-php.git",
|
||||||
|
"reference": "662cb7a01a342a7f33780fc955ff4a028d8b785a"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/getsentry/sentry-php/zipball/662cb7a01a342a7f33780fc955ff4a028d8b785a",
|
||||||
|
"reference": "662cb7a01a342a7f33780fc955ff4a028d8b785a",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-curl": "*",
|
||||||
|
"ext-json": "*",
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"guzzlehttp/psr7": "^1.8.4|^2.1.1",
|
||||||
|
"jean85/pretty-package-versions": "^1.5|^2.0.4",
|
||||||
|
"php": "^7.2|^8.0",
|
||||||
|
"psr/log": "^1.0|^2.0|^3.0",
|
||||||
|
"symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0|^8.0"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"raven/raven": "*"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"carthage-software/mago": "1.30.0",
|
||||||
|
"friendsofphp/php-cs-fixer": "^3.4",
|
||||||
|
"guzzlehttp/promises": "^2.0.3",
|
||||||
|
"monolog/monolog": "^1.6|^2.0|^3.0",
|
||||||
|
"nyholm/psr7": "^1.8",
|
||||||
|
"open-telemetry/api": "^1.0",
|
||||||
|
"open-telemetry/exporter-otlp": "^1.0",
|
||||||
|
"open-telemetry/sdk": "^1.0",
|
||||||
|
"phpstan/phpstan": "^1.3",
|
||||||
|
"phpunit/phpunit": "^8.5.52|^9.6.34",
|
||||||
|
"spiral/roadrunner-http": "^3.6",
|
||||||
|
"spiral/roadrunner-worker": "^3.6"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-excimer": "Enable Sentry profiling with the Excimer PHP extension.",
|
||||||
|
"monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler."
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"src/functions.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Sentry\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Sentry",
|
||||||
|
"email": "accounts@sentry.io"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHP SDK for Sentry (http://sentry.io)",
|
||||||
|
"homepage": "http://sentry.io",
|
||||||
|
"keywords": [
|
||||||
|
"crash-reporting",
|
||||||
|
"crash-reports",
|
||||||
|
"error-handler",
|
||||||
|
"error-monitoring",
|
||||||
|
"log",
|
||||||
|
"logging",
|
||||||
|
"profiling",
|
||||||
|
"sentry",
|
||||||
|
"tracing"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/getsentry/sentry-php/issues",
|
||||||
|
"source": "https://github.com/getsentry/sentry-php/tree/4.28.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://sentry.io/",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://sentry.io/pricing/",
|
||||||
|
"type": "custom"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-06-11T12:22:38+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sentry/sentry-symfony",
|
||||||
|
"version": "5.10.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/getsentry/sentry-symfony.git",
|
||||||
|
"reference": "6f49255f4cdcfc43a3a283bd3a1f65d483e9192f"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/getsentry/sentry-symfony/zipball/6f49255f4cdcfc43a3a283bd3a1f65d483e9192f",
|
||||||
|
"reference": "6f49255f4cdcfc43a3a283bd3a1f65d483e9192f",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"guzzlehttp/psr7": "^2.1.1",
|
||||||
|
"jean85/pretty-package-versions": "^1.5||^2.0",
|
||||||
|
"php": "^7.2||^8.0",
|
||||||
|
"sentry/sentry": "^4.23.0",
|
||||||
|
"symfony/cache-contracts": "^1.1||^2.4||^3.0",
|
||||||
|
"symfony/config": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||||
|
"symfony/console": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||||
|
"symfony/dependency-injection": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||||
|
"symfony/event-dispatcher": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||||
|
"symfony/http-kernel": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||||
|
"symfony/polyfill-php80": "^1.22",
|
||||||
|
"symfony/psr-http-message-bridge": "^1.2||^2.0||^6.4||^7.0||^8.0",
|
||||||
|
"symfony/yaml": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"doctrine/dbal": "^2.13||^3.3||^4.0",
|
||||||
|
"doctrine/doctrine-bundle": "^2.6||^3.0",
|
||||||
|
"friendsofphp/php-cs-fixer": "^2.19||^3.40",
|
||||||
|
"masterminds/html5": "^2.8",
|
||||||
|
"phpstan/extension-installer": "^1.0",
|
||||||
|
"phpstan/phpstan": "1.12.5",
|
||||||
|
"phpstan/phpstan-phpunit": "1.4.0",
|
||||||
|
"phpstan/phpstan-symfony": "1.4.10",
|
||||||
|
"phpunit/phpunit": "^8.5.40||^9.6.21",
|
||||||
|
"symfony/browser-kit": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||||
|
"symfony/cache": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||||
|
"symfony/dom-crawler": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||||
|
"symfony/framework-bundle": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||||
|
"symfony/http-client": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||||
|
"symfony/messenger": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||||
|
"symfony/monolog-bundle": "^3.4||^4.0",
|
||||||
|
"symfony/phpunit-bridge": "^5.2.6||^6.0||^7.0||^8.0",
|
||||||
|
"symfony/process": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||||
|
"symfony/security-core": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||||
|
"symfony/security-http": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||||
|
"symfony/twig-bundle": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||||
|
"vimeo/psalm": "^4.3||^5.16.0"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"doctrine/doctrine-bundle": "Allow distributed tracing of database queries using Sentry.",
|
||||||
|
"monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler.",
|
||||||
|
"symfony/cache": "Allow distributed tracing of cache pools using Sentry.",
|
||||||
|
"symfony/twig-bundle": "Allow distributed tracing of Twig template rendering using Sentry."
|
||||||
|
},
|
||||||
|
"type": "symfony-bundle",
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"src/aliases.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Sentry\\SentryBundle\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Sentry",
|
||||||
|
"email": "accounts@sentry.io"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Symfony integration for Sentry (http://getsentry.com)",
|
||||||
|
"homepage": "http://getsentry.com",
|
||||||
|
"keywords": [
|
||||||
|
"errors",
|
||||||
|
"logging",
|
||||||
|
"sentry",
|
||||||
|
"symfony"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/getsentry/sentry-symfony/issues",
|
||||||
|
"source": "https://github.com/getsentry/sentry-symfony/tree/5.10.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://sentry.io/",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://sentry.io/pricing/",
|
||||||
|
"type": "custom"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-04-01T14:50:32+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/asset",
|
"name": "symfony/asset",
|
||||||
"version": "v8.0.6",
|
"version": "v8.0.6",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle;
|
|||||||
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
|
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
|
||||||
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
|
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
|
||||||
use Nelmio\CorsBundle\NelmioCorsBundle;
|
use Nelmio\CorsBundle\NelmioCorsBundle;
|
||||||
|
use Sentry\SentryBundle\SentryBundle;
|
||||||
use Symfony\AI\McpBundle\McpBundle;
|
use Symfony\AI\McpBundle\McpBundle;
|
||||||
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
||||||
use Symfony\Bundle\MonologBundle\MonologBundle;
|
use Symfony\Bundle\MonologBundle\MonologBundle;
|
||||||
@@ -24,4 +25,5 @@ return [
|
|||||||
LexikJWTAuthenticationBundle::class => ['all' => true],
|
LexikJWTAuthenticationBundle::class => ['all' => true],
|
||||||
McpBundle::class => ['all' => true],
|
McpBundle::class => ['all' => true],
|
||||||
MonologBundle::class => ['all' => true],
|
MonologBundle::class => ['all' => true],
|
||||||
|
SentryBundle::class => ['prod' => true],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Error tracking → GlitchTip (compatible SDK Sentry).
|
||||||
|
# Actif uniquement en prod (bundle enregistre seulement pour prod dans bundles.php).
|
||||||
|
# Si SENTRY_DSN est vide/non defini, le SDK est inerte (rien n'est envoye).
|
||||||
|
when@prod:
|
||||||
|
parameters:
|
||||||
|
# Valeur par defaut : DSN vide => Sentry desactive tant qu'il n'est pas fourni.
|
||||||
|
env(SENTRY_DSN): ''
|
||||||
|
|
||||||
|
sentry:
|
||||||
|
dsn: '%env(SENTRY_DSN)%'
|
||||||
|
# Capture les exceptions levees par le kernel (comportement par defaut).
|
||||||
|
register_error_listener: true
|
||||||
|
register_error_handler: true
|
||||||
|
options:
|
||||||
|
environment: '%env(APP_ENV)%'
|
||||||
|
release: '%app.version%'
|
||||||
|
# Pas d'APM/tracing (DuckDB hors perimetre du ticket #146).
|
||||||
|
traces_sample_rate: 0.0
|
||||||
|
# Ne pas remonter les 4xx HTTP comme des erreurs (bruit).
|
||||||
|
ignore_exceptions:
|
||||||
|
- Symfony\Component\HttpKernel\Exception\NotFoundHttpException
|
||||||
|
- Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException
|
||||||
|
- Symfony\Component\Security\Core\Exception\AccessDeniedException
|
||||||
@@ -1752,6 +1752,90 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* },
|
* },
|
||||||
* }>,
|
* }>,
|
||||||
* }
|
* }
|
||||||
|
* @psalm-type SentryConfig = array{
|
||||||
|
* dsn?: scalar|Param|null, // If this value is not provided, the SDK will try to read it from the SENTRY_DSN environment variable. If that variable also does not exist, the SDK will not send any events.
|
||||||
|
* register_error_listener?: bool|Param, // Default: true
|
||||||
|
* register_error_handler?: bool|Param, // Default: true
|
||||||
|
* logger?: scalar|Param|null, // The service ID of the PSR-3 logger used to log messages coming from the SDK client. Be aware that setting the same logger of the application may create a circular loop when an event fails to be sent. // Default: null
|
||||||
|
* options?: array{
|
||||||
|
* integrations?: mixed, // Default: []
|
||||||
|
* default_integrations?: bool|Param,
|
||||||
|
* prefixes?: list<scalar|Param|null>,
|
||||||
|
* sample_rate?: float|Param, // The sampling factor to apply to events. A value of 0 will deny sending any event, and a value of 1 will send all events.
|
||||||
|
* enable_tracing?: bool|Param,
|
||||||
|
* traces_sample_rate?: float|Param, // The sampling factor to apply to transactions. A value of 0 will deny sending any transaction, and a value of 1 will send all transactions.
|
||||||
|
* traces_sampler?: scalar|Param|null,
|
||||||
|
* profiles_sample_rate?: float|Param, // The sampling factor to apply to profiles. A value of 0 will deny sending any profiles, and a value of 1 will send all profiles. Profiles are sampled in relation to traces_sample_rate
|
||||||
|
* enable_logs?: bool|Param,
|
||||||
|
* log_flush_threshold?: mixed, // Default: null
|
||||||
|
* enable_metrics?: bool|Param, // Default: true
|
||||||
|
* attach_stacktrace?: bool|Param,
|
||||||
|
* attach_metric_code_locations?: bool|Param,
|
||||||
|
* context_lines?: int|Param,
|
||||||
|
* environment?: scalar|Param|null, // Default: "%kernel.environment%"
|
||||||
|
* logger?: scalar|Param|null,
|
||||||
|
* spotlight?: bool|Param,
|
||||||
|
* spotlight_url?: scalar|Param|null,
|
||||||
|
* release?: scalar|Param|null, // Default: "%env(default::SENTRY_RELEASE)%"
|
||||||
|
* org_id?: int|Param,
|
||||||
|
* server_name?: scalar|Param|null,
|
||||||
|
* ignore_exceptions?: list<scalar|Param|null>,
|
||||||
|
* ignore_transactions?: list<scalar|Param|null>,
|
||||||
|
* before_send?: scalar|Param|null,
|
||||||
|
* before_send_transaction?: scalar|Param|null,
|
||||||
|
* before_send_check_in?: scalar|Param|null,
|
||||||
|
* before_send_metrics?: scalar|Param|null,
|
||||||
|
* before_send_log?: scalar|Param|null,
|
||||||
|
* before_send_metric?: scalar|Param|null,
|
||||||
|
* trace_propagation_targets?: mixed,
|
||||||
|
* strict_trace_continuation?: bool|Param,
|
||||||
|
* tags?: array<string, scalar|Param|null>,
|
||||||
|
* error_types?: scalar|Param|null,
|
||||||
|
* max_breadcrumbs?: int|Param,
|
||||||
|
* before_breadcrumb?: mixed,
|
||||||
|
* in_app_exclude?: list<scalar|Param|null>,
|
||||||
|
* in_app_include?: list<scalar|Param|null>,
|
||||||
|
* send_default_pii?: bool|Param,
|
||||||
|
* max_value_length?: int|Param,
|
||||||
|
* transport?: scalar|Param|null,
|
||||||
|
* http_client?: scalar|Param|null,
|
||||||
|
* http_proxy?: scalar|Param|null,
|
||||||
|
* http_proxy_authentication?: scalar|Param|null,
|
||||||
|
* http_connect_timeout?: float|Param, // The maximum number of seconds to wait while trying to connect to a server. It works only when using the default transport.
|
||||||
|
* http_timeout?: float|Param, // The maximum execution time for the request+response as a whole. It works only when using the default transport.
|
||||||
|
* http_ssl_verify_peer?: bool|Param,
|
||||||
|
* http_compression?: bool|Param,
|
||||||
|
* capture_silenced_errors?: bool|Param,
|
||||||
|
* max_request_body_size?: "none"|"never"|"small"|"medium"|"always"|Param,
|
||||||
|
* class_serializers?: array<string, scalar|Param|null>,
|
||||||
|
* },
|
||||||
|
* messenger?: bool|array{
|
||||||
|
* enabled?: bool|Param, // Default: true
|
||||||
|
* capture_soft_fails?: bool|Param, // Default: true
|
||||||
|
* isolate_breadcrumbs_by_message?: bool|Param, // Default: false
|
||||||
|
* isolate_context_by_message?: bool|Param, // Default: false
|
||||||
|
* },
|
||||||
|
* tracing?: bool|array{
|
||||||
|
* enabled?: bool|Param, // Default: true
|
||||||
|
* dbal?: bool|array{
|
||||||
|
* enabled?: bool|Param, // Default: true
|
||||||
|
* ignore_prepare_spans?: bool|Param, // Default: false
|
||||||
|
* connections?: list<scalar|Param|null>,
|
||||||
|
* },
|
||||||
|
* twig?: bool|array{
|
||||||
|
* enabled?: bool|Param, // Default: false
|
||||||
|
* },
|
||||||
|
* cache?: bool|array{
|
||||||
|
* enabled?: bool|Param, // Default: true
|
||||||
|
* },
|
||||||
|
* http_client?: bool|array{
|
||||||
|
* enabled?: bool|Param, // Default: true
|
||||||
|
* },
|
||||||
|
* console?: array{
|
||||||
|
* excluded_commands?: list<scalar|Param|null>,
|
||||||
|
* },
|
||||||
|
* },
|
||||||
|
* }
|
||||||
* @psalm-type ConfigType = array{
|
* @psalm-type ConfigType = array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
* parameters?: ParametersConfig,
|
* parameters?: ParametersConfig,
|
||||||
@@ -1792,6 +1876,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||||
* mcp?: McpConfig,
|
* mcp?: McpConfig,
|
||||||
* monolog?: MonologConfig,
|
* monolog?: MonologConfig,
|
||||||
|
* sentry?: SentryConfig,
|
||||||
* },
|
* },
|
||||||
* "when@test"?: array{
|
* "when@test"?: array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
|
|||||||
@@ -113,6 +113,14 @@ services:
|
|||||||
|
|
||||||
App\Module\Directory\Domain\Repository\ProspectRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineProspectRepository'
|
App\Module\Directory\Domain\Repository\ProspectRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineProspectRepository'
|
||||||
|
|
||||||
|
App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrinePrestataireRepository'
|
||||||
|
|
||||||
|
App\Module\Directory\Domain\Repository\ContactRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineContactRepository'
|
||||||
|
|
||||||
|
App\Module\Directory\Domain\Repository\AddressRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineAddressRepository'
|
||||||
|
|
||||||
|
App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineCommercialReportRepository'
|
||||||
|
|
||||||
App\Module\Directory\Infrastructure\EventListener\CommercialReportAuthorListener:
|
App\Module\Directory\Infrastructure\EventListener\CommercialReportAuthorListener:
|
||||||
tags:
|
tags:
|
||||||
- { name: doctrine.orm.entity_listener, entity: 'App\Module\Directory\Domain\Entity\CommercialReport', event: prePersist }
|
- { name: doctrine.orm.entity_listener, entity: 'App\Module\Directory\Domain\Entity\CommercialReport', event: prePersist }
|
||||||
|
|||||||
+9
-2
@@ -26,7 +26,14 @@ return [
|
|||||||
['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline', 'module' => 'project-management', 'permission' => 'project-management.tasks.view'],
|
['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline', 'module' => 'project-management', 'permission' => 'project-management.tasks.view'],
|
||||||
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline', 'module' => 'project-management', 'permission' => 'project-management.projects.view'],
|
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline', 'module' => 'project-management', 'permission' => 'project-management.projects.view'],
|
||||||
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline', 'module' => 'time-tracking', 'permission' => 'time-tracking.entries.view'],
|
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline', 'module' => 'time-tracking', 'permission' => 'time-tracking.entries.view'],
|
||||||
// Gating module uniquement (cf. en-tête) : rendu visuel + badge gérés côté layout.
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'sidebar.tools.section',
|
||||||
|
'icon' => 'mdi:tools',
|
||||||
|
'items' => [
|
||||||
|
// Gating module uniquement : rendu visuel + badge non-lus gérés côté layout
|
||||||
|
// (filtré de translatedSections puis ré-injecté avec suffixe (N)).
|
||||||
['label' => 'sidebar.general.mail', 'to' => '/mail', 'icon' => 'mdi:email-outline', 'module' => 'mail'],
|
['label' => 'sidebar.general.mail', 'to' => '/mail', 'icon' => 'mdi:email-outline', 'module' => 'mail'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -37,8 +44,8 @@ return [
|
|||||||
'items' => [
|
'items' => [
|
||||||
['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline', 'module' => 'absence'],
|
['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline', 'module' => 'absence'],
|
||||||
['label' => 'sidebar.admin.directory', 'to' => '/directory', 'icon' => 'mdi:card-account-details-outline', 'module' => 'directory'],
|
['label' => 'sidebar.admin.directory', 'to' => '/directory', 'icon' => 'mdi:card-account-details-outline', 'module' => 'directory'],
|
||||||
['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline', 'permission' => 'core.users.view'],
|
|
||||||
['label' => 'sidebar.admin.reporting', 'to' => '/reporting', 'icon' => 'mdi:chart-line', 'module' => 'reporting', 'permission' => 'reporting.view'],
|
['label' => 'sidebar.admin.reporting', 'to' => '/reporting', 'icon' => 'mdi:chart-line', 'module' => 'reporting', 'permission' => 'reporting.view'],
|
||||||
|
['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline', 'permission' => 'core.users.view'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.4.37'
|
app.version: '0.4.43'
|
||||||
|
|||||||
@@ -0,0 +1,484 @@
|
|||||||
|
# Migration sidebar vers MalioSidebar — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Remplacer la sidebar maison de Lesstime par le composant `MalioSidebar` de `@malio/layer-ui`, en 3 groupes (Général / Outils / Administration), avec timer + version dans le footer et le logo Malio de Starseed.
|
||||||
|
|
||||||
|
**Architecture:** Modèle backend-driven conservé — `config/sidebar.php` filtré par `SidebarProvider` (permissions/rôles/modules côté serveur), exposé via `/api/sidebar`, consommé par `useSidebar()`. Le layout `default.vue` mappe ces sections vers le format `MalioSidebar` et fusionne les items contextuels rendus côté client (Kanban/Groupes/Archives, Documents, Mail+badge, Mes absences).
|
||||||
|
|
||||||
|
**Tech Stack:** Nuxt 4 (SPA), Vue 3 `<script setup>` TS, Pinia, `@malio/layer-ui` ^1.7.16, i18n (@nuxtjs/i18n), Symfony 8 / API Platform 4 (backend config PHP).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- **Ne jamais modifier `@malio/layer-ui`** (lib externe). Source de référence en lecture seule : `frontend/node_modules/@malio/layer-ui/app/components/malio/sidebar/Sidebar.vue`.
|
||||||
|
- `MalioSidebar` : props `sections` (requis), `modelValue` (v-model collapse bool), `sidebarClass`, `toggleClass`. Item = `{ label: string; to: string; exact?: boolean }` (pas d'icône ni de badge par item). Section = `{ label?: string; icon?: string; items: SidebarItem[] }`. Slots : `#logo`, `#logo-collapsed`, `#footer`, `#footer-collapsed`.
|
||||||
|
- **TypeScript strict** ; indentation **4 espaces** (frontend).
|
||||||
|
- Backend : `declare(strict_types=1)` en tête des fichiers PHP.
|
||||||
|
- Commits format projet : `type(scope) : message` (espaces autour du `:`), types autorisés minuscules (`feat`, `fix`, `refactor`, `chore`, …). **Ne committer que sur demande de l'utilisateur** (règle CLAUDE.md). Travailler sur une branche dédiée (pas directement sur `develop`).
|
||||||
|
- **Pas de runner de test frontend** dans ce projet → vérification par `npm run build` (Nuxt, échoue sur erreur TS/template) + **QA manuelle navigateur** (`make dev-nuxt`, port 3002). Ne PAS introduire de framework de test (hors scope).
|
||||||
|
- Décisions validées : 3 groupes ; badge mail = **suffixe `(N)`** sur le label.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- `config/sidebar.php` — **Modify** : re-catégorisation en 3 sections.
|
||||||
|
- `frontend/i18n/locales/fr.json` — **Modify** : clés de sections/items.
|
||||||
|
- `frontend/i18n/locales/*.json` (autres langues présentes) — **Modify si existantes** : mêmes clés.
|
||||||
|
- `frontend/public/LOGO_MALIO.png` — **Create** (copie Starseed).
|
||||||
|
- `frontend/public/LOGO_MALIO_COLLAPSED.png` — **Create** (copie Starseed).
|
||||||
|
- `frontend/app/layouts/default.vue` — **Modify** : réécriture du template sidebar + logique `mergedSections`.
|
||||||
|
- `frontend/components/ui/SidebarLink.vue` — **Possible delete** (si plus aucun usage après migration).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 0 : Branche de travail
|
||||||
|
|
||||||
|
**Files:** aucun (git).
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Créer la branche depuis `develop`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/m-tristan/workspace/Lesstime
|
||||||
|
git checkout develop && git pull --ff-only
|
||||||
|
git checkout -b feat/malio-sidebar
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected : sur la branche `feat/malio-sidebar`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1 : Backend — re-catégorisation `config/sidebar.php` + i18n
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `config/sidebar.php`
|
||||||
|
- Modify: `frontend/i18n/locales/fr.json`
|
||||||
|
- Modify: autres `frontend/i18n/locales/*.json` si présentes (mêmes clés)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces : `/api/sidebar` renvoie des sections dont les `label` sont les clés `sidebar.general.section`, `sidebar.tools.section`, `sidebar.admin.section`. Items inchangés en `to` ; gates (`module`/`roles`/`permission`) inchangés, juste réorganisés.
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Réécrire `config/sidebar.php` en 3 sections**
|
||||||
|
|
||||||
|
Remplacer le `return [...]` (lignes 20-44) par :
|
||||||
|
|
||||||
|
```php
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'label' => 'sidebar.general.section',
|
||||||
|
'icon' => 'mdi:view-dashboard-outline',
|
||||||
|
'items' => [
|
||||||
|
['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'],
|
||||||
|
['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline', 'module' => 'project-management', 'permission' => 'project-management.tasks.view'],
|
||||||
|
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline', 'module' => 'project-management', 'permission' => 'project-management.projects.view'],
|
||||||
|
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline', 'module' => 'time-tracking', 'permission' => 'time-tracking.entries.view'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'sidebar.tools.section',
|
||||||
|
'icon' => 'mdi:tools',
|
||||||
|
'items' => [
|
||||||
|
// Gating module uniquement : rendu visuel + badge non-lus gérés côté layout
|
||||||
|
// (filtré de translatedSections puis ré-injecté avec suffixe (N)).
|
||||||
|
['label' => 'sidebar.general.mail', 'to' => '/mail', 'icon' => 'mdi:email-outline', 'module' => 'mail'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'sidebar.admin.section',
|
||||||
|
'icon' => 'mdi:cog-outline',
|
||||||
|
'roles' => ['ROLE_ADMIN'],
|
||||||
|
'items' => [
|
||||||
|
['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline', 'module' => 'absence'],
|
||||||
|
['label' => 'sidebar.admin.directory', 'to' => '/directory', 'icon' => 'mdi:card-account-details-outline', 'module' => 'directory'],
|
||||||
|
['label' => 'sidebar.admin.reporting', 'to' => '/reporting', 'icon' => 'mdi:chart-line', 'module' => 'reporting', 'permission' => 'reporting.view'],
|
||||||
|
['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline', 'permission' => 'core.users.view'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
> Mettre aussi à jour le commentaire d'en-tête si nécessaire (le bloc décrivant Mail/contextuels reste valable).
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Mettre à jour les clés i18n FR**
|
||||||
|
|
||||||
|
Dans `frontend/i18n/locales/fr.json`, bloc `sidebar` :
|
||||||
|
- `sidebar.general.section` : remplacer la valeur par `"Général"`.
|
||||||
|
- Ajouter `sidebar.tools.section` : `"Outils"`.
|
||||||
|
- Conserver `sidebar.general.dashboard|myTasks|projects|timeTracking|mail` et `sidebar.admin.*`.
|
||||||
|
- Ajouter les clés pour items client (utilisées en Task 3) :
|
||||||
|
- `sidebar.general.myAbsences` : `"Mes absences"`
|
||||||
|
- `sidebar.project.kanban` : `"Kanban"`
|
||||||
|
- `sidebar.project.groups` : `"Groupes"`
|
||||||
|
- `sidebar.project.archives` : `"Archives"`
|
||||||
|
|
||||||
|
Résultat attendu du bloc (extrait) :
|
||||||
|
|
||||||
|
```json
|
||||||
|
"sidebar": {
|
||||||
|
"general": {
|
||||||
|
"section": "Général",
|
||||||
|
"dashboard": "Tableau de bord",
|
||||||
|
"myTasks": "Mes tâches",
|
||||||
|
"projects": "Projets",
|
||||||
|
"timeTracking": "Suivi de temps",
|
||||||
|
"mail": "Messagerie",
|
||||||
|
"myAbsences": "Mes absences"
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"section": "Outils"
|
||||||
|
},
|
||||||
|
"project": {
|
||||||
|
"kanban": "Kanban",
|
||||||
|
"groups": "Groupes",
|
||||||
|
"archives": "Archives"
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"section": "Administration",
|
||||||
|
"teamAbsences": "Absences équipe",
|
||||||
|
"directory": "Répertoire",
|
||||||
|
"administration": "Administration",
|
||||||
|
"reporting": "Rapports"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Répliquer les clés dans les autres locales si présentes**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls /home/m-tristan/workspace/Lesstime/frontend/i18n/locales/
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour chaque fichier autre que `fr.json`, ajouter `tools.section`, `general.myAbsences`, `project.kanban|groups|archives` et ajuster `general.section`. S'il n'existe que `fr.json`, ne rien faire de plus.
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Vérifier `/api/sidebar` (admin)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -i php-lesstime-fpm php -r 'var_dump(require "/var/www/config/sidebar.php");' | head -5
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected : le fichier PHP se parse sans erreur (3 entrées de premier niveau). (Le chemin exact dans le container peut différer — sinon, vérifier via `make cache-clear` qui échouerait sur une erreur de syntaxe PHP.)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make cache-clear
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected : succès, pas d'erreur de parse.
|
||||||
|
|
||||||
|
- [ ] **Step 5 : Commit (sur demande utilisateur)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add config/sidebar.php frontend/i18n/locales/
|
||||||
|
git commit -m "refactor(sidebar) : re-catégorisation en 3 groupes (Général / Outils / Administration)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2 : Frontend — assets logo
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `frontend/public/LOGO_MALIO.png`
|
||||||
|
- Create: `frontend/public/LOGO_MALIO_COLLAPSED.png`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces : assets statiques servis à `/LOGO_MALIO.png` et `/LOGO_MALIO_COLLAPSED.png`.
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Copier les logos depuis Starseed**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp /home/m-tristan/workspace/Starseed/frontend/public/LOGO_MALIO.png \
|
||||||
|
/home/m-tristan/workspace/Lesstime/frontend/public/LOGO_MALIO.png
|
||||||
|
cp /home/m-tristan/workspace/Starseed/frontend/public/LOGO_MALIO_COLLAPSED.png \
|
||||||
|
/home/m-tristan/workspace/Lesstime/frontend/public/LOGO_MALIO_COLLAPSED.png
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Vérifier**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -la /home/m-tristan/workspace/Lesstime/frontend/public/LOGO_MALIO*.png
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected : deux fichiers présents (~5.8K et ~2.2K).
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Commit (sur demande utilisateur)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/public/LOGO_MALIO.png frontend/public/LOGO_MALIO_COLLAPSED.png
|
||||||
|
git commit -m "chore(sidebar) : ajout des logos Malio (déplié / replié)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3 : Frontend — migration du layout vers `MalioSidebar`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/app/layouts/default.vue`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes : `useSidebar().sections` (clés i18n des Task 1), `useUiStore().sidebarCollapsed`, `SidebarTimer` (`:collapsed`), `useAppVersion().version`, `useMailStore().globalUnreadCount`, `useShareStatus()`, `auth.user.isEmployee`, `auth.user.roles`, `useI18n().t`.
|
||||||
|
- Produces : layout rendant `<MalioSidebar>`.
|
||||||
|
|
||||||
|
> Ce task est une réécriture cohérente d'un seul fichier : la sidebar doit rester fonctionnelle (toutes features préservées) à la fin du task. On ne committe pas d'état intermédiaire cassé.
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Remplacer le bloc `<aside>…</aside>` (lignes 13-104) par `<MalioSidebar>`**
|
||||||
|
|
||||||
|
Nouveau template de la zone sidebar (remplace l'overlay mobile lignes 5-11 **et** l'`<aside>`) :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<MalioSidebar
|
||||||
|
v-model="ui.sidebarCollapsed"
|
||||||
|
:sections="mergedSections"
|
||||||
|
:sidebar-class="ui.sidebarCollapsed ? '' : 'w-[232px]'"
|
||||||
|
>
|
||||||
|
<template #logo>
|
||||||
|
<img src="/LOGO_MALIO.png" alt="Malio"/>
|
||||||
|
</template>
|
||||||
|
<template #logo-collapsed>
|
||||||
|
<img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio"/>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<SidebarTimer :collapsed="false" />
|
||||||
|
<p v-if="version" class="text-center text-sm font-bold">v {{ version }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer-collapsed>
|
||||||
|
<SidebarTimer :collapsed="true" />
|
||||||
|
</template>
|
||||||
|
</MalioSidebar>
|
||||||
|
```
|
||||||
|
|
||||||
|
Le bloc `<div class="h-full flex-1 …">` (AppTopNav + `<main>` + `<slot/>`) et le `<TimeEntryDrawer>` restent **inchangés**.
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Remplacer la logique `translatedSections` par `mergedSections` dans le `<script setup>`**
|
||||||
|
|
||||||
|
Supprimer le computed `translatedSections` (lignes 144-156) et le remplacer par :
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type MalioItem = { label: string; to: string; exact?: boolean }
|
||||||
|
type MalioSection = { label: string; icon: string; items: MalioItem[] }
|
||||||
|
|
||||||
|
// Ordre d'affichage canonique des sections.
|
||||||
|
const SECTION_ORDER = [
|
||||||
|
'sidebar.general.section',
|
||||||
|
'sidebar.tools.section',
|
||||||
|
'sidebar.admin.section',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
// Icônes de secours pour les sections créées côté client (absentes du backend,
|
||||||
|
// ex. module mail off mais partage actif → section Outils à recréer).
|
||||||
|
const SECTION_ICON: Record<string, string> = {
|
||||||
|
'sidebar.general.section': 'mdi:view-dashboard-outline',
|
||||||
|
'sidebar.tools.section': 'mdi:tools',
|
||||||
|
'sidebar.admin.section': 'mdi:cog-outline',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Items rendus côté client (dépendent d'un état runtime ignoré du backend).
|
||||||
|
function clientItemsFor(key: string): MalioItem[] {
|
||||||
|
if (key === 'sidebar.general.section') {
|
||||||
|
const items: MalioItem[] = []
|
||||||
|
if (currentProjectId.value) {
|
||||||
|
const id = currentProjectId.value
|
||||||
|
items.push({ label: t('sidebar.project.kanban'), to: `/projects/${id}`, exact: true })
|
||||||
|
items.push({ label: t('sidebar.project.groups'), to: `/projects/${id}/groups` })
|
||||||
|
items.push({ label: t('sidebar.project.archives'), to: `/projects/${id}/archives` })
|
||||||
|
}
|
||||||
|
if (isEmployee.value) {
|
||||||
|
items.push({ label: t('sidebar.general.myAbsences'), to: '/absences' })
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
if (key === 'sidebar.tools.section') {
|
||||||
|
const items: MalioItem[] = []
|
||||||
|
if (isMailVisible.value) {
|
||||||
|
const n = mailStore.globalUnreadCount
|
||||||
|
const suffix = n > 0 ? ` (${n > 99 ? '99+' : n})` : ''
|
||||||
|
items.push({ label: `${t('mail.sidebar.title')}${suffix}`, to: '/mail' })
|
||||||
|
}
|
||||||
|
if (isDocumentsVisible.value) {
|
||||||
|
items.push({ label: t('sharedFiles.sidebar.title'), to: '/documents' })
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedSections = computed<MalioSection[]>(() => {
|
||||||
|
// 1. Sections backend (déjà filtrées par permissions), mail retiré (ré-injecté côté client).
|
||||||
|
const backend = new Map<string, MalioSection>()
|
||||||
|
for (const section of sections.value) {
|
||||||
|
backend.set(section.label, {
|
||||||
|
label: t(section.label),
|
||||||
|
icon: section.icon,
|
||||||
|
items: section.items
|
||||||
|
.filter((item) => item.to !== '/mail')
|
||||||
|
.map((item) => ({ label: t(item.label), to: item.to })),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fusion dans l'ordre canonique.
|
||||||
|
const result: MalioSection[] = []
|
||||||
|
for (const key of SECTION_ORDER) {
|
||||||
|
const base = backend.get(key)
|
||||||
|
const extra = clientItemsFor(key)
|
||||||
|
if (base) {
|
||||||
|
base.items.push(...extra)
|
||||||
|
if (base.items.length > 0) {
|
||||||
|
result.push(base)
|
||||||
|
}
|
||||||
|
} else if (extra.length > 0) {
|
||||||
|
result.push({ label: t(key), icon: SECTION_ICON[key], items: extra })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Garde-fou : toute section backend hors ordre canonique n'est pas perdue.
|
||||||
|
for (const [key, section] of backend) {
|
||||||
|
if (!(SECTION_ORDER as readonly string[]).includes(key) && section.items.length > 0) {
|
||||||
|
result.push(section)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
> `isDocumentsVisible` existe déjà (ligne 166). `isMailVisible`, `isEmployee`, `currentProjectId`, `sections`, `mailStore`, `t`, `version`, `ui` sont déjà déclarés — ne pas les redéclarer.
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Nettoyer le `<script>` et les imports devenus inutiles**
|
||||||
|
|
||||||
|
- Supprimer `sidebarIsCollapsed` (computed lignes 169-172) **si** plus utilisé après suppression de l'`<aside>` (l'était pour le rendu manuel). Vérifier qu'aucune autre référence ne subsiste :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "sidebarIsCollapsed" frontend/app/layouts/default.vue
|
||||||
|
```
|
||||||
|
|
||||||
|
S'il ne reste aucune occurrence hors déclaration, supprimer le computed.
|
||||||
|
|
||||||
|
- Conserver `watch(() => route.path, () => { ui.closeMobileSidebar() })` (fermeture mobile sur navigation).
|
||||||
|
- Vérifier que `SidebarLink` n'est plus référencé dans ce fichier (le composant Malio le remplace) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "SidebarLink" frontend/app/layouts/default.vue
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected : aucune occurrence.
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Build de vérification**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/m-tristan/workspace/Lesstime/frontend && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected : build Nuxt réussi, **aucune erreur TypeScript** ni de template. (Si `mergedSections`/types invalides, le build échoue ici.)
|
||||||
|
|
||||||
|
- [ ] **Step 5 : QA manuelle (dev server)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dev-nuxt # port 3002
|
||||||
|
```
|
||||||
|
|
||||||
|
Vérifier en **admin** (`admin`/`admin`) :
|
||||||
|
- 3 groupes : Général, Outils, Administration.
|
||||||
|
- Général : Tableau de bord, Mes tâches, Projets, Suivi de temps.
|
||||||
|
- En ouvrant un projet (`/projects/<id>`) : Kanban/Groupes/Archives apparaissent dans Général ; Kanban actif uniquement sur la page kanban (exact).
|
||||||
|
- Outils : Messagerie (+ `(N)` si non-lus), Documents (si partage activé).
|
||||||
|
- Administration : Absences équipe, Répertoire, Rapports, Administration.
|
||||||
|
- Footer : timer cliquable (start/stop) + `v <version>` ; en replié, le timer reste (icône) et la version disparaît.
|
||||||
|
- Logo Malio déplié + replié (collapsed via toggle du composant).
|
||||||
|
- Route active surlignée ; pas de doublon `/mail`.
|
||||||
|
|
||||||
|
Vérifier en **utilisateur non-admin** (`alice`/`alice`) :
|
||||||
|
- **Pas** de groupe Administration.
|
||||||
|
- Items gated par permission absents si l'utilisateur n'a pas la permission.
|
||||||
|
- Mes absences visible uniquement si `isEmployee`.
|
||||||
|
|
||||||
|
- [ ] **Step 6 : Vérifier le comportement mobile (largeur < lg)**
|
||||||
|
|
||||||
|
Réduire la fenêtre / activer le responsive devtools.
|
||||||
|
- Vérifier l'ouverture/fermeture de la sidebar sur mobile.
|
||||||
|
- Vérifier le bouton hamburger éventuel de `AppTopNav` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rn "openMobileSidebar\|sidebarOpen\|closeMobileSidebar" frontend/app/components/ frontend/components/ frontend/app/layouts/default.vue
|
||||||
|
```
|
||||||
|
|
||||||
|
- Si `MalioSidebar` gère le responsive et que l'overlay supprimé n'est plus nécessaire : OK.
|
||||||
|
- Si l'ouverture mobile ne fonctionne plus (ex. AppTopNav appelait `openMobileSidebar` pour l'ancien overlay) : adapter **sans modifier la lib** — a minima conserver le repli/déploiement via `ui.sidebarCollapsed`, ou conserver un déclencheur. Documenter le choix retenu dans le commit.
|
||||||
|
|
||||||
|
- [ ] **Step 7 : Commit (sur demande utilisateur)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/app/layouts/default.vue
|
||||||
|
git commit -m "feat(sidebar) : migration du layout vers MalioSidebar (footer timer + version, logo Malio)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4 : Nettoyage des éléments obsolètes
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Possible delete: `frontend/components/ui/SidebarLink.vue`
|
||||||
|
- Possible delete: anciens logos `frontend/public/malio.png`, `frontend/public/LOGO_CARRE.png`
|
||||||
|
|
||||||
|
**Interfaces:** aucun (suppression sûre uniquement si zéro référence).
|
||||||
|
|
||||||
|
- [ ] **Step 1 : Vérifier les usages restants de `SidebarLink`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rn "SidebarLink" /home/m-tristan/workspace/Lesstime/frontend --include="*.vue" --include="*.ts" | grep -v node_modules
|
||||||
|
```
|
||||||
|
|
||||||
|
- Si **aucune** occurrence : supprimer le fichier.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git rm frontend/components/ui/SidebarLink.vue
|
||||||
|
```
|
||||||
|
|
||||||
|
- Si encore référencé ailleurs : **ne pas supprimer**, laisser tel quel.
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Vérifier les usages des anciens logos**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rn "malio.png\|LOGO_CARRE.png" /home/m-tristan/workspace/Lesstime/frontend --include="*.vue" --include="*.ts" --include="*.css" | grep -v node_modules
|
||||||
|
```
|
||||||
|
|
||||||
|
- Si **aucune** occurrence : supprimer les deux PNG.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git rm frontend/public/malio.png frontend/public/LOGO_CARRE.png
|
||||||
|
```
|
||||||
|
|
||||||
|
- Sinon : conserver.
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Build final**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/m-tristan/workspace/Lesstime/frontend && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected : build réussi.
|
||||||
|
|
||||||
|
- [ ] **Step 4 : Commit (sur demande utilisateur)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "chore(sidebar) : suppression des composants/assets obsolètes de l'ancienne sidebar"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (auteur du plan)
|
||||||
|
|
||||||
|
**Spec coverage :**
|
||||||
|
- Remplacement par MalioSidebar → Task 3 ✓
|
||||||
|
- Permissions serveur préservées → Task 1 (gates inchangés) + Task 3 (mail filtré/ré-injecté, garde-fou sections) ✓
|
||||||
|
- 3 groupes Général/Outils/Administration → Task 1 + Task 3 (ordre canonique) ✓
|
||||||
|
- Footer timer + version → Task 3 Step 1 ✓
|
||||||
|
- Logo Malio Starseed → Task 2 + Task 3 ✓
|
||||||
|
- Items contextuels (Kanban/Groupes/Archives, Documents, Mes absences) → Task 3 `clientItemsFor` ✓
|
||||||
|
- Badge mail = suffixe `(N)` → Task 3 `clientItemsFor` ✓
|
||||||
|
- Mobile → Task 3 Step 6 ✓
|
||||||
|
- Nettoyage → Task 4 ✓
|
||||||
|
|
||||||
|
**Placeholder scan :** pas de TBD ; les branches conditionnelles de suppression (Task 4) et d'adaptation mobile (Task 3 Step 6) sont des décisions binaires basées sur un `grep`, pas des placeholders.
|
||||||
|
|
||||||
|
**Type consistency :** `MalioItem`/`MalioSection` définis une fois (Task 3) et utilisés de façon cohérente ; `clientItemsFor`/`mergedSections`/`SECTION_ORDER`/`SECTION_ICON` cohérents. Items produits conformes au type attendu par `MalioSidebar` (`{label, to, exact?}`).
|
||||||
|
|
||||||
|
**Réserve connue :** absence de runner de test FE → vérification par build + QA manuelle (assumé, conforme à l'état du repo).
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
# Migration de la sidebar vers `MalioSidebar` (@malio/layer-ui)
|
||||||
|
|
||||||
|
**Date** : 2026-06-25
|
||||||
|
**Statut** : Design validé
|
||||||
|
**Scope** : Frontend (layout) + backend (config sidebar) + assets
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
La sidebar actuelle de Lesstime est un `<aside>` fait main dans
|
||||||
|
`frontend/app/layouts/default.vue`, qui itère sur les sections renvoyées par
|
||||||
|
`/api/sidebar` et rend chaque item via le composant maison `SidebarLink`. Le
|
||||||
|
timer et la version sont empilés en bas du `<aside>`, le toggle collapse et
|
||||||
|
l'overlay mobile sont gérés manuellement.
|
||||||
|
|
||||||
|
La librairie `@malio/layer-ui` (mise à jour) fournit désormais un composant
|
||||||
|
`MalioSidebar`. Le projet **Starseed** a déjà effectué cette migration sur une
|
||||||
|
architecture identique (`config/sidebar.php` → `SidebarProvider` → composable
|
||||||
|
`useSidebar` → layout). Cette spec applique la même migration à Lesstime, avec
|
||||||
|
trois spécificités Lesstime : footer (timer + version), re-catégorisation des
|
||||||
|
onglets, et plusieurs items contextuels rendus côté client.
|
||||||
|
|
||||||
|
On **ne modifie pas** la lib `@malio/layer-ui` (règle CLAUDE.md).
|
||||||
|
|
||||||
|
## Objectifs
|
||||||
|
|
||||||
|
1. Remplacer le `<aside>` maison par `<MalioSidebar>`.
|
||||||
|
2. Préserver le filtrage des permissions/rôles/modules **côté serveur**.
|
||||||
|
3. Re-catégoriser la navigation en 3 groupes : **Général / Outils / Administration**.
|
||||||
|
4. Mettre le timer et la version dans le **footer** du composant.
|
||||||
|
5. Reprendre le **logo Malio** de Starseed.
|
||||||
|
|
||||||
|
## Décisions validées
|
||||||
|
|
||||||
|
- **Catégorisation** : 3 groupes (option B).
|
||||||
|
- **Badge mail** : le compteur de non-lus devient un **suffixe sur le label**
|
||||||
|
(`Messagerie (3)`), faute de slot badge/icône par item dans `MalioSidebar`.
|
||||||
|
|
||||||
|
## Contraintes du composant `MalioSidebar`
|
||||||
|
|
||||||
|
Source : `frontend/node_modules/@malio/layer-ui/app/components/malio/sidebar/Sidebar.vue`.
|
||||||
|
|
||||||
|
- **Props** : `sections` (requis), `modelValue` (v-model collapse, bool),
|
||||||
|
`id`, `sidebarClass`, `toggleClass`.
|
||||||
|
- **Types** :
|
||||||
|
- `SidebarItem = { label: string; to: string; exact?: boolean }`
|
||||||
|
- `SidebarSection = { label?: string; icon?: string; items: SidebarItem[] }`
|
||||||
|
- **Slots** : `#logo`, `#logo-collapsed`, `#footer`, `#footer-collapsed`.
|
||||||
|
- **Events** : `update:modelValue(boolean)`.
|
||||||
|
- **Item** : pas d'icône par item ni de badge — uniquement l'icône de section.
|
||||||
|
Route active = match exact ou par préfixe (`exact: true` pour exact strict).
|
||||||
|
- Largeurs fixes : 232px (déplié) / 72px (replié). Toggle interne.
|
||||||
|
|
||||||
|
### Conséquences (compromis assumés)
|
||||||
|
|
||||||
|
- Perte de l'**icône par item** (design malioUI = texte + icône de section).
|
||||||
|
Starseed fonctionne ainsi.
|
||||||
|
- Le **badge mail** ne peut pas être une pastille → suffixe `(N)` dans le label.
|
||||||
|
|
||||||
|
## Architecture cible
|
||||||
|
|
||||||
|
Modèle **backend-driven** conservé (sécurité serveur intacte). Le frontend
|
||||||
|
mappe les sections renvoyées par `/api/sidebar` vers le format `MalioSidebar`
|
||||||
|
et **fusionne** les items contextuels (qui dépendent d'un état runtime non
|
||||||
|
connu du backend).
|
||||||
|
|
||||||
|
### 1. Backend — `config/sidebar.php`
|
||||||
|
|
||||||
|
Re-catégorisation en 3 sections (gates inchangés, juste réorganisés) :
|
||||||
|
|
||||||
|
```
|
||||||
|
GÉNÉRAL (sidebar.general.section, icon mdi:view-dashboard-outline)
|
||||||
|
Tableau de bord / —
|
||||||
|
Mes tâches /my-tasks module project-management, perm tasks.view
|
||||||
|
Projets /projects module project-management, perm projects.view
|
||||||
|
Suivi de temps /time-tracking module time-tracking, perm entries.view
|
||||||
|
|
||||||
|
OUTILS (sidebar.tools.section, icon mdi:tools)
|
||||||
|
Messagerie /mail module mail
|
||||||
|
(filtré du rendu backend côté front, ré-injecté avec badge)
|
||||||
|
|
||||||
|
ADMINISTRATION (sidebar.admin.section, icon mdi:cog-outline, roles [ROLE_ADMIN])
|
||||||
|
Absences équipe /team-absences module absence
|
||||||
|
Répertoire /directory module directory
|
||||||
|
Rapports /reporting module reporting, perm reporting.view
|
||||||
|
Administration /admin perm core.users.view
|
||||||
|
```
|
||||||
|
|
||||||
|
> `/mail` reste déclaré pour le gating module (`disabledRoutes`), mais est
|
||||||
|
> filtré des sections rendues et ré-injecté côté client avec son badge, comme
|
||||||
|
> aujourd'hui.
|
||||||
|
|
||||||
|
### 2. i18n — `frontend/i18n/locales/fr.json`
|
||||||
|
|
||||||
|
- Renommer `sidebar.general.section` : « Gestion de projet » → « Général ».
|
||||||
|
- Ajouter `sidebar.tools.section` : « Outils ».
|
||||||
|
- Conserver les clés d'items existantes. Items client : réutiliser les clés
|
||||||
|
existantes quand elles existent (`sharedFiles.sidebar.title` pour Documents,
|
||||||
|
`mail.sidebar.title`/`sidebar.general.mail` pour Messagerie) ; ajouter une
|
||||||
|
clé pour « Mes absences » (aujourd'hui en dur) et pour les contextuels
|
||||||
|
(Kanban/Groupes/Archives, aujourd'hui en dur) si on souhaite les traduire,
|
||||||
|
sinon conserver les libellés en dur actuels.
|
||||||
|
|
||||||
|
### 3. Frontend — `frontend/app/layouts/default.vue`
|
||||||
|
|
||||||
|
Réécriture du template autour de `<MalioSidebar>` :
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<MalioSidebar v-model="ui.sidebarCollapsed" :sections="mergedSections"
|
||||||
|
:sidebar-class="ui.sidebarCollapsed ? '' : 'w-[232px]'">
|
||||||
|
<template #logo> <img src="/LOGO_MALIO.png" alt="Malio"/></template>
|
||||||
|
<template #logo-collapsed> <img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio"/></template>
|
||||||
|
<template #footer>
|
||||||
|
<SidebarTimer :collapsed="false" />
|
||||||
|
<p class="font-bold">v {{ version }}</p>
|
||||||
|
</template>
|
||||||
|
<template #footer-collapsed>
|
||||||
|
<SidebarTimer :collapsed="true" />
|
||||||
|
</template>
|
||||||
|
</MalioSidebar>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Computed `mergedSections`** : construit les sections finales dans l'ordre
|
||||||
|
canonique `[général, outils, administration]`.
|
||||||
|
|
||||||
|
Logique de fusion :
|
||||||
|
1. Partir des sections backend (déjà filtrées), mappées en
|
||||||
|
`{ label: t(label), icon, items: items.filter(to !== '/mail').map({label: t, to}) }`.
|
||||||
|
2. Définir une table `clientItems` indexée par clé de section :
|
||||||
|
- `sidebar.general.section` → (si `currentProjectId`) Kanban (`exact`),
|
||||||
|
Groupes, Archives ; puis (si `isEmployee`) Mes absences.
|
||||||
|
- `sidebar.tools.section` → (si `isMailVisible`) Messagerie avec label
|
||||||
|
`Messagerie` + suffixe `(N)` quand `mailStore.globalUnreadCount > 0`
|
||||||
|
(`99+` au-delà) ; puis (si `shareEnabled`) Documents.
|
||||||
|
3. Pour chaque section backend, **append** ses items client.
|
||||||
|
4. Si une clé de `clientItems` produit des items mais que la section
|
||||||
|
correspondante n'est **pas** présente dans la réponse backend (ex. module
|
||||||
|
mail off mais partage on → pas de section « Outils » côté backend), **créer**
|
||||||
|
la section côté front (label + icône depuis une table locale).
|
||||||
|
5. **Supprimer** les sections finales sans items.
|
||||||
|
6. Trier selon l'ordre canonique des clés.
|
||||||
|
|
||||||
|
Le reste du `<script>` (timer title watchers, `refData`/`TimeEntryDrawer`,
|
||||||
|
polling mail, `ensureShareStatus`, `currentProjectId`, `isEmployee`,
|
||||||
|
`isMailVisible`, `shareEnabled`) est **conservé tel quel**.
|
||||||
|
|
||||||
|
### 4. Mobile
|
||||||
|
|
||||||
|
Starseed a **supprimé l'overlay mobile custom** et ne garde que
|
||||||
|
`watch(route) → ui.closeMobileSidebar()`. On s'aligne : suppression du markup
|
||||||
|
overlay (`ui.sidebarOpen`, `.sidebar-overlay`) si `MalioSidebar` gère le
|
||||||
|
responsive. **À vérifier à l'implémentation** : comportement mobile réel du
|
||||||
|
composant ; si l'ouverture mobile n'est pas couverte, adapter a minima sans
|
||||||
|
modifier la lib.
|
||||||
|
|
||||||
|
### 5. Assets — logo
|
||||||
|
|
||||||
|
Copier depuis Starseed vers `frontend/public/` :
|
||||||
|
- `LOGO_MALIO.png` (128×44)
|
||||||
|
- `LOGO_MALIO_COLLAPSED.png` (34×40)
|
||||||
|
|
||||||
|
Les anciens `/malio.png` et `/LOGO_CARRE.png` ne sont plus référencés par le
|
||||||
|
layout (les laisser ou les retirer si plus aucun usage — à vérifier).
|
||||||
|
|
||||||
|
## Composants / éléments réutilisés
|
||||||
|
|
||||||
|
- `SidebarTimer` (`components/ui/SidebarTimer.vue`) : inchangé, déjà piloté par
|
||||||
|
`:collapsed`.
|
||||||
|
- `useAppVersion()` : inchangé.
|
||||||
|
- `useSidebar()` : inchangé.
|
||||||
|
- `usePermissions()` : inchangé (le filtrage permission reste backend ; les
|
||||||
|
flags client `isEmployee`/`isMailVisible`/`shareEnabled` restent locaux).
|
||||||
|
|
||||||
|
## Éléments supprimés
|
||||||
|
|
||||||
|
- Le `<aside>` manuel et son markup (logo, nav, toggle, overlay) dans
|
||||||
|
`default.vue`.
|
||||||
|
- L'usage de `SidebarLink` dans le layout (le composant peut rester s'il est
|
||||||
|
utilisé ailleurs — à vérifier ; sinon suppression possible).
|
||||||
|
|
||||||
|
## Critères d'acceptation
|
||||||
|
|
||||||
|
1. La sidebar est rendue par `<MalioSidebar>`.
|
||||||
|
2. 3 groupes : Général, Outils, Administration (Administration visible
|
||||||
|
uniquement pour `ROLE_ADMIN` / permissions, comme avant).
|
||||||
|
3. Toutes les permissions/rôles/modules sont respectés à l'identique (aucune
|
||||||
|
régression de visibilité pour user/admin).
|
||||||
|
4. Items contextuels présents : Kanban/Groupes/Archives (dans un projet),
|
||||||
|
Documents (partage activé), Mes absences (employé).
|
||||||
|
5. Messagerie affiche `(N)` quand il y a des non-lus.
|
||||||
|
6. Footer : timer fonctionnel + version (version masquée en replié).
|
||||||
|
7. Logo Malio de Starseed affiché (déplié + replié).
|
||||||
|
8. Collapse/expand et route active fonctionnent.
|
||||||
|
9. Pas de doublon `/mail`. Pas de section vide affichée.
|
||||||
|
10. Build Nuxt OK, pas d'erreur TS.
|
||||||
|
|
||||||
|
## Hors scope
|
||||||
|
|
||||||
|
- Refonte du `SiteSelector` (n'existe pas dans Lesstime).
|
||||||
|
- Modification de la lib `@malio/layer-ui`.
|
||||||
|
- Changement du modèle de permissions backend.
|
||||||
+126
-141
@@ -1,112 +1,31 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-screen overflow-hidden">
|
<div class="h-screen overflow-hidden">
|
||||||
<div class="flex h-full">
|
<div class="flex h-full">
|
||||||
<!-- Mobile sidebar overlay -->
|
<MalioSidebar
|
||||||
<Transition name="sidebar-overlay">
|
v-model="ui.sidebarCollapsed"
|
||||||
<div
|
:sections="mergedSections"
|
||||||
v-if="ui.sidebarOpen"
|
:sidebar-class="ui.sidebarCollapsed ? '' : 'w-[232px]'"
|
||||||
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 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.sidebarCollapsed ? 'lg:w-16' : 'lg:w-64',
|
|
||||||
ui.sidebarOpen ? 'w-64 translate-x-0' : '-translate-x-full',
|
|
||||||
]"
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center overflow-hidden" :class="sidebarIsCollapsed ? 'justify-center p-3' : 'justify-between'">
|
<template #logo>
|
||||||
<img
|
<img src="/LOGO_MALIO.png" alt="Malio"/>
|
||||||
v-if="!sidebarIsCollapsed"
|
</template>
|
||||||
src="/malio.png"
|
<template #logo-collapsed>
|
||||||
alt="Logo"
|
<img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio"/>
|
||||||
class="w-auto"
|
</template>
|
||||||
/>
|
<template #footer>
|
||||||
<img
|
<div class="flex flex-col gap-2">
|
||||||
v-else
|
<SidebarTimer :collapsed="false" />
|
||||||
src="/LOGO_CARRE.png"
|
<p v-if="version" class="text-center text-sm font-bold">v {{ version }}</p>
|
||||||
alt="Logo"
|
</div>
|
||||||
class="w-[46px] h-[55px]"
|
</template>
|
||||||
/>
|
<template #footer-collapsed>
|
||||||
<button
|
<SidebarTimer :collapsed="true" />
|
||||||
class="mr-2 rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:hidden"
|
</template>
|
||||||
@click="ui.closeMobileSidebar()"
|
</MalioSidebar>
|
||||||
>
|
|
||||||
<Icon name="mdi:close" size="20" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<nav class="flex-1 overflow-hidden" :class="sidebarIsCollapsed ? 'px-1 pb-6' : 'px-4 pb-6'">
|
|
||||||
<!-- Sections dynamiques (/api/sidebar) : navigation globale + sections gated par rôle -->
|
|
||||||
<template v-for="(section, sIndex) in translatedSections" :key="section.label">
|
|
||||||
<p v-if="!sidebarIsCollapsed" class="px-4 pt-5 pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
|
|
||||||
{{ section.label }}
|
|
||||||
</p>
|
|
||||||
<div v-else class="mx-2 my-3 border-t border-secondary-500" />
|
|
||||||
<SidebarLink
|
|
||||||
v-for="item in section.items"
|
|
||||||
:key="item.to"
|
|
||||||
:to="item.to"
|
|
||||||
:icon="item.icon"
|
|
||||||
:label="item.label"
|
|
||||||
:collapsed="sidebarIsCollapsed"
|
|
||||||
@click="ui.closeMobileSidebar()"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Items conservés côté client, insérés après la 1re section (cf. décision 3) -->
|
|
||||||
<template v-if="sIndex === 0">
|
|
||||||
<!-- Contextuel projet -->
|
|
||||||
<template v-if="currentProjectId">
|
|
||||||
<SidebarLink :to="`/projects/${currentProjectId}`" icon="mdi:view-column-outline" label="Kanban" :collapsed="sidebarIsCollapsed" sub exact @click="ui.closeMobileSidebar()" />
|
|
||||||
<SidebarLink :to="`/projects/${currentProjectId}/groups`" icon="mdi:tag-multiple-outline" label="Groupes" :collapsed="sidebarIsCollapsed" sub @click="ui.closeMobileSidebar()" />
|
|
||||||
<SidebarLink :to="`/projects/${currentProjectId}/archives`" icon="mdi:archive-outline" label="Archives" :collapsed="sidebarIsCollapsed" sub @click="ui.closeMobileSidebar()" />
|
|
||||||
</template>
|
|
||||||
<!-- Feature-flag : Documents -->
|
|
||||||
<SidebarLink v-if="isDocumentsVisible" to="/documents" icon="mdi:folder-network-outline" :label="$t('sharedFiles.sidebar.title')" :collapsed="sidebarIsCollapsed" @click="ui.closeMobileSidebar()" />
|
|
||||||
<!-- Feature-flag : Mail + badge -->
|
|
||||||
<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>
|
|
||||||
<!-- User-flag : Mes absences (isEmployee — non couvert par le gate rôle) -->
|
|
||||||
<SidebarLink v-if="isEmployee" to="/absences" icon="mdi:umbrella-beach-outline" label="Mes absences" :collapsed="sidebarIsCollapsed" @click="ui.closeMobileSidebar()" />
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="px-4 py-3">
|
|
||||||
<SidebarTimer :collapsed="sidebarIsCollapsed" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-center p-4">
|
|
||||||
<p v-if="!sidebarIsCollapsed" class="font-bold">v {{ version }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Collapse toggle button centered vertically on the sidebar edge -->
|
|
||||||
<button
|
|
||||||
class="absolute top-1/2 -right-4 z-10 hidden h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full border border-neutral-200 bg-white text-neutral-400 shadow-sm hover:text-neutral-700 transition-colors lg:flex"
|
|
||||||
:title="ui.sidebarCollapsed ? 'Ouvrir le menu' : 'Réduire le menu'"
|
|
||||||
@click="ui.toggleSidebar()"
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
:name="ui.sidebarCollapsed ? 'mdi:chevron-right' : 'mdi:chevron-left'"
|
|
||||||
size="18"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
|
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
|
||||||
<AppTopNav :user="auth.user" />
|
<AppTopNav :user="auth.user" />
|
||||||
<main class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-24 sm:px-8 lg:px-16">
|
<main class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-24 sm:px-6 lg:px-12 xl:px-11">
|
||||||
<div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12" />
|
|
||||||
<slot/>
|
<slot/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,23 +58,6 @@ const route = useRoute()
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { sections } = useSidebar()
|
const { sections } = useSidebar()
|
||||||
|
|
||||||
// `/mail` est déclaré dans config/sidebar.php pour le gating module (disabledRoutes),
|
|
||||||
// mais son rendu visuel + badge non-lus est géré manuellement ci-dessous (feature-flag Mail).
|
|
||||||
// On le filtre des sections dynamiques pour éviter un doublon dans la nav.
|
|
||||||
const translatedSections = computed(() =>
|
|
||||||
sections.value.map((section) => ({
|
|
||||||
label: t(section.label),
|
|
||||||
icon: section.icon,
|
|
||||||
items: section.items
|
|
||||||
.filter((item) => item.to !== '/mail')
|
|
||||||
.map((item) => ({
|
|
||||||
label: t(item.label),
|
|
||||||
to: item.to,
|
|
||||||
icon: item.icon,
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
|
|
||||||
const isEmployee = computed(() => Boolean(auth.user?.isEmployee))
|
const isEmployee = computed(() => Boolean(auth.user?.isEmployee))
|
||||||
|
|
||||||
const isMailVisible = computed(() => {
|
const isMailVisible = computed(() => {
|
||||||
@@ -166,22 +68,116 @@ const isMailVisible = computed(() => {
|
|||||||
const { enabled: shareEnabled, ensureLoaded: ensureShareStatus } = useShareStatus()
|
const { enabled: shareEnabled, ensureLoaded: ensureShareStatus } = useShareStatus()
|
||||||
const isDocumentsVisible = computed(() => shareEnabled.value === true)
|
const isDocumentsVisible = computed(() => shareEnabled.value === true)
|
||||||
|
|
||||||
// On mobile, sidebar is always expanded (not collapsed icon mode)
|
|
||||||
const sidebarIsCollapsed = computed(() => {
|
|
||||||
if (ui.sidebarOpen) return false
|
|
||||||
return ui.sidebarCollapsed
|
|
||||||
})
|
|
||||||
|
|
||||||
// Close mobile sidebar on route change
|
|
||||||
watch(() => route.path, () => {
|
|
||||||
ui.closeMobileSidebar()
|
|
||||||
})
|
|
||||||
|
|
||||||
const currentProjectId = computed(() => {
|
const currentProjectId = computed(() => {
|
||||||
const match = route.path.match(/^\/projects\/(\d+)/)
|
const match = route.path.match(/^\/projects\/(\d+)/)
|
||||||
return match ? match[1] : null
|
return match ? match[1] : null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
type MalioItem = { label: string; to: string; exact?: boolean }
|
||||||
|
type MalioSection = { label: string; icon: string; items: MalioItem[] }
|
||||||
|
|
||||||
|
// Ordre d'affichage canonique des sections.
|
||||||
|
const SECTION_ORDER = [
|
||||||
|
'sidebar.general.section',
|
||||||
|
'sidebar.tools.section',
|
||||||
|
'sidebar.admin.section',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
// Icônes de secours pour les sections créées côté client (absentes du backend,
|
||||||
|
// ex. module mail off mais partage actif → section Outils à recréer).
|
||||||
|
const SECTION_ICON: Record<string, string> = {
|
||||||
|
'sidebar.general.section': 'mdi:view-dashboard-outline',
|
||||||
|
'sidebar.tools.section': 'mdi:tools',
|
||||||
|
'sidebar.admin.section': 'mdi:cog-outline',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Item client avec ancre optionnelle : `after` = `to` de l'item après lequel l'insérer
|
||||||
|
// (sinon ajouté en fin de section).
|
||||||
|
type ClientItem = MalioItem & { after?: string }
|
||||||
|
|
||||||
|
// Items rendus côté client (dépendent d'un état runtime ignoré du backend).
|
||||||
|
function clientItemsFor(key: string): ClientItem[] {
|
||||||
|
if (key === 'sidebar.general.section') {
|
||||||
|
const items: ClientItem[] = []
|
||||||
|
if (currentProjectId.value) {
|
||||||
|
const id = currentProjectId.value
|
||||||
|
// Insérés juste sous « Projets », dans l'ordre via ancres chaînées.
|
||||||
|
items.push({ label: t('sidebar.project.kanban'), to: `/projects/${id}`, exact: true, after: '/projects' })
|
||||||
|
items.push({ label: t('sidebar.project.groups'), to: `/projects/${id}/groups`, after: `/projects/${id}` })
|
||||||
|
items.push({ label: t('sidebar.project.archives'), to: `/projects/${id}/archives`, after: `/projects/${id}/groups` })
|
||||||
|
}
|
||||||
|
if (isEmployee.value) {
|
||||||
|
items.push({ label: t('sidebar.general.myAbsences'), to: '/absences' })
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
if (key === 'sidebar.tools.section') {
|
||||||
|
const items: ClientItem[] = []
|
||||||
|
if (isMailVisible.value) {
|
||||||
|
const n = mailStore.globalUnreadCount
|
||||||
|
const suffix = n > 0 ? ` (${n > 99 ? '99+' : n})` : ''
|
||||||
|
items.push({ label: `${t('mail.sidebar.title')}${suffix}`, to: '/mail' })
|
||||||
|
}
|
||||||
|
if (isDocumentsVisible.value) {
|
||||||
|
items.push({ label: t('sharedFiles.sidebar.title'), to: '/documents' })
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insère les items client après leur ancre (`after`), sinon en fin de liste.
|
||||||
|
function mergeClientItems(base: MalioItem[], extra: ClientItem[]): MalioItem[] {
|
||||||
|
const result = [...base]
|
||||||
|
for (const { after, ...item } of extra) {
|
||||||
|
const idx = after ? result.findIndex((i) => i.to === after) : -1
|
||||||
|
if (idx !== -1) {
|
||||||
|
result.splice(idx + 1, 0, item)
|
||||||
|
} else {
|
||||||
|
result.push(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedSections = computed<MalioSection[]>(() => {
|
||||||
|
// 1. Sections backend (déjà filtrées par permissions), mail retiré (ré-injecté côté client).
|
||||||
|
const backend = new Map<string, MalioSection>()
|
||||||
|
for (const section of sections.value) {
|
||||||
|
backend.set(section.label, {
|
||||||
|
label: t(section.label),
|
||||||
|
icon: section.icon,
|
||||||
|
items: section.items
|
||||||
|
.filter((item) => item.to !== '/mail')
|
||||||
|
.map((item) => ({ label: t(item.label), to: item.to })),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fusion dans l'ordre canonique.
|
||||||
|
const result: MalioSection[] = []
|
||||||
|
for (const key of SECTION_ORDER) {
|
||||||
|
const base = backend.get(key)
|
||||||
|
const extra = clientItemsFor(key)
|
||||||
|
if (base) {
|
||||||
|
base.items = mergeClientItems(base.items, extra)
|
||||||
|
if (base.items.length > 0) {
|
||||||
|
result.push(base)
|
||||||
|
}
|
||||||
|
} else if (extra.length > 0) {
|
||||||
|
result.push({ label: t(key), icon: SECTION_ICON[key] ?? '', items: mergeClientItems([], extra) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Garde-fou : toute section backend hors ordre canonique n'est pas perdue.
|
||||||
|
for (const [key, section] of backend) {
|
||||||
|
if (!(SECTION_ORDER as readonly string[]).includes(key) && section.items.length > 0) {
|
||||||
|
result.push(section)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
const timerStore = useTimerStore()
|
const timerStore = useTimerStore()
|
||||||
|
|
||||||
const baseTitle = ref('Lesstime')
|
const baseTitle = ref('Lesstime')
|
||||||
@@ -269,14 +265,3 @@ function onCompleteSaved() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</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>
|
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
<div class="flex h-full items-center justify-between">
|
<div class="flex h-full items-center justify-between">
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
icon="mdi:menu"
|
icon="mdi:menu"
|
||||||
aria-label="Menu"
|
aria-label="Replier ou déplier le menu"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
icon-size="24"
|
icon-size="24"
|
||||||
button-class="lg:hidden text-white hover:bg-primary-600"
|
button-class="lg:hidden text-white hover:bg-primary-600"
|
||||||
@click="ui.openMobileSidebar()"
|
@click="ui.toggleSidebar()"
|
||||||
/>
|
/>
|
||||||
<div class="hidden items-center gap-2 lg:flex">
|
<div class="hidden items-center gap-2 lg:flex">
|
||||||
<h1 class="text-lg font-bold tracking-tight">Lesstime</h1>
|
<h1 class="text-lg font-bold tracking-tight">Lesstime</h1>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Entête de page standard : source unique du style des titres.
|
||||||
|
Toujours sticky en haut du <main> scrollable : reste visible au scroll.
|
||||||
|
Fond blanc + pt-[38px]/pb-[30px] (au lieu de marges) pour que le contenu
|
||||||
|
défilant soit masqué sous l'entête (espaces haut ET bas compris) et que
|
||||||
|
l'entête soit collée sous l'AppTopNav sans trou.
|
||||||
|
Slots :
|
||||||
|
- défaut : texte du titre
|
||||||
|
- #actions : boutons à droite du titre
|
||||||
|
- #subheader : barre de filtres / onglets rendue SOUS le titre, dans le
|
||||||
|
même bloc sticky (reste donc collée avec le titre). La
|
||||||
|
marge titre -> sous-entête est portée par le contenu passé
|
||||||
|
(ex. mt-4) pour laisser chaque page régler son cas. -->
|
||||||
|
<div class="sticky top-0 z-20 bg-white pt-[38px] pb-[30px]">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<h1 class="text-[30px] font-semibold text-primary-500">
|
||||||
|
<slot/>
|
||||||
|
</h1>
|
||||||
|
<div v-if="$slots.actions" class="shrink-0">
|
||||||
|
<slot name="actions"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<slot name="subheader"/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
<template>
|
|
||||||
<NuxtLink
|
|
||||||
:to="to"
|
|
||||||
class="group/link relative flex items-center transition-colors hover:text-primary-500"
|
|
||||||
:class="linkClasses"
|
|
||||||
:active-class="exact ? '' : activeClass"
|
|
||||||
:exact-active-class="exact ? activeClass : ''"
|
|
||||||
>
|
|
||||||
<Icon :name="icon" :size="sub ? '20' : '24'" class="flex-shrink-0" />
|
|
||||||
<span
|
|
||||||
v-if="!collapsed"
|
|
||||||
class="self-baseline whitespace-nowrap overflow-hidden transition-opacity duration-300"
|
|
||||||
:class="sub ? 'text-sm' : 'text-md'"
|
|
||||||
>
|
|
||||||
{{ label }}
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
v-if="collapsed"
|
|
||||||
class="pointer-events-none absolute left-full z-50 ml-2 rounded-md bg-neutral-800 px-2 py-1 text-xs text-white opacity-0 shadow-lg transition-opacity group-hover/link:pointer-events-auto group-hover/link:opacity-100 whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{{ label }}
|
|
||||||
</div>
|
|
||||||
</NuxtLink>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const props = defineProps<{
|
|
||||||
to: string
|
|
||||||
icon: string
|
|
||||||
label: string
|
|
||||||
collapsed: boolean
|
|
||||||
sub?: boolean
|
|
||||||
exact?: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const activeClass = computed(() => {
|
|
||||||
if (props.collapsed) {
|
|
||||||
return '!text-primary-500 bg-primary-500/10'
|
|
||||||
}
|
|
||||||
return '!text-primary-500 bg-tertiary-500'
|
|
||||||
})
|
|
||||||
|
|
||||||
const linkClasses = computed(() => {
|
|
||||||
if (props.collapsed) {
|
|
||||||
return 'justify-center w-10 h-10 mx-auto my-1 p-2 rounded-lg text-neutral-600 hover:text-primary-500 hover:bg-primary-500/10'
|
|
||||||
}
|
|
||||||
if (props.sub) {
|
|
||||||
return 'gap-3 px-4 py-2 pl-12 text-sm font-semibold text-neutral-700'
|
|
||||||
}
|
|
||||||
return 'gap-3 px-4 py-3 text-md font-semibold text-neutral-700'
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -349,21 +349,29 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"myTasks": "Mes tâches",
|
|
||||||
"general": {
|
"general": {
|
||||||
"section": "Gestion de projet",
|
"section": "Général",
|
||||||
"dashboard": "Tableau de bord",
|
"dashboard": "Tableau de bord",
|
||||||
"myTasks": "Mes tâches",
|
"myTasks": "Mes tâches",
|
||||||
"projects": "Projets",
|
"projects": "Projets",
|
||||||
"timeTracking": "Suivi de temps",
|
"timeTracking": "Suivi de temps",
|
||||||
"mail": "Messagerie"
|
"mail": "Messagerie",
|
||||||
|
"myAbsences": "Mes absences"
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"section": "Outils"
|
||||||
|
},
|
||||||
|
"project": {
|
||||||
|
"kanban": "Kanban",
|
||||||
|
"groups": "Groupes",
|
||||||
|
"archives": "Archives"
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"section": "Administration",
|
"section": "Administration",
|
||||||
"teamAbsences": "Absences équipe",
|
"teamAbsences": "Absences équipe",
|
||||||
"administration": "Administration",
|
|
||||||
"directory": "Répertoire",
|
"directory": "Répertoire",
|
||||||
"reporting": "Rapports"
|
"reporting": "Rapports",
|
||||||
|
"administration": "Administration"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"reporting": {
|
"reporting": {
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-6">
|
<div>
|
||||||
<div class="flex items-center justify-between">
|
<PageHeader>
|
||||||
<h1 class="text-2xl font-bold text-neutral-900">{{ $t('absences.title') }}</h1>
|
{{ $t('absences.title') }}
|
||||||
<MalioButton
|
<template #actions>
|
||||||
:label="$t('absences.newRequest')"
|
<MalioButton
|
||||||
icon-name="mdi:plus"
|
:label="$t('absences.newRequest')"
|
||||||
icon-position="left"
|
icon-name="mdi:plus"
|
||||||
@click="requestDrawerOpen = true"
|
icon-position="left"
|
||||||
/>
|
@click="requestDrawerOpen = true"
|
||||||
</div>
|
/>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
<AbsenceBalanceCards :balances="balances" />
|
<AbsenceBalanceCards :balances="balances" />
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
@@ -65,6 +68,7 @@
|
|||||||
:can-cancel="selected?.status === 'pending'"
|
:can-cancel="selected?.status === 'pending'"
|
||||||
@cancelled="reload"
|
@cancelled="reload"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-6">
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-neutral-900">
|
<PageHeader>
|
||||||
{{ $t("absences.teamTitle") }}
|
{{ $t("absences.teamTitle") }}
|
||||||
</h1>
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
<!-- KPIs -->
|
<!-- KPIs -->
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
<div class="rounded-lg border border-neutral-200 bg-white p-4">
|
<div class="rounded-lg border border-neutral-200 bg-white p-4">
|
||||||
@@ -189,6 +190,7 @@
|
|||||||
:user="selectedEmployee"
|
:user="selectedEmployee"
|
||||||
@saved="loadEmployees"
|
@saved="loadEmployees"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<NuxtLayout name="default">
|
<NuxtLayout name="default">
|
||||||
<div class="mx-auto max-w-lg px-4 py-10">
|
<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>
|
<PageHeader>{{ $t('profile.title') }}</PageHeader>
|
||||||
|
|
||||||
<div class="flex flex-col items-center gap-6 rounded-xl border border-neutral-200 bg-white p-8 shadow-sm">
|
<div class="flex flex-col items-center gap-6 rounded-xl border border-neutral-200 bg-white p-8 shadow-sm">
|
||||||
<!-- Current avatar -->
|
<!-- Current avatar -->
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-6">
|
<div>
|
||||||
<div class="flex items-center gap-3 pt-4">
|
<PageHeader>
|
||||||
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" />
|
<span class="inline-flex items-center gap-3">
|
||||||
<h1 class="text-2xl font-bold text-neutral-900">{{ client?.name ?? '…' }}</h1>
|
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" />
|
||||||
</div>
|
{{ client?.name ?? '…' }}
|
||||||
|
</span>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
<p v-if="loading">{{ $t('common.loading') }}</p>
|
<p v-if="loading">{{ $t('common.loading') }}</p>
|
||||||
<template v-else-if="client">
|
<template v-else-if="client">
|
||||||
<MalioTabList v-model="activeTab" :tabs="tabs">
|
<MalioTabList v-model="activeTab" :tabs="tabs">
|
||||||
@@ -111,6 +114,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</MalioTabList>
|
</MalioTabList>
|
||||||
</template>
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-6">
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-neutral-900">
|
<PageHeader>
|
||||||
{{ $t('directory.title') }}
|
{{ $t('directory.title') }}
|
||||||
</h1>
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
<MalioTabList v-model="activeTab" :tabs="tabs">
|
<MalioTabList v-model="activeTab" :tabs="tabs">
|
||||||
<!-- Clients -->
|
<!-- Clients -->
|
||||||
<template #clients>
|
<template #clients>
|
||||||
@@ -171,6 +172,7 @@
|
|||||||
:message="deleteModalMessage"
|
:message="deleteModalMessage"
|
||||||
@confirm="confirmDelete"
|
@confirm="confirmDelete"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-6">
|
<div>
|
||||||
<div class="flex items-center gap-3 pt-4">
|
<PageHeader>
|
||||||
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" />
|
<span class="inline-flex items-center gap-3">
|
||||||
<h1 class="text-2xl font-bold text-neutral-900">{{ prestataire?.name ?? '…' }}</h1>
|
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" />
|
||||||
</div>
|
{{ prestataire?.name ?? '…' }}
|
||||||
|
</span>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
<p v-if="loading">{{ $t('common.loading') }}</p>
|
<p v-if="loading">{{ $t('common.loading') }}</p>
|
||||||
<template v-else-if="prestataire">
|
<template v-else-if="prestataire">
|
||||||
<MalioTabList v-model="activeTab" :tabs="tabs">
|
<MalioTabList v-model="activeTab" :tabs="tabs">
|
||||||
@@ -111,6 +114,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</MalioTabList>
|
</MalioTabList>
|
||||||
</template>
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-6">
|
<div>
|
||||||
<div class="flex items-center gap-3 pt-4">
|
<PageHeader>
|
||||||
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" />
|
<span class="inline-flex items-center gap-3">
|
||||||
<h1 class="text-2xl font-bold text-neutral-900">{{ prospect?.company ?? '…' }}</h1>
|
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" />
|
||||||
</div>
|
{{ prospect?.company ?? '…' }}
|
||||||
|
</span>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
<p v-if="loading">{{ $t('common.loading') }}</p>
|
<p v-if="loading">{{ $t('common.loading') }}</p>
|
||||||
<template v-else-if="prospect">
|
<template v-else-if="prospect">
|
||||||
<MalioTabList v-model="activeTab" :tabs="tabs">
|
<MalioTabList v-model="activeTab" :tabs="tabs">
|
||||||
@@ -126,6 +129,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</MalioTabList>
|
</MalioTabList>
|
||||||
</template>
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -95,11 +95,13 @@ function handleTaskLinked(_taskId: number): void {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex h-full flex-col overflow-hidden">
|
<div class="flex h-full flex-col overflow-hidden">
|
||||||
<div class="flex flex-shrink-0 items-center justify-between border-b border-neutral-200 bg-white px-4 py-3">
|
<div class="flex-shrink-0">
|
||||||
<h1 class="text-lg font-semibold text-neutral-900">
|
<PageHeader>
|
||||||
{{ t('mail.title') }}
|
{{ t('mail.title') }}
|
||||||
</h1>
|
<template #actions>
|
||||||
<MailRefreshButton />
|
<MailRefreshButton />
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-1 overflow-hidden">
|
<div class="flex flex-1 overflow-hidden">
|
||||||
|
|||||||
@@ -355,9 +355,9 @@ onMounted(async () => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<!-- Header + Filters -->
|
<!-- Header + Filters -->
|
||||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
<PageHeader>
|
||||||
<div class="flex items-center justify-between gap-3">
|
{{ $t('myTasks.title') }}
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('myTasks.title') }}</h1>
|
<template #actions>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
icon-name="mdi:plus"
|
icon-name="mdi:plus"
|
||||||
@@ -378,78 +378,79 @@ onMounted(async () => {
|
|||||||
<Icon name="mdi:format-list-bulleted" size="20" />
|
<Icon name="mdi:format-list-bulleted" size="20" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
<template #subheader>
|
||||||
<div class="mt-4 flex flex-wrap gap-3">
|
<div class="mt-4 flex flex-wrap gap-3">
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedProjectId"
|
v-model="selectedProjectId"
|
||||||
:options="projectOptions"
|
:options="projectOptions"
|
||||||
label="Projet"
|
label="Projet"
|
||||||
:empty-option-label="$t('myTasks.allProjects')"
|
:empty-option-label="$t('myTasks.allProjects')"
|
||||||
group-class="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedGroupId"
|
v-model="selectedGroupId"
|
||||||
:options="groupOptions"
|
:options="groupOptions"
|
||||||
label="Groupe"
|
label="Groupe"
|
||||||
:empty-option-label="$t('myTasks.allGroups')"
|
:empty-option-label="$t('myTasks.allGroups')"
|
||||||
group-class="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedTagId"
|
v-model="selectedTagId"
|
||||||
:options="tagOptions"
|
:options="tagOptions"
|
||||||
label="Type"
|
label="Type"
|
||||||
:empty-option-label="$t('myTasks.allTypes')"
|
:empty-option-label="$t('myTasks.allTypes')"
|
||||||
group-class="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedPriorityId"
|
v-model="selectedPriorityId"
|
||||||
:options="priorityOptions"
|
:options="priorityOptions"
|
||||||
label="Priorité"
|
label="Priorité"
|
||||||
:empty-option-label="$t('myTasks.allPriorities')"
|
:empty-option-label="$t('myTasks.allPriorities')"
|
||||||
group-class="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedEffortId"
|
v-model="selectedEffortId"
|
||||||
:options="effortOptions"
|
:options="effortOptions"
|
||||||
label="Effort"
|
label="Effort"
|
||||||
:empty-option-label="$t('myTasks.allEfforts')"
|
:empty-option-label="$t('myTasks.allEfforts')"
|
||||||
group-class="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedAssigneeId"
|
v-model="selectedAssigneeId"
|
||||||
:options="assigneeOptions"
|
:options="assigneeOptions"
|
||||||
label="Assigné"
|
label="Assigné"
|
||||||
:empty-option-label="$t('myTasks.allAssignees')"
|
:empty-option-label="$t('myTasks.allAssignees')"
|
||||||
group-class="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="sortById"
|
v-model="sortById"
|
||||||
:options="sortOptions"
|
:options="sortOptions"
|
||||||
:label="$t('myTasks.sortBy')"
|
:label="$t('myTasks.sortBy')"
|
||||||
:empty-option-label="$t('myTasks.sortDefault')"
|
:empty-option-label="$t('myTasks.sortDefault')"
|
||||||
group-class="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
<!-- Kanban View — grouped by canonical category -->
|
<!-- Kanban View — grouped by canonical category -->
|
||||||
<div v-if="viewMode === 'kanban'">
|
<div v-if="viewMode === 'kanban'">
|
||||||
<div class="mt-6 flex h-[calc(100vh-260px)] gap-3 overflow-x-auto pb-4">
|
<div class="flex h-[calc(100vh-260px)] gap-3 overflow-x-auto pb-4">
|
||||||
<div
|
<div
|
||||||
v-for="cat in CATEGORIES"
|
v-for="cat in CATEGORIES"
|
||||||
:key="cat"
|
:key="cat"
|
||||||
@@ -509,7 +510,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- List View -->
|
<!-- List View -->
|
||||||
<div v-if="viewMode === 'list'" class="mt-6 flex flex-col gap-2.5 rounded-[10px] bg-tertiary-500 p-2.5">
|
<div v-if="viewMode === 'list'" class="flex flex-col gap-2.5 rounded-[10px] bg-tertiary-500 p-2.5">
|
||||||
<TaskBulkActions
|
<TaskBulkActions
|
||||||
:selected-count="selectedTaskIds.size"
|
:selected-count="selectedTaskIds.size"
|
||||||
:total-count="tasks.length"
|
:total-count="tasks.length"
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
<PageHeader>
|
||||||
<div class="flex items-center justify-between">
|
{{ project?.name ?? '' }} — {{ $t('archive.title') }}
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }} — {{ $t('archive.title') }}</h1>
|
<template #subheader>
|
||||||
</div>
|
<div class="mt-4">
|
||||||
|
<MalioSelect
|
||||||
<div class="mt-4">
|
v-model="selectedGroupId"
|
||||||
<MalioSelect
|
:options="groupFilterOptions"
|
||||||
v-model="selectedGroupId"
|
label="Groupe"
|
||||||
:options="groupFilterOptions"
|
empty-option-label="Tous les groupes"
|
||||||
label="Groupe"
|
group-class="w-64"
|
||||||
empty-option-label="Tous les groupes"
|
/>
|
||||||
group-class="w-64"
|
</div>
|
||||||
/>
|
</template>
|
||||||
</div>
|
</PageHeader>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p v-if="filteredTasks.length === 0" class="text-sm text-neutral-400">
|
<p v-if="filteredTasks.length === 0" class="text-sm text-neutral-400">
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
<PageHeader>{{ project?.name ?? '' }} — Groupes</PageHeader>
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }} — Groupes</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<ProjectGroupTab :project-id="projectId" />
|
<ProjectGroupTab :project-id="projectId" />
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
<PageHeader>
|
||||||
<div class="flex items-center justify-between gap-3">
|
{{ project?.name ?? '' }}
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }}</h1>
|
<template #actions>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
icon-name="mdi:plus"
|
icon-name="mdi:plus"
|
||||||
@@ -30,66 +30,67 @@
|
|||||||
@click="projectDrawerOpen = true"
|
@click="projectDrawerOpen = true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
<template #subheader>
|
||||||
<div class="mt-4 flex flex-wrap gap-3">
|
<div class="mt-4 flex flex-wrap gap-3">
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedGroupId"
|
v-model="selectedGroupId"
|
||||||
:options="groupFilterOptions"
|
:options="groupFilterOptions"
|
||||||
label="Groupe"
|
label="Groupe"
|
||||||
empty-option-label="Tous les groupes"
|
empty-option-label="Tous les groupes"
|
||||||
group-class="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedTagId"
|
v-model="selectedTagId"
|
||||||
:options="tagFilterOptions"
|
:options="tagFilterOptions"
|
||||||
label="Tags"
|
label="Tags"
|
||||||
empty-option-label="Tous les tags"
|
empty-option-label="Tous les tags"
|
||||||
group-class="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedAssigneeId"
|
v-model="selectedAssigneeId"
|
||||||
:options="userFilterOptions"
|
:options="userFilterOptions"
|
||||||
label="User"
|
label="User"
|
||||||
empty-option-label="Tous les users"
|
empty-option-label="Tous les users"
|
||||||
group-class="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-if="viewMode === 'list'"
|
v-if="viewMode === 'list'"
|
||||||
v-model="selectedStatusId"
|
v-model="selectedStatusId"
|
||||||
:options="statusFilterOptions"
|
:options="statusFilterOptions"
|
||||||
label="Status"
|
label="Status"
|
||||||
empty-option-label="Tous les status"
|
empty-option-label="Tous les status"
|
||||||
group-class="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedPriorityId"
|
v-model="selectedPriorityId"
|
||||||
:options="priorityFilterOptions"
|
:options="priorityFilterOptions"
|
||||||
label="Priorité"
|
label="Priorité"
|
||||||
empty-option-label="Toutes"
|
empty-option-label="Toutes"
|
||||||
group-class="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedEffortId"
|
v-model="selectedEffortId"
|
||||||
:options="effortFilterOptions"
|
:options="effortFilterOptions"
|
||||||
label="Effort"
|
label="Effort"
|
||||||
empty-option-label="Tous"
|
empty-option-label="Tous"
|
||||||
group-class="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
<!-- Kanban -->
|
<!-- Kanban -->
|
||||||
<div v-if="viewMode === 'kanban'" class="mt-6 flex h-[calc(100vh-200px)] gap-3 overflow-x-auto pb-4">
|
<div v-if="viewMode === 'kanban'" class="mt-6 flex h-[calc(100vh-200px)] gap-3 overflow-x-auto pb-4">
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
<PageHeader>
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
{{ $t('projects.title') }}
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('projects.title') }}</h1>
|
<template #actions>
|
||||||
<div class="flex items-center gap-2 sm:gap-3">
|
<div class="flex items-center gap-2 sm:gap-3">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
@@ -23,8 +23,8 @@
|
|||||||
<span class="sm:hidden">{{ $t('projects.addProjectShort') }}</span>
|
<span class="sm:hidden">{{ $t('projects.addProjectShort') }}</span>
|
||||||
</MalioButton>
|
</MalioButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</PageHeader>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,60 +1,59 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
<PageHeader>
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
|
{{ $t('reporting.title') }}
|
||||||
{{ $t('reporting.title') }}
|
<template #subheader>
|
||||||
</h1>
|
<!-- Filters -->
|
||||||
|
<div class="mt-4 flex flex-wrap items-end gap-3">
|
||||||
<!-- Filters -->
|
<MalioSelect
|
||||||
<div class="mt-4 flex flex-wrap items-end gap-3">
|
v-model="selectedPeriod"
|
||||||
<MalioSelect
|
:options="periodOptions"
|
||||||
v-model="selectedPeriod"
|
:label="$t('reporting.filters.period')"
|
||||||
:options="periodOptions"
|
group-class="!w-48"
|
||||||
:label="$t('reporting.filters.period')"
|
text-field="text-sm"
|
||||||
group-class="!w-48"
|
text-value="text-sm"
|
||||||
text-field="text-sm"
|
/>
|
||||||
text-value="text-sm"
|
<div class="w-40">
|
||||||
/>
|
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
||||||
<div class="w-40">
|
{{ $t('reporting.filters.from') }}
|
||||||
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
</label>
|
||||||
{{ $t('reporting.filters.from') }}
|
<MalioDate
|
||||||
</label>
|
v-model="customFrom"
|
||||||
<MalioDate
|
:disabled="selectedPeriod !== 'custom'"
|
||||||
v-model="customFrom"
|
group-class="w-full"
|
||||||
:disabled="selectedPeriod !== 'custom'"
|
/>
|
||||||
group-class="w-full"
|
</div>
|
||||||
|
<div class="w-40">
|
||||||
|
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
||||||
|
{{ $t('reporting.filters.to') }}
|
||||||
|
</label>
|
||||||
|
<MalioDate
|
||||||
|
v-model="customTo"
|
||||||
|
:disabled="selectedPeriod !== 'custom'"
|
||||||
|
group-class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<MalioSelect
|
||||||
|
v-model="selectedProjectId"
|
||||||
|
:options="projectOptions"
|
||||||
|
:label="$t('reporting.filters.project')"
|
||||||
|
:empty-option-label="$t('reporting.filters.allProjects')"
|
||||||
|
group-class="!w-44"
|
||||||
|
text-field="text-sm"
|
||||||
|
text-value="text-sm"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
v-model="selectedUserId"
|
||||||
|
:options="userOptions"
|
||||||
|
:label="$t('reporting.filters.user')"
|
||||||
|
:empty-option-label="$t('reporting.filters.allUsers')"
|
||||||
|
group-class="!w-44"
|
||||||
|
text-field="text-sm"
|
||||||
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-40">
|
</template>
|
||||||
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
</PageHeader>
|
||||||
{{ $t('reporting.filters.to') }}
|
|
||||||
</label>
|
|
||||||
<MalioDate
|
|
||||||
v-model="customTo"
|
|
||||||
:disabled="selectedPeriod !== 'custom'"
|
|
||||||
group-class="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<MalioSelect
|
|
||||||
v-model="selectedProjectId"
|
|
||||||
:options="projectOptions"
|
|
||||||
:label="$t('reporting.filters.project')"
|
|
||||||
:empty-option-label="$t('reporting.filters.allProjects')"
|
|
||||||
group-class="!w-44"
|
|
||||||
text-field="text-sm"
|
|
||||||
text-value="text-sm"
|
|
||||||
/>
|
|
||||||
<MalioSelect
|
|
||||||
v-model="selectedUserId"
|
|
||||||
:options="userOptions"
|
|
||||||
:label="$t('reporting.filters.user')"
|
|
||||||
:empty-option-label="$t('reporting.filters.allUsers')"
|
|
||||||
group-class="!w-44"
|
|
||||||
text-field="text-sm"
|
|
||||||
text-value="text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
<div v-if="isLoading" class="mt-12 flex items-center justify-center">
|
<div v-if="isLoading" class="mt-12 flex items-center justify-center">
|
||||||
|
|||||||
@@ -1,101 +1,104 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex min-h-0 flex-1 flex-col">
|
<div class="flex min-h-0 flex-1 flex-col">
|
||||||
<div ref="pageHeaderEl" class="sticky top-8 z-20 flex-shrink-0 bg-white pb-4 sm:top-12">
|
<div ref="pageHeaderEl" class="flex-shrink-0">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<PageHeader>
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">Suivi des temps</h1>
|
Suivi des temps
|
||||||
<MalioButton
|
<template #actions>
|
||||||
icon-name="mdi:plus"
|
<MalioButton
|
||||||
icon-position="left"
|
icon-name="mdi:plus"
|
||||||
button-class="shrink-0"
|
icon-position="left"
|
||||||
@click="openCreateDrawer()"
|
button-class="shrink-0"
|
||||||
>
|
@click="openCreateDrawer()"
|
||||||
<span class="hidden sm:inline">Ajouter une Activité</span>
|
|
||||||
<span class="sm:hidden">Activité</span>
|
|
||||||
</MalioButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative z-30 mt-4 flex flex-wrap items-center gap-3 sm:gap-4">
|
|
||||||
<div class="flex shrink-0 items-center gap-1 h-8">
|
|
||||||
<MalioButtonIcon
|
|
||||||
icon="mdi:chevron-left"
|
|
||||||
aria-label="Précédent"
|
|
||||||
variant="ghost"
|
|
||||||
@click="navigatePrev"
|
|
||||||
/>
|
|
||||||
<DateFilter v-model="selectedDateFilter" :picker-mode="viewMode === 'day' ? 'day' : 'week'" />
|
|
||||||
<h2 class="shrink-0 whitespace-nowrap text-lg font-bold leading-8 text-orange-500">
|
|
||||||
{{ currentMonthLabel }}
|
|
||||||
</h2>
|
|
||||||
<MalioButtonIcon
|
|
||||||
icon="mdi:chevron-right"
|
|
||||||
aria-label="Suivant"
|
|
||||||
variant="ghost"
|
|
||||||
@click="navigateNext"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center rounded-full bg-neutral-100 p-1">
|
|
||||||
<button
|
|
||||||
v-for="mode in (['week', 'day', 'list'] as const)"
|
|
||||||
:key="mode"
|
|
||||||
class="rounded-full px-4 py-1.5 text-sm font-semibold transition-all"
|
|
||||||
:class="viewMode === mode
|
|
||||||
? 'bg-primary-500 text-white shadow-sm'
|
|
||||||
: 'text-neutral-500 hover:text-neutral-700'"
|
|
||||||
@click="viewMode = mode"
|
|
||||||
>
|
>
|
||||||
{{ mode === 'week' ? 'Semaine' : mode === 'day' ? 'Jour' : 'Liste' }}
|
<span class="hidden sm:inline">Ajouter une Activité</span>
|
||||||
</button>
|
<span class="sm:hidden">Activité</span>
|
||||||
</div>
|
</MalioButton>
|
||||||
|
</template>
|
||||||
|
<template #subheader>
|
||||||
|
<div class="relative z-30 mt-4 flex flex-wrap items-center gap-3 sm:gap-4">
|
||||||
|
<div class="flex shrink-0 items-center gap-1 h-8">
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:chevron-left"
|
||||||
|
aria-label="Précédent"
|
||||||
|
variant="ghost"
|
||||||
|
@click="navigatePrev"
|
||||||
|
/>
|
||||||
|
<DateFilter v-model="selectedDateFilter" :picker-mode="viewMode === 'day' ? 'day' : 'week'" />
|
||||||
|
<h2 class="shrink-0 whitespace-nowrap text-lg font-bold leading-8 text-orange-500">
|
||||||
|
{{ currentMonthLabel }}
|
||||||
|
</h2>
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:chevron-right"
|
||||||
|
aria-label="Suivant"
|
||||||
|
variant="ghost"
|
||||||
|
@click="navigateNext"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="[&>div]:!mt-0">
|
<div class="flex items-center rounded-full bg-neutral-100 p-1">
|
||||||
<MalioSelect
|
<button
|
||||||
v-model="selectedUserId"
|
v-for="mode in (['week', 'day', 'list'] as const)"
|
||||||
:options="userOptions"
|
:key="mode"
|
||||||
group-class="!w-36 sm:!w-44"
|
class="rounded-full px-4 py-1.5 text-sm font-semibold transition-all"
|
||||||
text-field="text-sm"
|
:class="viewMode === mode
|
||||||
text-value="text-sm"
|
? 'bg-primary-500 text-white shadow-sm'
|
||||||
label="User"
|
: 'text-neutral-500 hover:text-neutral-700'"
|
||||||
empty-option-label="Tous"
|
@click="viewMode = mode"
|
||||||
/>
|
>
|
||||||
</div>
|
{{ mode === 'week' ? 'Semaine' : mode === 'day' ? 'Jour' : 'Liste' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="[&>div]:!mt-0">
|
<div class="[&>div]:!mt-0">
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedProjectId"
|
v-model="selectedUserId"
|
||||||
:options="projectOptions"
|
:options="userOptions"
|
||||||
empty-option-label="Tous"
|
group-class="!w-36 sm:!w-44"
|
||||||
label="Projet"
|
text-field="text-sm"
|
||||||
group-class="!w-36 sm:!w-44"
|
text-value="text-sm"
|
||||||
text-field="text-sm"
|
label="User"
|
||||||
text-value="text-sm"
|
empty-option-label="Tous"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="[&>div]:!mt-0">
|
<div class="[&>div]:!mt-0">
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedTagId"
|
v-model="selectedProjectId"
|
||||||
:options="tagOptions"
|
:options="projectOptions"
|
||||||
empty-option-label="Tous"
|
empty-option-label="Tous"
|
||||||
label="Tag"
|
label="Projet"
|
||||||
group-class="!w-36 sm:!w-44"
|
group-class="!w-36 sm:!w-44"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MalioButton
|
<div class="[&>div]:!mt-0">
|
||||||
:label="$t('timeEntries.export')"
|
<MalioSelect
|
||||||
variant="secondary"
|
v-model="selectedTagId"
|
||||||
icon-name="mdi:download"
|
:options="tagOptions"
|
||||||
icon-position="left"
|
empty-option-label="Tous"
|
||||||
button-class="w-auto px-4"
|
label="Tag"
|
||||||
@click="exportDrawerOpen = true"
|
group-class="!w-36 sm:!w-44"
|
||||||
/>
|
text-field="text-sm"
|
||||||
</div>
|
text-value="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MalioButton
|
||||||
|
:label="$t('timeEntries.export')"
|
||||||
|
variant="secondary"
|
||||||
|
icon-name="mdi:download"
|
||||||
|
icon-position="left"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
@click="exportDrawerOpen = true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative z-0 mt-4 -mb-24 min-h-0 flex-1">
|
<div class="relative z-0 -mb-24 min-h-0 flex-1">
|
||||||
<TimeEntryList
|
<TimeEntryList
|
||||||
v-if="viewMode === 'list'"
|
v-if="viewMode === 'list'"
|
||||||
:entries="filteredEntries"
|
:entries="filteredEntries"
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ export default defineNuxtConfig({
|
|||||||
'nuxt-toast',
|
'nuxt-toast',
|
||||||
'@nuxtjs/i18n',
|
'@nuxtjs/i18n',
|
||||||
'@nuxt/icon',
|
'@nuxt/icon',
|
||||||
|
// Error tracking GlitchTip : charge le module uniquement si un DSN est fourni
|
||||||
|
// (donc seulement au build prod). En dev, aucun DSN => zero impact.
|
||||||
|
...(process.env.NUXT_PUBLIC_SENTRY_DSN ? ['@sentry/nuxt/module'] : []),
|
||||||
],
|
],
|
||||||
dir: {
|
dir: {
|
||||||
layouts: 'app/layouts',
|
layouts: 'app/layouts',
|
||||||
@@ -56,6 +59,23 @@ export default defineNuxtConfig({
|
|||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
public: {
|
public: {
|
||||||
apiBase: process.env.NUXT_PUBLIC_API_BASE,
|
apiBase: process.env.NUXT_PUBLIC_API_BASE,
|
||||||
|
sentry: {
|
||||||
|
// DSN du projet GlitchTip "lesstime-front" (vide => SDK inerte).
|
||||||
|
dsn: process.env.NUXT_PUBLIC_SENTRY_DSN || '',
|
||||||
|
environment: process.env.NODE_ENV || 'development',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Upload des source maps vers GlitchTip (stacktraces lisibles cote front).
|
||||||
|
// Ne s'execute au build que si un token d'upload est fourni.
|
||||||
|
sourcemap: { client: 'hidden' },
|
||||||
|
sentry: {
|
||||||
|
sourceMapsUploadOptions: {
|
||||||
|
url: process.env.SENTRY_URL,
|
||||||
|
org: process.env.SENTRY_ORG,
|
||||||
|
project: process.env.SENTRY_PROJECT,
|
||||||
|
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
devServer: {
|
devServer: {
|
||||||
|
|||||||
Generated
+890
-136
File diff suppressed because it is too large
Load Diff
@@ -11,11 +11,12 @@
|
|||||||
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@malio/layer-ui": "^1.7.5",
|
"@malio/layer-ui": "^1.7.16",
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/i18n": "^10.2.3",
|
"@nuxtjs/i18n": "^10.2.3",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
"@pinia/nuxt": "^0.11.3",
|
"@pinia/nuxt": "^0.11.3",
|
||||||
|
"@sentry/nuxt": "^10.61.0",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@vuepic/vue-datepicker": "^12.1.0",
|
"@vuepic/vue-datepicker": "^12.1.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
|||||||
+20
-19
@@ -1,24 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
<PageHeader>
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">Administration</h1>
|
Administration
|
||||||
|
<template #subheader>
|
||||||
<div class="mt-6 border-b border-neutral-200 overflow-x-auto">
|
<div class="mt-6 border-b border-neutral-200 overflow-x-auto">
|
||||||
<nav class="flex gap-4 sm:gap-6">
|
<nav class="flex gap-4 sm:gap-6">
|
||||||
<button
|
<button
|
||||||
v-for="tab in visibleTabs"
|
v-for="tab in visibleTabs"
|
||||||
:key="tab.key"
|
:key="tab.key"
|
||||||
class="whitespace-nowrap px-1 pb-3 text-sm font-semibold transition"
|
class="whitespace-nowrap px-1 pb-3 text-sm font-semibold transition"
|
||||||
:class="activeTab === tab.key
|
:class="activeTab === tab.key
|
||||||
? 'border-b-2 border-primary-500 text-primary-500'
|
? 'border-b-2 border-primary-500 text-primary-500'
|
||||||
: 'text-neutral-500 hover:text-neutral-700'"
|
: 'text-neutral-500 hover:text-neutral-700'"
|
||||||
@click="activeTab = tab.key"
|
@click="activeTab = tab.key"
|
||||||
>
|
>
|
||||||
{{ tab.label }}
|
{{ tab.label }}
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<AdminWorkflowTab v-if="activeTab === 'workflows'" />
|
<AdminWorkflowTab v-if="activeTab === 'workflows'" />
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('sharedFiles.title') }}</h1>
|
<PageHeader>{{ $t('sharedFiles.title') }}</PageHeader>
|
||||||
|
|
||||||
<!-- Fil d'Ariane -->
|
<!-- Fil d'Ariane -->
|
||||||
<nav class="mt-4 flex flex-wrap items-center gap-1 text-sm text-neutral-500">
|
<nav class="flex flex-wrap items-center gap-1 text-sm text-neutral-500">
|
||||||
<button class="hover:text-primary-500" @click="openPath('')">{{ $t('sharedFiles.root') }}</button>
|
<button class="hover:text-primary-500" @click="openPath('')">{{ $t('sharedFiles.root') }}</button>
|
||||||
<template v-for="crumb in breadcrumb" :key="crumb.path">
|
<template v-for="crumb in breadcrumb" :key="crumb.path">
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Filtre local + rechargement -->
|
<!-- Filtre local + rechargement -->
|
||||||
<div class="mt-4 flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="max-w-sm flex-1">
|
<div class="max-w-sm flex-1">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="filter"
|
v-model="filter"
|
||||||
|
|||||||
+35
-34
@@ -506,39 +506,40 @@ const lineOptions = {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
<PageHeader>
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('dashboard.title') }}</h1>
|
{{ $t('dashboard.title') }}
|
||||||
|
<template #subheader>
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="mt-4 flex flex-wrap gap-3">
|
<div class="mt-4 flex flex-wrap gap-3">
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedPeriod"
|
v-model="selectedPeriod"
|
||||||
:options="periodOptions"
|
:options="periodOptions"
|
||||||
:label="$t('dashboard.filters.period')"
|
:label="$t('dashboard.filters.period')"
|
||||||
group-class="!w-48"
|
group-class="!w-48"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedProjectId"
|
v-model="selectedProjectId"
|
||||||
:options="projectOptions"
|
:options="projectOptions"
|
||||||
:label="$t('dashboard.filters.project')"
|
:label="$t('dashboard.filters.project')"
|
||||||
:empty-option-label="$t('dashboard.filters.allProjects')"
|
:empty-option-label="$t('dashboard.filters.allProjects')"
|
||||||
group-class="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="selectedUserId"
|
v-model="selectedUserId"
|
||||||
:options="userOptions"
|
:options="userOptions"
|
||||||
:label="$t('dashboard.filters.user')"
|
:label="$t('dashboard.filters.user')"
|
||||||
:empty-option-label="$t('dashboard.filters.allUsers')"
|
:empty-option-label="$t('dashboard.filters.allUsers')"
|
||||||
group-class="!w-40"
|
group-class="!w-40"
|
||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
<div v-if="isLoading" class="mt-12 flex items-center justify-center">
|
<div v-if="isLoading" class="mt-12 flex items-center justify-center">
|
||||||
@@ -547,7 +548,7 @@ const lineOptions = {
|
|||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- KPI Cards -->
|
<!-- KPI Cards -->
|
||||||
<div class="mt-6 grid grid-cols-2 gap-4 lg:grid-cols-4">
|
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
|
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
|
||||||
<p class="text-xs font-medium uppercase tracking-wider text-neutral-400">
|
<p class="text-xs font-medium uppercase tracking-wider text-neutral-400">
|
||||||
{{ $t('dashboard.stats.hoursPeriod') }}
|
{{ $t('dashboard.stats.hoursPeriod') }}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
import * as Sentry from '@sentry/nuxt'
|
||||||
|
|
||||||
|
// Init Sentry cote client (SPA). Le DSN provient du build prod (NUXT_PUBLIC_SENTRY_DSN).
|
||||||
|
// Si le DSN est vide (dev), Sentry.init est un no-op : rien n'est envoye.
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const dsn = config.public.sentry?.dsn
|
||||||
|
|
||||||
|
if (dsn) {
|
||||||
|
Sentry.init({
|
||||||
|
dsn,
|
||||||
|
environment: config.public.sentry?.environment,
|
||||||
|
// Pas d'APM/tracing (hors perimetre ticket #146) : on ne remonte que les erreurs.
|
||||||
|
tracesSampleRate: 0,
|
||||||
|
// Pas de session replay (volume).
|
||||||
|
replaysSessionSampleRate: 0,
|
||||||
|
replaysOnErrorSampleRate: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
export const useUiStore = defineStore('ui', () => {
|
export const useUiStore = defineStore('ui', () => {
|
||||||
const sidebarCollapsed = ref(false)
|
const sidebarCollapsed = ref(false)
|
||||||
const sidebarOpen = ref(false)
|
|
||||||
const darkMode = ref(false)
|
const darkMode = ref(false)
|
||||||
|
|
||||||
if (import.meta.client) {
|
if (import.meta.client) {
|
||||||
@@ -45,13 +44,5 @@ export const useUiStore = defineStore('ui', () => {
|
|||||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||||
}
|
}
|
||||||
|
|
||||||
function openMobileSidebar() {
|
return { sidebarCollapsed, darkMode, toggleSidebar, toggleDarkMode }
|
||||||
sidebarOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeMobileSidebar() {
|
|
||||||
sidebarOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
return { sidebarCollapsed, sidebarOpen, darkMode, toggleSidebar, openMobileSidebar, closeMobileSidebar, toggleDarkMode }
|
|
||||||
})
|
})
|
||||||
|
|||||||
+23
-2
@@ -29,10 +29,26 @@ COPY frontend/package.json frontend/package-lock.json ./
|
|||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
|
|
||||||
|
# Sentry / GlitchTip (front) — fourni au BUILD :
|
||||||
|
# - NUXT_PUBLIC_SENTRY_DSN est bake dans le bundle statique (SPA generee).
|
||||||
|
# - SENTRY_URL/ORG/PROJECT/AUTH_TOKEN servent a uploader les source maps.
|
||||||
|
# Si NUXT_PUBLIC_SENTRY_DSN est vide, le module Sentry n'est pas charge (no-op).
|
||||||
|
ARG NUXT_PUBLIC_SENTRY_DSN=""
|
||||||
|
ARG SENTRY_URL=""
|
||||||
|
ARG SENTRY_ORG=""
|
||||||
|
ARG SENTRY_PROJECT=""
|
||||||
|
ARG SENTRY_AUTH_TOKEN=""
|
||||||
|
|
||||||
ENV CI=1 \
|
ENV CI=1 \
|
||||||
NUXT_TELEMETRY_DISABLED=1 \
|
NUXT_TELEMETRY_DISABLED=1 \
|
||||||
NUXT_PUBLIC_API_BASE=/api \
|
NUXT_PUBLIC_API_BASE=/api \
|
||||||
NUXT_PUBLIC_APP_BASE=/
|
NUXT_PUBLIC_APP_BASE=/ \
|
||||||
|
NUXT_PUBLIC_SENTRY_DSN=$NUXT_PUBLIC_SENTRY_DSN \
|
||||||
|
SENTRY_URL=$SENTRY_URL \
|
||||||
|
SENTRY_ORG=$SENTRY_ORG \
|
||||||
|
SENTRY_PROJECT=$SENTRY_PROJECT \
|
||||||
|
SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN
|
||||||
RUN npm run generate
|
RUN npm run generate
|
||||||
|
|
||||||
# --- Stage 3: Production image ---
|
# --- Stage 3: Production image ---
|
||||||
@@ -40,10 +56,15 @@ FROM php:8.4-fpm AS production
|
|||||||
|
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
|
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
|
||||||
nginx supervisor smbclient \
|
nginx supervisor smbclient ca-certificates \
|
||||||
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
|
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# CA racine interne MALIO (auto-signée) — permet au SDK Sentry/HttpClient de
|
||||||
|
# joindre les services HTTPS internes (ex. GlitchTip sur logs.malio-dev.fr).
|
||||||
|
COPY infra/prod/malio-dev-root-ca.crt /usr/local/share/ca-certificates/malio-dev-root-ca.crt
|
||||||
|
RUN update-ca-certificates
|
||||||
|
|
||||||
# PHP production config
|
# PHP production config
|
||||||
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
|
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ echo "==> Clearing cache..."
|
|||||||
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
||||||
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
||||||
|
|
||||||
|
echo "==> Checking error tracking (GlitchTip)..."
|
||||||
|
SENTRY_DSN_VALUE="$(sudo docker compose exec -T app printenv SENTRY_DSN 2>/dev/null || true)"
|
||||||
|
if [ -n "${SENTRY_DSN_VALUE}" ]; then
|
||||||
|
echo " SENTRY_DSN detecte — erreurs backend envoyees a GlitchTip."
|
||||||
|
else
|
||||||
|
echo " SENTRY_DSN absent/vide — error tracking backend desactive (ajouter SENTRY_DSN dans .env)."
|
||||||
|
fi
|
||||||
|
|
||||||
echo "==> Disabling maintenance mode..."
|
echo "==> Disabling maintenance mode..."
|
||||||
rm -f maintenance.on
|
rm -f maintenance.on
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ services:
|
|||||||
app:
|
app:
|
||||||
image: gitea.malio.fr/malio-dev/lesstime:${LESSTIME_IMAGE_TAG:-latest}
|
image: gitea.malio.fr/malio-dev/lesstime:${LESSTIME_IMAGE_TAG:-latest}
|
||||||
container_name: lesstime-app
|
container_name: lesstime-app
|
||||||
|
# Error tracking backend → GlitchTip : ajouter `SENTRY_DSN=...` (projet
|
||||||
|
# GlitchTip "lesstime-api") dans ce fichier .env. Vide/absent => Sentry inerte.
|
||||||
|
# (Le DSN FRONT et l'upload des source maps sont fournis au BUILD de l'image,
|
||||||
|
# pas ici — voir infra/prod/Dockerfile + .gitea/workflows/build-docker.yml.)
|
||||||
env_file: .env
|
env_file: .env
|
||||||
ports:
|
ports:
|
||||||
- "8081:80"
|
- "8081:80"
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIFZzCCA0+gAwIBAgIUOiZigxwgIgtLipnLnu4eSgItc5MwDQYJKoZIhvcNAQEL
|
||||||
|
BQAwQzELMAkGA1UEBhMCRlIxEjAQBgNVBAoMCU1BTElPLURFVjEgMB4GA1UEAwwX
|
||||||
|
TUFMSU8tREVWIExvY2FsIFJvb3QgQ0EwHhcNMjYwNjI1MTYxMjIwWhcNMzYwNjIy
|
||||||
|
MTYxMjIwWjBDMQswCQYDVQQGEwJGUjESMBAGA1UECgwJTUFMSU8tREVWMSAwHgYD
|
||||||
|
VQQDDBdNQUxJTy1ERVYgTG9jYWwgUm9vdCBDQTCCAiIwDQYJKoZIhvcNAQEBBQAD
|
||||||
|
ggIPADCCAgoCggIBALqHXVWEae9aKtveLfSpxYy9RS0Aslw2Ls9+LWI33lpMRs02
|
||||||
|
QssE9wquf3WGjz8NnHUWl5RM0QHC0DOCCddcbnRBciDRJeTaU43IGdNg+TSY+7aM
|
||||||
|
3t/jysZrpc/eu/udlIs7npCPaOGnRiuGN68Fkf9Q70FtmaASpusUe7J3jKDinznr
|
||||||
|
R2hARplO4OF01tFauu039A4yudLrZTUFTldicuZ6a5U3NhajgfNZA+pyJqvL3tLT
|
||||||
|
lXG3KupPD9BsbWe4zSM96CmyHM22QNlcL+M5XG5+EtDtM07tkDcyxFOsREjQHvSQ
|
||||||
|
NH+7h6G/QBHHKkYJhdyiuvpj6b5tEJBM2PVgy1T2JX5TuOBOLx6HvHLbNjUY/JI5
|
||||||
|
0sIjnHbeybQCOfnKNAwidtnqjAfVg+XJ9UZCiGJOeRJOdN5isvvqEKydsX4ouCTj
|
||||||
|
89kwBbfCJeCS6BiadvNFUwnM0PksV0ovnOiUEEAPHRiP74jZ3IvH95BEwiZzyLpy
|
||||||
|
tXiJMW7cJMaqlT3jNwq3P00irfrpJNy4S1Mg2cBQh5ucv+PcMBfQT8YiarzlTQJo
|
||||||
|
saksh/2C43WH+qIFAL2aeD+rKReVBZcGa1XOBI8FUJTu3rLd37+iS4N2BUKq4fWo
|
||||||
|
FttuX5NOfeU3BRDLlCJ2AXau7o0czVy896R9iZTfBJC95QWD07PdHgoctuexAgMB
|
||||||
|
AAGjUzBRMB0GA1UdDgQWBBRNU0WsMg/pqo5XF/WXx78GrAzD5TAfBgNVHSMEGDAW
|
||||||
|
gBRNU0WsMg/pqo5XF/WXx78GrAzD5TAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3
|
||||||
|
DQEBCwUAA4ICAQBFXsuT7Rm2oJBlWT/RsJtmWr95NoFLHovVDycgM8Vjm+E8hv/m
|
||||||
|
AcSjPjZDmXQLOrN31T/XUAs0nURHxSFgVzdIKpq2gOlGgHkZRMAW/iTON9Cqjn81
|
||||||
|
Arjp5fjAJyFkoCiT3eTOElpteF4NhL8xMFaOg1Y2CEfOYO9OZR7Z38HdB6IArVwr
|
||||||
|
W3Dxq3DPtarCeo1k8SHJmJzUduYCltV8urB43gIiI2Hqd7aAlpkTfDhruKxxr7sJ
|
||||||
|
3/TpemJDCN9m8XMv2QvxqpMwH6EXg/7oqit5k0MvD445f3xt9vZydmV/x6F7u/A/
|
||||||
|
gJitN+ixA4AKv7Lw210vaupiChqdY+78TXgLoPJ2/l2QPWG/R7Fb4yNZ2rEd6lyt
|
||||||
|
KLPxHDcdZetFnyqyaoB2SNtLx9hNUE5G3udU6DkNhDfQlDhqEG4f7GAInOu/cMWE
|
||||||
|
2uiIUEjcGSLM+XrrTFRc1tdXy6hnu+sw5ckvhwJ+kjah/pVGz21/y5a0p42AUznI
|
||||||
|
iN7HBV8YaSkeJLvBPnfakUAat1R98e0l72DucHe8RF44NmZCywpaUBsTpNy+bO2f
|
||||||
|
atqp4/ZEGJJlJ38rLv9bAuwr6d8x6T+m0oHknqtJHcWfO0kr4l3Lxsd8mRpGgmBe
|
||||||
|
zOjqjrat4vSc04Rqic4UV2IEoWCiSS/TSiBx8JAB6Ck0+YR9dUgXVQsFFg==
|
||||||
|
-----END CERTIFICATE-----
|
||||||
@@ -9,4 +9,12 @@ use App\Module\Directory\Domain\Entity\Address;
|
|||||||
interface AddressRepositoryInterface
|
interface AddressRepositoryInterface
|
||||||
{
|
{
|
||||||
public function findById(int $id): ?Address;
|
public function findById(int $id): ?Address;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $criteria
|
||||||
|
* @param null|array<string, string> $orderBy
|
||||||
|
*
|
||||||
|
* @return Address[]
|
||||||
|
*/
|
||||||
|
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,4 +9,12 @@ use App\Module\Directory\Domain\Entity\CommercialReport;
|
|||||||
interface CommercialReportRepositoryInterface
|
interface CommercialReportRepositoryInterface
|
||||||
{
|
{
|
||||||
public function findById(int $id): ?CommercialReport;
|
public function findById(int $id): ?CommercialReport;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $criteria
|
||||||
|
* @param null|array<string, string> $orderBy
|
||||||
|
*
|
||||||
|
* @return CommercialReport[]
|
||||||
|
*/
|
||||||
|
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,4 +9,12 @@ use App\Module\Directory\Domain\Entity\Contact;
|
|||||||
interface ContactRepositoryInterface
|
interface ContactRepositoryInterface
|
||||||
{
|
{
|
||||||
public function findById(int $id): ?Contact;
|
public function findById(int $id): ?Contact;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $criteria
|
||||||
|
* @param null|array<string, string> $orderBy
|
||||||
|
*
|
||||||
|
* @return Contact[]
|
||||||
|
*/
|
||||||
|
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Entity\Address;
|
||||||
|
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||||||
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
#[McpTool(
|
||||||
|
name: 'create-address',
|
||||||
|
description: 'Create an address (admin) attached to exactly one of clientId / prospectId / prestataireId. Country defaults to FR (ISO 3166 alpha-2).'
|
||||||
|
)]
|
||||||
|
class CreateAddressTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly ClientRepositoryInterface $clientRepository,
|
||||||
|
private readonly ProspectRepositoryInterface $prospectRepository,
|
||||||
|
private readonly PrestataireRepositoryInterface $prestataireRepository,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(
|
||||||
|
?int $clientId = null,
|
||||||
|
?int $prospectId = null,
|
||||||
|
?int $prestataireId = null,
|
||||||
|
?string $label = null,
|
||||||
|
?string $street = null,
|
||||||
|
?string $streetComplement = null,
|
||||||
|
?string $postalCode = null,
|
||||||
|
?string $city = null,
|
||||||
|
?string $country = null,
|
||||||
|
): string {
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$parents = array_filter([$clientId, $prospectId, $prestataireId], static fn ($v) => null !== $v);
|
||||||
|
if (1 !== count($parents)) {
|
||||||
|
throw new InvalidArgumentException('Exactly one of clientId, prospectId or prestataireId must be provided.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$address = new Address();
|
||||||
|
|
||||||
|
if (null !== $clientId) {
|
||||||
|
$client = $this->clientRepository->findById($clientId);
|
||||||
|
if (null === $client) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $clientId));
|
||||||
|
}
|
||||||
|
$address->setClient($client);
|
||||||
|
}
|
||||||
|
if (null !== $prospectId) {
|
||||||
|
$prospect = $this->prospectRepository->findById($prospectId);
|
||||||
|
if (null === $prospect) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $prospectId));
|
||||||
|
}
|
||||||
|
$address->setProspect($prospect);
|
||||||
|
}
|
||||||
|
if (null !== $prestataireId) {
|
||||||
|
$prestataire = $this->prestataireRepository->findById($prestataireId);
|
||||||
|
if (null === $prestataire) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Prestataire with ID %d not found.', $prestataireId));
|
||||||
|
}
|
||||||
|
$address->setPrestataire($prestataire);
|
||||||
|
}
|
||||||
|
|
||||||
|
$address->setLabel($label);
|
||||||
|
$address->setStreet($street);
|
||||||
|
$address->setStreetComplement($streetComplement);
|
||||||
|
$address->setPostalCode($postalCode);
|
||||||
|
$address->setCity($city);
|
||||||
|
if (null !== $country) {
|
||||||
|
if (2 !== strlen($country)) {
|
||||||
|
throw new InvalidArgumentException('country must be a 2-letter ISO 3166 alpha-2 code (e.g., FR, BE).');
|
||||||
|
}
|
||||||
|
$address->setCountry(strtoupper($country));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->persist($address);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return json_encode(Serializer::address($address));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Entity\CommercialReport;
|
||||||
|
use App\Module\Directory\Domain\Enum\ReportType;
|
||||||
|
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||||||
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Exception;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
#[McpTool(
|
||||||
|
name: 'create-commercial-report',
|
||||||
|
description: 'Create a commercial report (admin) attached to exactly one of clientId / prospectId / prestataireId. Type defaults to "note". Allowed types: note, call, meeting, email. Date defaults to today if omitted.'
|
||||||
|
)]
|
||||||
|
class CreateCommercialReportTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly ClientRepositoryInterface $clientRepository,
|
||||||
|
private readonly ProspectRepositoryInterface $prospectRepository,
|
||||||
|
private readonly PrestataireRepositoryInterface $prestataireRepository,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(
|
||||||
|
string $subject,
|
||||||
|
?int $clientId = null,
|
||||||
|
?int $prospectId = null,
|
||||||
|
?int $prestataireId = null,
|
||||||
|
?string $body = null,
|
||||||
|
?string $occurredAt = null,
|
||||||
|
?string $type = null,
|
||||||
|
): string {
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$parents = array_filter([$clientId, $prospectId, $prestataireId], static fn ($v) => null !== $v);
|
||||||
|
if (1 !== count($parents)) {
|
||||||
|
throw new InvalidArgumentException('Exactly one of clientId, prospectId or prestataireId must be provided.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$report = new CommercialReport();
|
||||||
|
$report->setSubject($subject);
|
||||||
|
$report->setBody($body);
|
||||||
|
|
||||||
|
if (null !== $clientId) {
|
||||||
|
$client = $this->clientRepository->findById($clientId);
|
||||||
|
if (null === $client) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $clientId));
|
||||||
|
}
|
||||||
|
$report->setClient($client);
|
||||||
|
}
|
||||||
|
if (null !== $prospectId) {
|
||||||
|
$prospect = $this->prospectRepository->findById($prospectId);
|
||||||
|
if (null === $prospect) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $prospectId));
|
||||||
|
}
|
||||||
|
$report->setProspect($prospect);
|
||||||
|
}
|
||||||
|
if (null !== $prestataireId) {
|
||||||
|
$prestataire = $this->prestataireRepository->findById($prestataireId);
|
||||||
|
if (null === $prestataire) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Prestataire with ID %d not found.', $prestataireId));
|
||||||
|
}
|
||||||
|
$report->setPrestataire($prestataire);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$date = null === $occurredAt
|
||||||
|
? new DateTimeImmutable('today')
|
||||||
|
: new DateTimeImmutable($occurredAt);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Invalid occurredAt "%s": %s', $occurredAt, $e->getMessage()));
|
||||||
|
}
|
||||||
|
$report->setOccurredAt($date);
|
||||||
|
|
||||||
|
if (null !== $type) {
|
||||||
|
$typeEnum = ReportType::tryFrom($type);
|
||||||
|
if (null === $typeEnum) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Invalid type "%s". Allowed: note, call, meeting, email.', $type));
|
||||||
|
}
|
||||||
|
$report->setType($typeEnum);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->persist($report);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return json_encode(Serializer::commercialReport($report));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Entity\Contact;
|
||||||
|
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||||||
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
#[McpTool(
|
||||||
|
name: 'create-contact',
|
||||||
|
description: 'Create a contact (admin) attached to exactly one of clientId / prospectId / prestataireId. All fields except the parent are optional.'
|
||||||
|
)]
|
||||||
|
class CreateContactTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly ClientRepositoryInterface $clientRepository,
|
||||||
|
private readonly ProspectRepositoryInterface $prospectRepository,
|
||||||
|
private readonly PrestataireRepositoryInterface $prestataireRepository,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(
|
||||||
|
?int $clientId = null,
|
||||||
|
?int $prospectId = null,
|
||||||
|
?int $prestataireId = null,
|
||||||
|
?string $firstName = null,
|
||||||
|
?string $lastName = null,
|
||||||
|
?string $jobTitle = null,
|
||||||
|
?string $email = null,
|
||||||
|
?string $phonePrimary = null,
|
||||||
|
?string $phoneSecondary = null,
|
||||||
|
): string {
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$parents = array_filter([$clientId, $prospectId, $prestataireId], static fn ($v) => null !== $v);
|
||||||
|
if (1 !== count($parents)) {
|
||||||
|
throw new InvalidArgumentException('Exactly one of clientId, prospectId or prestataireId must be provided.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$contact = new Contact();
|
||||||
|
|
||||||
|
if (null !== $clientId) {
|
||||||
|
$client = $this->clientRepository->findById($clientId);
|
||||||
|
if (null === $client) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $clientId));
|
||||||
|
}
|
||||||
|
$contact->setClient($client);
|
||||||
|
}
|
||||||
|
if (null !== $prospectId) {
|
||||||
|
$prospect = $this->prospectRepository->findById($prospectId);
|
||||||
|
if (null === $prospect) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $prospectId));
|
||||||
|
}
|
||||||
|
$contact->setProspect($prospect);
|
||||||
|
}
|
||||||
|
if (null !== $prestataireId) {
|
||||||
|
$prestataire = $this->prestataireRepository->findById($prestataireId);
|
||||||
|
if (null === $prestataire) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Prestataire with ID %d not found.', $prestataireId));
|
||||||
|
}
|
||||||
|
$contact->setPrestataire($prestataire);
|
||||||
|
}
|
||||||
|
|
||||||
|
$contact->setFirstName($firstName);
|
||||||
|
$contact->setLastName($lastName);
|
||||||
|
$contact->setJobTitle($jobTitle);
|
||||||
|
$contact->setEmail($email);
|
||||||
|
$contact->setPhonePrimary($phonePrimary);
|
||||||
|
$contact->setPhoneSecondary($phoneSecondary);
|
||||||
|
|
||||||
|
$this->entityManager->persist($contact);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return json_encode(Serializer::contact($contact));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Entity\Prestataire;
|
||||||
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
#[McpTool(name: 'create-prestataire', description: 'Create a prestataire / service provider (admin). Only name is required.')]
|
||||||
|
class CreatePrestataireTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(
|
||||||
|
string $name,
|
||||||
|
?string $email = null,
|
||||||
|
?string $phone = null,
|
||||||
|
?string $website = null,
|
||||||
|
): string {
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$prestataire = new Prestataire();
|
||||||
|
$prestataire->setName($name);
|
||||||
|
$prestataire->setEmail($email);
|
||||||
|
$prestataire->setPhone($phone);
|
||||||
|
$prestataire->setWebsite($website);
|
||||||
|
|
||||||
|
$this->entityManager->persist($prestataire);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return json_encode(Serializer::prestataire($prestataire));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
#[McpTool(name: 'delete-address', description: 'Delete an address (admin).')]
|
||||||
|
class DeleteAddressTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AddressRepositoryInterface $addressRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(int $id): string
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$address = $this->addressRepository->findById($id);
|
||||||
|
if (null === $address) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Address with ID %d not found.', $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->remove($address);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return json_encode(['success' => true, 'message' => sprintf('Address #%d deleted.', $id)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
#[McpTool(name: 'delete-commercial-report', description: 'Delete a commercial report (admin). Cascade removes its attached documents.')]
|
||||||
|
class DeleteCommercialReportTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CommercialReportRepositoryInterface $reportRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(int $id): string
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$report = $this->reportRepository->findById($id);
|
||||||
|
if (null === $report) {
|
||||||
|
throw new InvalidArgumentException(sprintf('CommercialReport with ID %d not found.', $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->remove($report);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return json_encode(['success' => true, 'message' => sprintf('CommercialReport #%d deleted.', $id)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
#[McpTool(name: 'delete-contact', description: 'Delete a contact (admin).')]
|
||||||
|
class DeleteContactTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ContactRepositoryInterface $contactRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(int $id): string
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$contact = $this->contactRepository->findById($id);
|
||||||
|
if (null === $contact) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Contact with ID %d not found.', $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->remove($contact);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return json_encode(['success' => true, 'message' => sprintf('Contact #%d deleted.', $id)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
#[McpTool(name: 'delete-prestataire', description: 'Delete a prestataire (admin). Cascade removes its contacts, addresses and reports.')]
|
||||||
|
class DeletePrestataireTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PrestataireRepositoryInterface $prestataireRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(int $id): string
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$prestataire = $this->prestataireRepository->findById($id);
|
||||||
|
if (null === $prestataire) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Prestataire with ID %d not found.', $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = $prestataire->getName();
|
||||||
|
$this->entityManager->remove($prestataire);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return json_encode(['success' => true, 'message' => sprintf('Prestataire "%s" deleted.', $name)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
|
||||||
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
#[McpTool(name: 'get-address', description: 'Get an address by ID.')]
|
||||||
|
class GetAddressTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AddressRepositoryInterface $addressRepository,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(int $id): string
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$address = $this->addressRepository->findById($id);
|
||||||
|
if (null === $address) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Address with ID %d not found.', $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return json_encode(Serializer::address($address));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,10 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
|
||||||
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
|
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
|
||||||
use App\Shared\Infrastructure\Mcp\Serializer;
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use Mcp\Capability\Attribute\McpTool;
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
@@ -13,11 +16,14 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
|||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
#[McpTool(name: 'get-client', description: 'Get a client by ID with full contact details.')]
|
#[McpTool(name: 'get-client', description: 'Get a client by ID, with its linked contacts, addresses, and commercial reports.')]
|
||||||
class GetClientTool
|
class GetClientTool
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ClientRepositoryInterface $clientRepository,
|
private readonly ClientRepositoryInterface $clientRepository,
|
||||||
|
private readonly ContactRepositoryInterface $contactRepository,
|
||||||
|
private readonly AddressRepositoryInterface $addressRepository,
|
||||||
|
private readonly CommercialReportRepositoryInterface $reportRepository,
|
||||||
private readonly Security $security,
|
private readonly Security $security,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -32,6 +38,20 @@ class GetClientTool
|
|||||||
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $id));
|
throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $id));
|
||||||
}
|
}
|
||||||
|
|
||||||
return json_encode(Serializer::client($client));
|
$payload = Serializer::client($client);
|
||||||
|
$payload['contacts'] = array_map(
|
||||||
|
fn ($c) => Serializer::contact($c),
|
||||||
|
$this->contactRepository->findBy(['client' => $client], ['lastName' => 'ASC'])
|
||||||
|
);
|
||||||
|
$payload['addresses'] = array_map(
|
||||||
|
fn ($a) => Serializer::address($a),
|
||||||
|
$this->addressRepository->findBy(['client' => $client], ['id' => 'ASC'])
|
||||||
|
);
|
||||||
|
$payload['reports'] = array_map(
|
||||||
|
fn ($r) => Serializer::commercialReport($r),
|
||||||
|
$this->reportRepository->findBy(['client' => $client], ['occurredAt' => 'DESC'])
|
||||||
|
);
|
||||||
|
|
||||||
|
return json_encode($payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
|
||||||
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
#[McpTool(name: 'get-commercial-report', description: 'Get a commercial report by ID, including its attached documents.')]
|
||||||
|
class GetCommercialReportTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CommercialReportRepositoryInterface $reportRepository,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(int $id): string
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$report = $this->reportRepository->findById($id);
|
||||||
|
if (null === $report) {
|
||||||
|
throw new InvalidArgumentException(sprintf('CommercialReport with ID %d not found.', $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return json_encode(Serializer::commercialReport($report));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
|
||||||
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
#[McpTool(name: 'get-contact', description: 'Get a contact by ID.')]
|
||||||
|
class GetContactTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ContactRepositoryInterface $contactRepository,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(int $id): string
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$contact = $this->contactRepository->findById($id);
|
||||||
|
if (null === $contact) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Contact with ID %d not found.', $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return json_encode(Serializer::contact($contact));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
|
||||||
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
#[McpTool(name: 'get-prestataire', description: 'Get a prestataire by ID, with its linked contacts, addresses, and commercial reports.')]
|
||||||
|
class GetPrestataireTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PrestataireRepositoryInterface $prestataireRepository,
|
||||||
|
private readonly ContactRepositoryInterface $contactRepository,
|
||||||
|
private readonly AddressRepositoryInterface $addressRepository,
|
||||||
|
private readonly CommercialReportRepositoryInterface $reportRepository,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(int $id): string
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$prestataire = $this->prestataireRepository->findById($id);
|
||||||
|
if (null === $prestataire) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Prestataire with ID %d not found.', $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = Serializer::prestataire($prestataire);
|
||||||
|
$payload['contacts'] = array_map(
|
||||||
|
fn ($c) => Serializer::contact($c),
|
||||||
|
$this->contactRepository->findBy(['prestataire' => $prestataire], ['lastName' => 'ASC'])
|
||||||
|
);
|
||||||
|
$payload['addresses'] = array_map(
|
||||||
|
fn ($a) => Serializer::address($a),
|
||||||
|
$this->addressRepository->findBy(['prestataire' => $prestataire], ['id' => 'ASC'])
|
||||||
|
);
|
||||||
|
$payload['reports'] = array_map(
|
||||||
|
fn ($r) => Serializer::commercialReport($r),
|
||||||
|
$this->reportRepository->findBy(['prestataire' => $prestataire], ['occurredAt' => 'DESC'])
|
||||||
|
);
|
||||||
|
|
||||||
|
return json_encode($payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
|
||||||
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||||||
use App\Shared\Infrastructure\Mcp\Serializer;
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
@@ -13,11 +16,14 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
|||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
#[McpTool(name: 'get-prospect', description: 'Get a prospect by ID with full details.')]
|
#[McpTool(name: 'get-prospect', description: 'Get a prospect by ID, with its linked contacts, addresses, and commercial reports.')]
|
||||||
class GetProspectTool
|
class GetProspectTool
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ProspectRepositoryInterface $prospectRepository,
|
private readonly ProspectRepositoryInterface $prospectRepository,
|
||||||
|
private readonly ContactRepositoryInterface $contactRepository,
|
||||||
|
private readonly AddressRepositoryInterface $addressRepository,
|
||||||
|
private readonly CommercialReportRepositoryInterface $reportRepository,
|
||||||
private readonly Security $security,
|
private readonly Security $security,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -32,6 +38,20 @@ class GetProspectTool
|
|||||||
throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $id));
|
throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $id));
|
||||||
}
|
}
|
||||||
|
|
||||||
return json_encode(Serializer::prospect($prospect));
|
$payload = Serializer::prospect($prospect);
|
||||||
|
$payload['contacts'] = array_map(
|
||||||
|
fn ($c) => Serializer::contact($c),
|
||||||
|
$this->contactRepository->findBy(['prospect' => $prospect], ['lastName' => 'ASC'])
|
||||||
|
);
|
||||||
|
$payload['addresses'] = array_map(
|
||||||
|
fn ($a) => Serializer::address($a),
|
||||||
|
$this->addressRepository->findBy(['prospect' => $prospect], ['id' => 'ASC'])
|
||||||
|
);
|
||||||
|
$payload['reports'] = array_map(
|
||||||
|
fn ($r) => Serializer::commercialReport($r),
|
||||||
|
$this->reportRepository->findBy(['prospect' => $prospect], ['occurredAt' => 'DESC'])
|
||||||
|
);
|
||||||
|
|
||||||
|
return json_encode($payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
|
||||||
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
#[McpTool(
|
||||||
|
name: 'list-addresses',
|
||||||
|
description: 'List addresses, optionally filtered by parent (at most one of clientId / prospectId / prestataireId).'
|
||||||
|
)]
|
||||||
|
class ListAddressesTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AddressRepositoryInterface $addressRepository,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(
|
||||||
|
?int $clientId = null,
|
||||||
|
?int $prospectId = null,
|
||||||
|
?int $prestataireId = null,
|
||||||
|
): string {
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$filters = array_filter([$clientId, $prospectId, $prestataireId], static fn ($v) => null !== $v);
|
||||||
|
if (count($filters) > 1) {
|
||||||
|
throw new InvalidArgumentException('At most one of clientId, prospectId or prestataireId can be provided.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$criteria = [];
|
||||||
|
if (null !== $clientId) {
|
||||||
|
$criteria['client'] = $clientId;
|
||||||
|
}
|
||||||
|
if (null !== $prospectId) {
|
||||||
|
$criteria['prospect'] = $prospectId;
|
||||||
|
}
|
||||||
|
if (null !== $prestataireId) {
|
||||||
|
$criteria['prestataire'] = $prestataireId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$addresses = $this->addressRepository->findBy($criteria, ['id' => 'ASC']);
|
||||||
|
|
||||||
|
return json_encode(array_map(fn ($a) => Serializer::address($a), $addresses));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
|
||||||
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
#[McpTool(
|
||||||
|
name: 'list-commercial-reports',
|
||||||
|
description: 'List commercial reports, optionally filtered by parent (at most one of clientId / prospectId / prestataireId). Returns reports ordered by occurredAt DESC.'
|
||||||
|
)]
|
||||||
|
class ListCommercialReportsTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CommercialReportRepositoryInterface $reportRepository,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(
|
||||||
|
?int $clientId = null,
|
||||||
|
?int $prospectId = null,
|
||||||
|
?int $prestataireId = null,
|
||||||
|
): string {
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$filters = array_filter([$clientId, $prospectId, $prestataireId], static fn ($v) => null !== $v);
|
||||||
|
if (count($filters) > 1) {
|
||||||
|
throw new InvalidArgumentException('At most one of clientId, prospectId or prestataireId can be provided.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$criteria = [];
|
||||||
|
if (null !== $clientId) {
|
||||||
|
$criteria['client'] = $clientId;
|
||||||
|
}
|
||||||
|
if (null !== $prospectId) {
|
||||||
|
$criteria['prospect'] = $prospectId;
|
||||||
|
}
|
||||||
|
if (null !== $prestataireId) {
|
||||||
|
$criteria['prestataire'] = $prestataireId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reports = $this->reportRepository->findBy($criteria, ['occurredAt' => 'DESC']);
|
||||||
|
|
||||||
|
return json_encode(array_map(fn ($r) => Serializer::commercialReport($r), $reports));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
|
||||||
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
#[McpTool(
|
||||||
|
name: 'list-contacts',
|
||||||
|
description: 'List contacts, optionally filtered by parent (at most one of clientId / prospectId / prestataireId).'
|
||||||
|
)]
|
||||||
|
class ListContactsTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ContactRepositoryInterface $contactRepository,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(
|
||||||
|
?int $clientId = null,
|
||||||
|
?int $prospectId = null,
|
||||||
|
?int $prestataireId = null,
|
||||||
|
): string {
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$filters = array_filter([$clientId, $prospectId, $prestataireId], static fn ($v) => null !== $v);
|
||||||
|
if (count($filters) > 1) {
|
||||||
|
throw new InvalidArgumentException('At most one of clientId, prospectId or prestataireId can be provided.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$criteria = [];
|
||||||
|
if (null !== $clientId) {
|
||||||
|
$criteria['client'] = $clientId;
|
||||||
|
}
|
||||||
|
if (null !== $prospectId) {
|
||||||
|
$criteria['prospect'] = $prospectId;
|
||||||
|
}
|
||||||
|
if (null !== $prestataireId) {
|
||||||
|
$criteria['prestataire'] = $prestataireId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$contacts = $this->contactRepository->findBy($criteria, ['lastName' => 'ASC']);
|
||||||
|
|
||||||
|
return json_encode(array_map(fn ($c) => Serializer::contact($c), $contacts));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
#[McpTool(name: 'list-prestataires', description: 'List all prestataires with their IDs, names, and emails. Use this to discover valid prestataire IDs.')]
|
||||||
|
class ListPrestatairesTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PrestataireRepositoryInterface $prestataireRepository,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(): string
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$prestataires = $this->prestataireRepository->findBy([], ['name' => 'ASC']);
|
||||||
|
|
||||||
|
return json_encode(array_map(fn ($p) => [
|
||||||
|
'id' => $p->getId(),
|
||||||
|
'name' => $p->getName(),
|
||||||
|
'email' => $p->getEmail(),
|
||||||
|
], $prestataires));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
|
||||||
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
#[McpTool(
|
||||||
|
name: 'update-address',
|
||||||
|
description: 'Update an address (admin). Only provided fields change. Parent (client/prospect/prestataire) is immutable.'
|
||||||
|
)]
|
||||||
|
class UpdateAddressTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AddressRepositoryInterface $addressRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(
|
||||||
|
int $id,
|
||||||
|
?string $label = null,
|
||||||
|
?string $street = null,
|
||||||
|
?string $streetComplement = null,
|
||||||
|
?string $postalCode = null,
|
||||||
|
?string $city = null,
|
||||||
|
?string $country = null,
|
||||||
|
): string {
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$address = $this->addressRepository->findById($id);
|
||||||
|
if (null === $address) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Address with ID %d not found.', $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $label) {
|
||||||
|
$address->setLabel($label);
|
||||||
|
}
|
||||||
|
if (null !== $street) {
|
||||||
|
$address->setStreet($street);
|
||||||
|
}
|
||||||
|
if (null !== $streetComplement) {
|
||||||
|
$address->setStreetComplement($streetComplement);
|
||||||
|
}
|
||||||
|
if (null !== $postalCode) {
|
||||||
|
$address->setPostalCode($postalCode);
|
||||||
|
}
|
||||||
|
if (null !== $city) {
|
||||||
|
$address->setCity($city);
|
||||||
|
}
|
||||||
|
if (null !== $country) {
|
||||||
|
if (2 !== strlen($country)) {
|
||||||
|
throw new InvalidArgumentException('country must be a 2-letter ISO 3166 alpha-2 code (e.g., FR, BE).');
|
||||||
|
}
|
||||||
|
$address->setCountry(strtoupper($country));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return json_encode(Serializer::address($address));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Enum\ReportType;
|
||||||
|
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
|
||||||
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Exception;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
#[McpTool(
|
||||||
|
name: 'update-commercial-report',
|
||||||
|
description: 'Update a commercial report (admin). Only provided fields change. Parent (client/prospect/prestataire) is immutable.'
|
||||||
|
)]
|
||||||
|
class UpdateCommercialReportTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CommercialReportRepositoryInterface $reportRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(
|
||||||
|
int $id,
|
||||||
|
?string $subject = null,
|
||||||
|
?string $body = null,
|
||||||
|
?string $occurredAt = null,
|
||||||
|
?string $type = null,
|
||||||
|
): string {
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$report = $this->reportRepository->findById($id);
|
||||||
|
if (null === $report) {
|
||||||
|
throw new InvalidArgumentException(sprintf('CommercialReport with ID %d not found.', $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $subject) {
|
||||||
|
$report->setSubject($subject);
|
||||||
|
}
|
||||||
|
if (null !== $body) {
|
||||||
|
$report->setBody($body);
|
||||||
|
}
|
||||||
|
if (null !== $occurredAt) {
|
||||||
|
try {
|
||||||
|
$report->setOccurredAt(new DateTimeImmutable($occurredAt));
|
||||||
|
} catch (Exception $e) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Invalid occurredAt "%s": %s', $occurredAt, $e->getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (null !== $type) {
|
||||||
|
$typeEnum = ReportType::tryFrom($type);
|
||||||
|
if (null === $typeEnum) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Invalid type "%s". Allowed: note, call, meeting, email.', $type));
|
||||||
|
}
|
||||||
|
$report->setType($typeEnum);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return json_encode(Serializer::commercialReport($report));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
|
||||||
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
#[McpTool(
|
||||||
|
name: 'update-contact',
|
||||||
|
description: 'Update a contact (admin). Only provided fields change. The parent (client/prospect/prestataire) is immutable — delete then recreate to re-attach.'
|
||||||
|
)]
|
||||||
|
class UpdateContactTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ContactRepositoryInterface $contactRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(
|
||||||
|
int $id,
|
||||||
|
?string $firstName = null,
|
||||||
|
?string $lastName = null,
|
||||||
|
?string $jobTitle = null,
|
||||||
|
?string $email = null,
|
||||||
|
?string $phonePrimary = null,
|
||||||
|
?string $phoneSecondary = null,
|
||||||
|
): string {
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$contact = $this->contactRepository->findById($id);
|
||||||
|
if (null === $contact) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Contact with ID %d not found.', $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $firstName) {
|
||||||
|
$contact->setFirstName($firstName);
|
||||||
|
}
|
||||||
|
if (null !== $lastName) {
|
||||||
|
$contact->setLastName($lastName);
|
||||||
|
}
|
||||||
|
if (null !== $jobTitle) {
|
||||||
|
$contact->setJobTitle($jobTitle);
|
||||||
|
}
|
||||||
|
if (null !== $email) {
|
||||||
|
$contact->setEmail($email);
|
||||||
|
}
|
||||||
|
if (null !== $phonePrimary) {
|
||||||
|
$contact->setPhonePrimary($phonePrimary);
|
||||||
|
}
|
||||||
|
if (null !== $phoneSecondary) {
|
||||||
|
$contact->setPhoneSecondary($phoneSecondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return json_encode(Serializer::contact($contact));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Directory\Infrastructure\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
|
||||||
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
#[McpTool(name: 'update-prestataire', description: 'Update a prestataire (admin). Only provided fields change.')]
|
||||||
|
class UpdatePrestataireTool
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PrestataireRepositoryInterface $prestataireRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(
|
||||||
|
int $id,
|
||||||
|
?string $name = null,
|
||||||
|
?string $email = null,
|
||||||
|
?string $phone = null,
|
||||||
|
?string $website = null,
|
||||||
|
): string {
|
||||||
|
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$prestataire = $this->prestataireRepository->findById($id);
|
||||||
|
if (null === $prestataire) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Prestataire with ID %d not found.', $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $name) {
|
||||||
|
$prestataire->setName($name);
|
||||||
|
}
|
||||||
|
if (null !== $email) {
|
||||||
|
$prestataire->setEmail($email);
|
||||||
|
}
|
||||||
|
if (null !== $phone) {
|
||||||
|
$prestataire->setPhone($phone);
|
||||||
|
}
|
||||||
|
if (null !== $website) {
|
||||||
|
$prestataire->setWebsite($website);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return json_encode(Serializer::prestataire($prestataire));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,13 @@ use App\Module\Absence\Domain\Entity\AbsenceBalance;
|
|||||||
use App\Module\Absence\Domain\Entity\AbsencePolicy;
|
use App\Module\Absence\Domain\Entity\AbsencePolicy;
|
||||||
use App\Module\Absence\Domain\Entity\AbsenceRequest;
|
use App\Module\Absence\Domain\Entity\AbsenceRequest;
|
||||||
use App\Module\Core\Domain\Entity\User;
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use App\Module\Directory\Domain\Entity\Address;
|
||||||
use App\Module\Directory\Domain\Entity\Client;
|
use App\Module\Directory\Domain\Entity\Client;
|
||||||
|
use App\Module\Directory\Domain\Entity\CommercialReport;
|
||||||
|
use App\Module\Directory\Domain\Entity\Contact;
|
||||||
|
use App\Module\Directory\Domain\Entity\Prestataire;
|
||||||
use App\Module\Directory\Domain\Entity\Prospect;
|
use App\Module\Directory\Domain\Entity\Prospect;
|
||||||
|
use App\Module\Directory\Domain\Entity\ReportDocument;
|
||||||
use App\Module\ProjectManagement\Domain\Entity\Project;
|
use App\Module\ProjectManagement\Domain\Entity\Project;
|
||||||
use App\Module\ProjectManagement\Domain\Entity\TaskDocument;
|
use App\Module\ProjectManagement\Domain\Entity\TaskDocument;
|
||||||
use App\Module\ProjectManagement\Domain\Entity\TaskEffort;
|
use App\Module\ProjectManagement\Domain\Entity\TaskEffort;
|
||||||
@@ -18,11 +23,13 @@ use App\Module\ProjectManagement\Domain\Entity\TaskPriority;
|
|||||||
use App\Module\ProjectManagement\Domain\Entity\TaskStatus;
|
use App\Module\ProjectManagement\Domain\Entity\TaskStatus;
|
||||||
use App\Module\ProjectManagement\Domain\Entity\TaskTag;
|
use App\Module\ProjectManagement\Domain\Entity\TaskTag;
|
||||||
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
|
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
|
||||||
|
use App\Shared\Domain\Contract\ClientInterface;
|
||||||
use App\Shared\Domain\Contract\ProjectInterface;
|
use App\Shared\Domain\Contract\ProjectInterface;
|
||||||
use App\Shared\Domain\Contract\TaskInterface;
|
use App\Shared\Domain\Contract\TaskInterface;
|
||||||
use App\Shared\Domain\Contract\TaskTagInterface;
|
use App\Shared\Domain\Contract\TaskTagInterface;
|
||||||
use App\Shared\Domain\Contract\UserInterface;
|
use App\Shared\Domain\Contract\UserInterface;
|
||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
use Doctrine\ORM\EntityNotFoundException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared serialization helpers for MCP tools.
|
* Shared serialization helpers for MCP tools.
|
||||||
@@ -54,11 +61,8 @@ final class Serializer
|
|||||||
'name' => $project->getName(),
|
'name' => $project->getName(),
|
||||||
'description' => $project->getDescription(),
|
'description' => $project->getDescription(),
|
||||||
'color' => $project->getColor(),
|
'color' => $project->getColor(),
|
||||||
'client' => $project->getClient() ? [
|
'client' => self::clientRef($project->getClient()),
|
||||||
'id' => $project->getClient()->getId(),
|
'archived' => $project->isArchived(),
|
||||||
'name' => $project->getClient()->getName(),
|
|
||||||
] : null,
|
|
||||||
'archived' => $project->isArchived(),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,6 +378,98 @@ final class Serializer
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function prestataire(Prestataire $p): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $p->getId(),
|
||||||
|
'name' => $p->getName(),
|
||||||
|
'email' => $p->getEmail(),
|
||||||
|
'phone' => $p->getPhone(),
|
||||||
|
'website' => $p->getWebsite(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function contact(Contact $c): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $c->getId(),
|
||||||
|
'firstName' => $c->getFirstName(),
|
||||||
|
'lastName' => $c->getLastName(),
|
||||||
|
'jobTitle' => $c->getJobTitle(),
|
||||||
|
'email' => $c->getEmail(),
|
||||||
|
'phonePrimary' => $c->getPhonePrimary(),
|
||||||
|
'phoneSecondary' => $c->getPhoneSecondary(),
|
||||||
|
'clientId' => $c->getClient()?->getId(),
|
||||||
|
'prospectId' => $c->getProspect()?->getId(),
|
||||||
|
'prestataireId' => $c->getPrestataire()?->getId(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function address(Address $a): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $a->getId(),
|
||||||
|
'label' => $a->getLabel(),
|
||||||
|
'street' => $a->getStreet(),
|
||||||
|
'streetComplement' => $a->getStreetComplement(),
|
||||||
|
'postalCode' => $a->getPostalCode(),
|
||||||
|
'city' => $a->getCity(),
|
||||||
|
'country' => $a->getCountry(),
|
||||||
|
'clientId' => $a->getClient()?->getId(),
|
||||||
|
'prospectId' => $a->getProspect()?->getId(),
|
||||||
|
'prestataireId' => $a->getPrestataire()?->getId(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function reportDocument(ReportDocument $d): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $d->getId(),
|
||||||
|
'originalName' => $d->getOriginalName(),
|
||||||
|
'mimeType' => $d->getMimeType(),
|
||||||
|
'size' => $d->getSize(),
|
||||||
|
'createdAt' => $d->getCreatedAt()?->format('c'),
|
||||||
|
'uploadedBy' => self::user($d->getUploadedBy()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function commercialReport(CommercialReport $r): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $r->getId(),
|
||||||
|
'subject' => $r->getSubject(),
|
||||||
|
'body' => $r->getBody(),
|
||||||
|
'occurredAt' => $r->getOccurredAt()?->format('Y-m-d'),
|
||||||
|
'type' => $r->getType()->value,
|
||||||
|
'typeLabel' => $r->getType()->label(),
|
||||||
|
'author' => self::user($r->getAuthor()),
|
||||||
|
'clientId' => $r->getClient()?->getId(),
|
||||||
|
'prospectId' => $r->getProspect()?->getId(),
|
||||||
|
'prestataireId' => $r->getPrestataire()?->getId(),
|
||||||
|
'documents' => array_map(
|
||||||
|
fn (ReportDocument $d) => self::reportDocument($d),
|
||||||
|
$r->getDocuments()->toArray()
|
||||||
|
),
|
||||||
|
'createdAt' => $r->getCreatedAt()?->format('c'),
|
||||||
|
'updatedAt' => $r->getUpdatedAt()?->format('c'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
@@ -419,4 +515,31 @@ final class Serializer
|
|||||||
'initialLeaveBalance' => $u->getInitialLeaveBalance(),
|
'initialLeaveBalance' => $u->getInitialLeaveBalance(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely serialize a project's client reference.
|
||||||
|
*
|
||||||
|
* The client association uses ON DELETE SET NULL, but a stale row may leave
|
||||||
|
* a dangling foreign key (e.g. data imported with the constraint disabled).
|
||||||
|
* In that case Doctrine returns an uninitialized proxy whose hydration
|
||||||
|
* throws EntityNotFoundException; we treat such a reference as absent rather
|
||||||
|
* than letting it crash the whole tool.
|
||||||
|
*
|
||||||
|
* @return null|array{id: ?int, name: ?string}
|
||||||
|
*/
|
||||||
|
private static function clientRef(?ClientInterface $client): ?array
|
||||||
|
{
|
||||||
|
if (null === $client) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return [
|
||||||
|
'id' => $client->getId(),
|
||||||
|
'name' => $client->getName(),
|
||||||
|
];
|
||||||
|
} catch (EntityNotFoundException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,6 +124,15 @@
|
|||||||
"bin/phpunit"
|
"bin/phpunit"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"sentry/sentry-symfony": {
|
||||||
|
"version": "5.10",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes-contrib",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "5.0",
|
||||||
|
"ref": "aac2bc5220e9ab5b9e3838a7a4da90e7f74e6148"
|
||||||
|
}
|
||||||
|
},
|
||||||
"symfony/console": {
|
"symfony/console": {
|
||||||
"version": "8.0",
|
"version": "8.0",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Functional\Mcp\Directory;
|
||||||
|
|
||||||
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use App\Module\Directory\Domain\Entity\Client;
|
||||||
|
use App\Module\Directory\Domain\Entity\Prestataire;
|
||||||
|
use App\Module\Directory\Domain\Entity\Prospect;
|
||||||
|
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\CreateAddressTool;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\DeleteAddressTool;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\GetAddressTool;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\ListAddressesTool;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\UpdateAddressTool;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
class AddressLifecycleTest extends KernelTestCase
|
||||||
|
{
|
||||||
|
private EntityManagerInterface $em;
|
||||||
|
private User $admin;
|
||||||
|
private Client $client;
|
||||||
|
private Prospect $prospect;
|
||||||
|
private Prestataire $prestataire;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$this->admin = new User();
|
||||||
|
$this->admin->setUsername('mcp-address-admin-'.uniqid());
|
||||||
|
$this->admin->setPassword('x');
|
||||||
|
$this->admin->setRoles(['ROLE_ADMIN']);
|
||||||
|
$this->em->persist($this->admin);
|
||||||
|
|
||||||
|
$this->client = new Client();
|
||||||
|
$this->client->setName('Test Client '.uniqid());
|
||||||
|
$this->em->persist($this->client);
|
||||||
|
|
||||||
|
$this->prospect = new Prospect();
|
||||||
|
$this->prospect->setCompany('Test Prospect '.uniqid());
|
||||||
|
$this->em->persist($this->prospect);
|
||||||
|
|
||||||
|
$this->prestataire = new Prestataire();
|
||||||
|
$this->prestataire->setName('Test Prestataire '.uniqid());
|
||||||
|
$this->em->persist($this->prestataire);
|
||||||
|
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateRequiresExactlyOneParent(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
($this->createTool())(null, null, null, 'Home');
|
||||||
|
self::fail('Expected error when no parent provided.');
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
self::assertStringContainsString('Exactly one of clientId, prospectId or prestataireId', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
($this->createTool())($this->client->getId(), null, $this->prestataire->getId(), 'Dup');
|
||||||
|
self::fail('Expected error when two parents provided.');
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
self::assertStringContainsString('Exactly one of clientId, prospectId or prestataireId', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateCountryDefaultsToFRWhenOmitted(): void
|
||||||
|
{
|
||||||
|
$data = json_decode(($this->createTool())($this->client->getId(), null, null, 'HQ'), true);
|
||||||
|
|
||||||
|
self::assertSame('FR', $data['country']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateRejectsNonIso3166Country(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('country must be a 2-letter ISO 3166 alpha-2 code');
|
||||||
|
($this->createTool())($this->client->getId(), null, null, 'HQ', null, null, null, null, 'France');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateNormalizesCountryToUppercase(): void
|
||||||
|
{
|
||||||
|
$data = json_decode(($this->createTool())($this->client->getId(), null, null, 'HQ', null, null, null, null, 'be'), true);
|
||||||
|
|
||||||
|
self::assertSame('BE', $data['country']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateOnEachParentWorks(): void
|
||||||
|
{
|
||||||
|
$clientAddr = json_decode(($this->createTool())($this->client->getId(), null, null, 'CHQ'), true);
|
||||||
|
self::assertSame($this->client->getId(), $clientAddr['clientId']);
|
||||||
|
self::assertNull($clientAddr['prospectId']);
|
||||||
|
|
||||||
|
$prospectAddr = json_decode(($this->createTool())(null, $this->prospect->getId(), null, 'PHQ'), true);
|
||||||
|
self::assertSame($this->prospect->getId(), $prospectAddr['prospectId']);
|
||||||
|
|
||||||
|
$prestAddr = json_decode(($this->createTool())(null, null, $this->prestataire->getId(), 'XHQ'), true);
|
||||||
|
self::assertSame($this->prestataire->getId(), $prestAddr['prestataireId']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetReturnsAddress(): void
|
||||||
|
{
|
||||||
|
$created = json_decode(($this->createTool())($this->client->getId(), null, null, 'Office', '1 rue X', null, '75001', 'Paris', 'FR'), true);
|
||||||
|
|
||||||
|
$data = json_decode(($this->getTool())((int) $created['id']), true);
|
||||||
|
|
||||||
|
self::assertSame('Office', $data['label']);
|
||||||
|
self::assertSame('1 rue X', $data['street']);
|
||||||
|
self::assertSame('75001', $data['postalCode']);
|
||||||
|
self::assertSame('Paris', $data['city']);
|
||||||
|
self::assertSame('FR', $data['country']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testListFilteredByClient(): void
|
||||||
|
{
|
||||||
|
($this->createTool())($this->client->getId(), null, null, 'A');
|
||||||
|
($this->createTool())($this->client->getId(), null, null, 'B');
|
||||||
|
($this->createTool())(null, null, $this->prestataire->getId(), 'Z');
|
||||||
|
|
||||||
|
$data = json_decode(($this->listTool())($this->client->getId(), null, null), true);
|
||||||
|
|
||||||
|
self::assertCount(2, $data);
|
||||||
|
self::assertSame('A', $data[0]['label']);
|
||||||
|
self::assertSame('B', $data[1]['label']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateRejectsNonIso3166Country(): void
|
||||||
|
{
|
||||||
|
$created = json_decode(($this->createTool())($this->client->getId(), null, null, 'X'), true);
|
||||||
|
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('country must be a 2-letter ISO 3166 alpha-2 code');
|
||||||
|
($this->updateTool())((int) $created['id'], null, null, null, null, null, 'Belgium');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateOnlyTouchesProvidedFields(): void
|
||||||
|
{
|
||||||
|
$created = json_decode(($this->createTool())($this->client->getId(), null, null, 'Old', '1 rue X', null, '75001', 'Paris', 'FR'), true);
|
||||||
|
|
||||||
|
$data = json_decode(($this->updateTool())((int) $created['id'], 'New', null, null, '75002', null, 'be'), true);
|
||||||
|
|
||||||
|
self::assertSame('New', $data['label']); // changed
|
||||||
|
self::assertSame('1 rue X', $data['street']); // unchanged
|
||||||
|
self::assertSame('75002', $data['postalCode']); // changed
|
||||||
|
self::assertSame('Paris', $data['city']); // unchanged
|
||||||
|
self::assertSame('BE', $data['country']); // changed + uppercased
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDeleteRemovesAddress(): void
|
||||||
|
{
|
||||||
|
$created = json_decode(($this->createTool())($this->client->getId(), null, null, 'Bye'), true);
|
||||||
|
$id = (int) $created['id'];
|
||||||
|
|
||||||
|
$data = json_decode(($this->deleteTool())($id), true);
|
||||||
|
|
||||||
|
self::assertTrue($data['success']);
|
||||||
|
|
||||||
|
$this->em->clear();
|
||||||
|
self::assertNull(self::getContainer()->get(AddressRepositoryInterface::class)->findById($id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function securityFor(bool $admin = true): Security
|
||||||
|
{
|
||||||
|
$security = $this->createMock(Security::class);
|
||||||
|
$security->method('isGranted')->willReturn($admin);
|
||||||
|
$security->method('getUser')->willReturn($admin ? $this->admin : null);
|
||||||
|
|
||||||
|
return $security;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createTool(): CreateAddressTool
|
||||||
|
{
|
||||||
|
$c = self::getContainer();
|
||||||
|
|
||||||
|
return new CreateAddressTool(
|
||||||
|
$this->em,
|
||||||
|
$c->get(ClientRepositoryInterface::class),
|
||||||
|
$c->get(ProspectRepositoryInterface::class),
|
||||||
|
$c->get(PrestataireRepositoryInterface::class),
|
||||||
|
$this->securityFor(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getTool(): GetAddressTool
|
||||||
|
{
|
||||||
|
return new GetAddressTool(
|
||||||
|
self::getContainer()->get(AddressRepositoryInterface::class),
|
||||||
|
$this->securityFor(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function listTool(): ListAddressesTool
|
||||||
|
{
|
||||||
|
return new ListAddressesTool(
|
||||||
|
self::getContainer()->get(AddressRepositoryInterface::class),
|
||||||
|
$this->securityFor(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateTool(): UpdateAddressTool
|
||||||
|
{
|
||||||
|
return new UpdateAddressTool(
|
||||||
|
self::getContainer()->get(AddressRepositoryInterface::class),
|
||||||
|
$this->em,
|
||||||
|
$this->securityFor(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deleteTool(): DeleteAddressTool
|
||||||
|
{
|
||||||
|
return new DeleteAddressTool(
|
||||||
|
self::getContainer()->get(AddressRepositoryInterface::class),
|
||||||
|
$this->em,
|
||||||
|
$this->securityFor(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Functional\Mcp\Directory;
|
||||||
|
|
||||||
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use App\Module\Directory\Domain\Entity\Client;
|
||||||
|
use App\Module\Directory\Domain\Entity\Prestataire;
|
||||||
|
use App\Module\Directory\Domain\Entity\Prospect;
|
||||||
|
use App\Module\Directory\Domain\Enum\ReportType;
|
||||||
|
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\CreateCommercialReportTool;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\DeleteCommercialReportTool;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\GetCommercialReportTool;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\ListCommercialReportsTool;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\UpdateCommercialReportTool;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
class CommercialReportLifecycleTest extends KernelTestCase
|
||||||
|
{
|
||||||
|
private EntityManagerInterface $em;
|
||||||
|
private User $admin;
|
||||||
|
private Client $client;
|
||||||
|
private Prospect $prospect;
|
||||||
|
private Prestataire $prestataire;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$this->admin = new User();
|
||||||
|
$this->admin->setUsername('mcp-report-admin-'.uniqid());
|
||||||
|
$this->admin->setPassword('x');
|
||||||
|
$this->admin->setRoles(['ROLE_ADMIN']);
|
||||||
|
$this->em->persist($this->admin);
|
||||||
|
|
||||||
|
$this->client = new Client();
|
||||||
|
$this->client->setName('Test Client '.uniqid());
|
||||||
|
$this->em->persist($this->client);
|
||||||
|
|
||||||
|
$this->prospect = new Prospect();
|
||||||
|
$this->prospect->setCompany('Test Prospect '.uniqid());
|
||||||
|
$this->em->persist($this->prospect);
|
||||||
|
|
||||||
|
$this->prestataire = new Prestataire();
|
||||||
|
$this->prestataire->setName('Test Prestataire '.uniqid());
|
||||||
|
$this->em->persist($this->prestataire);
|
||||||
|
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateRequiresExactlyOneParent(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
($this->createTool())('subject', null, null, null);
|
||||||
|
self::fail('Expected error when no parent provided.');
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
self::assertStringContainsString('Exactly one of clientId, prospectId or prestataireId', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
($this->createTool())('subject', $this->client->getId(), null, $this->prestataire->getId());
|
||||||
|
self::fail('Expected error when two parents provided.');
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
self::assertStringContainsString('Exactly one of clientId, prospectId or prestataireId', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateRejectsInvalidType(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Invalid type "lunch". Allowed: note, call, meeting, email.');
|
||||||
|
($this->createTool())('Lunch at noon', $this->client->getId(), null, null, null, null, 'lunch');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateAcceptsAllValidTypes(): void
|
||||||
|
{
|
||||||
|
foreach (['note', 'call', 'meeting', 'email'] as $type) {
|
||||||
|
$data = json_decode(
|
||||||
|
($this->createTool())('subject', $this->client->getId(), null, null, null, '2026-01-15', $type),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
self::assertSame($type, $data['type']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateDefaultsTypeToNote(): void
|
||||||
|
{
|
||||||
|
$data = json_decode(($this->createTool())('subject', $this->client->getId(), null, null), true);
|
||||||
|
|
||||||
|
self::assertSame(ReportType::Note->value, $data['type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateRejectsInvalidDate(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Invalid occurredAt "not-a-date"');
|
||||||
|
($this->createTool())('subject', $this->client->getId(), null, null, null, 'not-a-date');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateDefaultsOccurredAtToToday(): void
|
||||||
|
{
|
||||||
|
$data = json_decode(($this->createTool())('subject', $this->client->getId(), null, null), true);
|
||||||
|
|
||||||
|
self::assertSame(new DateTimeImmutable('today')->format('Y-m-d'), $data['occurredAt']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateAutoFillsAuthorFromCurrentUser(): void
|
||||||
|
{
|
||||||
|
$this->loginAdmin();
|
||||||
|
|
||||||
|
$data = json_decode(($this->createTool())('subject', $this->client->getId(), null, null), true);
|
||||||
|
|
||||||
|
self::assertNotNull($data['author']);
|
||||||
|
self::assertSame($this->admin->getId(), $data['author']['id']);
|
||||||
|
self::assertSame($this->admin->getUsername(), $data['author']['username']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetReturnsReport(): void
|
||||||
|
{
|
||||||
|
$created = json_decode(
|
||||||
|
($this->createTool())('My subject', $this->prestataire->getId() ? null : null, null, $this->prestataire->getId(), 'body text', '2026-03-01', 'meeting'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
$data = json_decode(($this->getTool())((int) $created['id']), true);
|
||||||
|
|
||||||
|
self::assertSame('My subject', $data['subject']);
|
||||||
|
self::assertSame('body text', $data['body']);
|
||||||
|
self::assertSame('2026-03-01', $data['occurredAt']);
|
||||||
|
self::assertSame('meeting', $data['type']);
|
||||||
|
self::assertSame($this->prestataire->getId(), $data['prestataireId']);
|
||||||
|
self::assertSame([], $data['documents']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testListOrderedByOccurredAtDesc(): void
|
||||||
|
{
|
||||||
|
($this->createTool())('oldest', $this->client->getId(), null, null, null, '2026-01-01');
|
||||||
|
($this->createTool())('newest', $this->client->getId(), null, null, null, '2026-12-01');
|
||||||
|
($this->createTool())('middle', $this->client->getId(), null, null, null, '2026-06-15');
|
||||||
|
|
||||||
|
$data = json_decode(($this->listTool())($this->client->getId(), null, null), true);
|
||||||
|
|
||||||
|
self::assertCount(3, $data);
|
||||||
|
self::assertSame('newest', $data[0]['subject']);
|
||||||
|
self::assertSame('middle', $data[1]['subject']);
|
||||||
|
self::assertSame('oldest', $data[2]['subject']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testListRejectsMultipleFilters(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('At most one of clientId, prospectId or prestataireId');
|
||||||
|
($this->listTool())($this->client->getId(), $this->prospect->getId(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateChangesTypeAndDate(): void
|
||||||
|
{
|
||||||
|
$created = json_decode(($this->createTool())('s', $this->client->getId(), null, null, null, '2026-01-01', 'note'), true);
|
||||||
|
|
||||||
|
$data = json_decode(($this->updateTool())((int) $created['id'], 'new subject', null, '2026-02-02', 'call'), true);
|
||||||
|
|
||||||
|
self::assertSame('new subject', $data['subject']);
|
||||||
|
self::assertSame('2026-02-02', $data['occurredAt']);
|
||||||
|
self::assertSame('call', $data['type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateRejectsInvalidType(): void
|
||||||
|
{
|
||||||
|
$created = json_decode(($this->createTool())('s', $this->client->getId(), null, null), true);
|
||||||
|
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Invalid type "lunch"');
|
||||||
|
($this->updateTool())((int) $created['id'], null, null, null, 'lunch');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDeleteRemovesReport(): void
|
||||||
|
{
|
||||||
|
$created = json_decode(($this->createTool())('Bye', $this->client->getId(), null, null), true);
|
||||||
|
$id = (int) $created['id'];
|
||||||
|
|
||||||
|
$data = json_decode(($this->deleteTool())($id), true);
|
||||||
|
|
||||||
|
self::assertTrue($data['success']);
|
||||||
|
|
||||||
|
$this->em->clear();
|
||||||
|
self::assertNull(self::getContainer()->get(CommercialReportRepositoryInterface::class)->findById($id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loginAdmin(): void
|
||||||
|
{
|
||||||
|
$token = new UsernamePasswordToken($this->admin, 'main', $this->admin->getRoles());
|
||||||
|
self::getContainer()->get(TokenStorageInterface::class)->setToken($token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function securityFor(bool $admin = true): Security
|
||||||
|
{
|
||||||
|
$security = $this->createMock(Security::class);
|
||||||
|
$security->method('isGranted')->willReturn($admin);
|
||||||
|
$security->method('getUser')->willReturn($admin ? $this->admin : null);
|
||||||
|
|
||||||
|
return $security;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createTool(): CreateCommercialReportTool
|
||||||
|
{
|
||||||
|
$c = self::getContainer();
|
||||||
|
|
||||||
|
return new CreateCommercialReportTool(
|
||||||
|
$this->em,
|
||||||
|
$c->get(ClientRepositoryInterface::class),
|
||||||
|
$c->get(ProspectRepositoryInterface::class),
|
||||||
|
$c->get(PrestataireRepositoryInterface::class),
|
||||||
|
$this->securityFor(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getTool(): GetCommercialReportTool
|
||||||
|
{
|
||||||
|
return new GetCommercialReportTool(
|
||||||
|
self::getContainer()->get(CommercialReportRepositoryInterface::class),
|
||||||
|
$this->securityFor(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function listTool(): ListCommercialReportsTool
|
||||||
|
{
|
||||||
|
return new ListCommercialReportsTool(
|
||||||
|
self::getContainer()->get(CommercialReportRepositoryInterface::class),
|
||||||
|
$this->securityFor(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateTool(): UpdateCommercialReportTool
|
||||||
|
{
|
||||||
|
return new UpdateCommercialReportTool(
|
||||||
|
self::getContainer()->get(CommercialReportRepositoryInterface::class),
|
||||||
|
$this->em,
|
||||||
|
$this->securityFor(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deleteTool(): DeleteCommercialReportTool
|
||||||
|
{
|
||||||
|
return new DeleteCommercialReportTool(
|
||||||
|
self::getContainer()->get(CommercialReportRepositoryInterface::class),
|
||||||
|
$this->em,
|
||||||
|
$this->securityFor(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Functional\Mcp\Directory;
|
||||||
|
|
||||||
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use App\Module\Directory\Domain\Entity\Client;
|
||||||
|
use App\Module\Directory\Domain\Entity\Prestataire;
|
||||||
|
use App\Module\Directory\Domain\Entity\Prospect;
|
||||||
|
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\CreateContactTool;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\DeleteContactTool;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\GetContactTool;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\ListContactsTool;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\UpdateContactTool;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
class ContactLifecycleTest extends KernelTestCase
|
||||||
|
{
|
||||||
|
private EntityManagerInterface $em;
|
||||||
|
private User $admin;
|
||||||
|
private Client $client;
|
||||||
|
private Prospect $prospect;
|
||||||
|
private Prestataire $prestataire;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$this->admin = new User();
|
||||||
|
$this->admin->setUsername('mcp-contact-admin-'.uniqid());
|
||||||
|
$this->admin->setPassword('x');
|
||||||
|
$this->admin->setRoles(['ROLE_ADMIN']);
|
||||||
|
$this->em->persist($this->admin);
|
||||||
|
|
||||||
|
$this->client = new Client();
|
||||||
|
$this->client->setName('Test Client '.uniqid());
|
||||||
|
$this->em->persist($this->client);
|
||||||
|
|
||||||
|
$this->prospect = new Prospect();
|
||||||
|
$this->prospect->setCompany('Test Prospect '.uniqid());
|
||||||
|
$this->em->persist($this->prospect);
|
||||||
|
|
||||||
|
$this->prestataire = new Prestataire();
|
||||||
|
$this->prestataire->setName('Test Prestataire '.uniqid());
|
||||||
|
$this->em->persist($this->prestataire);
|
||||||
|
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateRequiresExactlyOneParent(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
($this->createTool())(null, null, null, 'Anon');
|
||||||
|
self::fail('Expected error when no parent provided.');
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
self::assertStringContainsString('Exactly one of clientId, prospectId or prestataireId', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
($this->createTool())($this->client->getId(), $this->prospect->getId(), null, 'Dup');
|
||||||
|
self::fail('Expected error when two parents provided.');
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
self::assertStringContainsString('Exactly one of clientId, prospectId or prestataireId', $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateWithUnknownClientThrows(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Client with ID 999999 not found.');
|
||||||
|
($this->createTool())(999999, null, null, 'Anon');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateOnEachParentWorks(): void
|
||||||
|
{
|
||||||
|
foreach (
|
||||||
|
[
|
||||||
|
['clientId', $this->client->getId()],
|
||||||
|
['prospectId', $this->prospect->getId()],
|
||||||
|
['prestataireId', $this->prestataire->getId()],
|
||||||
|
] as [$field, $id]
|
||||||
|
) {
|
||||||
|
$args = [null, null, null, 'John', 'Doe-'.$field, 'CTO', 'john@x.test'];
|
||||||
|
$idx = ['clientId' => 0, 'prospectId' => 1, 'prestataireId' => 2][$field];
|
||||||
|
$args[$idx] = $id;
|
||||||
|
|
||||||
|
$data = json_decode(($this->createTool())(...$args), true);
|
||||||
|
self::assertSame('Doe-'.$field, $data['lastName']);
|
||||||
|
self::assertSame($id, $data[$field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetReturnsContact(): void
|
||||||
|
{
|
||||||
|
$created = json_decode(($this->createTool())($this->client->getId(), null, null, 'Jane', 'Smith'), true);
|
||||||
|
|
||||||
|
$data = json_decode(($this->getTool())((int) $created['id']), true);
|
||||||
|
|
||||||
|
self::assertSame('Jane', $data['firstName']);
|
||||||
|
self::assertSame('Smith', $data['lastName']);
|
||||||
|
self::assertSame($this->client->getId(), $data['clientId']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testListFilteredByPrestataire(): void
|
||||||
|
{
|
||||||
|
($this->createTool())(null, null, $this->prestataire->getId(), 'A', 'A-Last');
|
||||||
|
($this->createTool())(null, null, $this->prestataire->getId(), 'B', 'B-Last');
|
||||||
|
($this->createTool())($this->client->getId(), null, null, 'Z', 'Z-Last');
|
||||||
|
|
||||||
|
$data = json_decode(($this->listTool())(null, null, $this->prestataire->getId()), true);
|
||||||
|
|
||||||
|
self::assertCount(2, $data);
|
||||||
|
self::assertSame('A-Last', $data[0]['lastName']);
|
||||||
|
self::assertSame('B-Last', $data[1]['lastName']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testListRejectsMultipleFilters(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('At most one of clientId, prospectId or prestataireId');
|
||||||
|
($this->listTool())($this->client->getId(), $this->prospect->getId(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateOnlyTouchesProvidedFields(): void
|
||||||
|
{
|
||||||
|
$created = json_decode(($this->createTool())(null, null, $this->prestataire->getId(), 'Old', 'Last', 'CTO', 'old@x.test'), true);
|
||||||
|
|
||||||
|
$data = json_decode(($this->updateTool())((int) $created['id'], 'New', null, null, 'new@x.test'), true);
|
||||||
|
|
||||||
|
self::assertSame('New', $data['firstName']); // changed
|
||||||
|
self::assertSame('Last', $data['lastName']); // unchanged
|
||||||
|
self::assertSame('CTO', $data['jobTitle']); // unchanged
|
||||||
|
self::assertSame('new@x.test', $data['email']); // changed
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDeleteRemovesContact(): void
|
||||||
|
{
|
||||||
|
$created = json_decode(($this->createTool())($this->client->getId(), null, null, 'Bye'), true);
|
||||||
|
$id = (int) $created['id'];
|
||||||
|
|
||||||
|
$data = json_decode(($this->deleteTool())($id), true);
|
||||||
|
|
||||||
|
self::assertTrue($data['success']);
|
||||||
|
|
||||||
|
$this->em->clear();
|
||||||
|
self::assertNull(self::getContainer()->get(ContactRepositoryInterface::class)->findById($id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function securityFor(bool $admin = true): Security
|
||||||
|
{
|
||||||
|
$security = $this->createMock(Security::class);
|
||||||
|
$security->method('isGranted')->willReturn($admin);
|
||||||
|
$security->method('getUser')->willReturn($admin ? $this->admin : null);
|
||||||
|
|
||||||
|
return $security;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createTool(): CreateContactTool
|
||||||
|
{
|
||||||
|
$c = self::getContainer();
|
||||||
|
|
||||||
|
return new CreateContactTool(
|
||||||
|
$this->em,
|
||||||
|
$c->get(ClientRepositoryInterface::class),
|
||||||
|
$c->get(ProspectRepositoryInterface::class),
|
||||||
|
$c->get(PrestataireRepositoryInterface::class),
|
||||||
|
$this->securityFor(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getTool(): GetContactTool
|
||||||
|
{
|
||||||
|
return new GetContactTool(
|
||||||
|
self::getContainer()->get(ContactRepositoryInterface::class),
|
||||||
|
$this->securityFor(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function listTool(): ListContactsTool
|
||||||
|
{
|
||||||
|
return new ListContactsTool(
|
||||||
|
self::getContainer()->get(ContactRepositoryInterface::class),
|
||||||
|
$this->securityFor(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateTool(): UpdateContactTool
|
||||||
|
{
|
||||||
|
return new UpdateContactTool(
|
||||||
|
self::getContainer()->get(ContactRepositoryInterface::class),
|
||||||
|
$this->em,
|
||||||
|
$this->securityFor(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deleteTool(): DeleteContactTool
|
||||||
|
{
|
||||||
|
return new DeleteContactTool(
|
||||||
|
self::getContainer()->get(ContactRepositoryInterface::class),
|
||||||
|
$this->em,
|
||||||
|
$this->securityFor(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Functional\Mcp\Directory;
|
||||||
|
|
||||||
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
|
||||||
|
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\CreatePrestataireTool;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\DeletePrestataireTool;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\GetPrestataireTool;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\ListPrestatairesTool;
|
||||||
|
use App\Module\Directory\Infrastructure\Mcp\Tool\UpdatePrestataireTool;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
class PrestataireLifecycleTest extends KernelTestCase
|
||||||
|
{
|
||||||
|
private EntityManagerInterface $em;
|
||||||
|
private User $admin;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$this->admin = new User();
|
||||||
|
$this->admin->setUsername('mcp-prest-admin-'.uniqid());
|
||||||
|
$this->admin->setPassword('x');
|
||||||
|
$this->admin->setRoles(['ROLE_ADMIN']);
|
||||||
|
$this->em->persist($this->admin);
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreatePersistsAllFields(): void
|
||||||
|
{
|
||||||
|
$json = ($this->createTool(admin: true))('ACME Cleaning', 'contact@acme.example', '+33100000000', 'https://acme.example');
|
||||||
|
$data = json_decode($json, true);
|
||||||
|
|
||||||
|
self::assertIsInt($data['id']);
|
||||||
|
self::assertSame('ACME Cleaning', $data['name']);
|
||||||
|
self::assertSame('contact@acme.example', $data['email']);
|
||||||
|
self::assertSame('+33100000000', $data['phone']);
|
||||||
|
self::assertSame('https://acme.example', $data['website']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateRequiresAdmin(): void
|
||||||
|
{
|
||||||
|
$this->expectException(AccessDeniedException::class);
|
||||||
|
($this->createTool(admin: false))('Should not pass');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetReturnsEmptyCollectionsWhenNoChildren(): void
|
||||||
|
{
|
||||||
|
$created = json_decode(($this->createTool(admin: true))('Lonely Prest'), true);
|
||||||
|
|
||||||
|
$json = ($this->getTool(admin: true))((int) $created['id']);
|
||||||
|
$data = json_decode($json, true);
|
||||||
|
|
||||||
|
self::assertSame($created['id'], $data['id']);
|
||||||
|
self::assertSame([], $data['contacts']);
|
||||||
|
self::assertSame([], $data['addresses']);
|
||||||
|
self::assertSame([], $data['reports']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testGetUnknownIdThrows(): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Prestataire with ID 999999 not found.');
|
||||||
|
($this->getTool(admin: true))(999999);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUpdateOnlyTouchesProvidedFields(): void
|
||||||
|
{
|
||||||
|
$created = json_decode(($this->createTool(admin: true))('Before', 'before@x.test', '+33000000000', 'https://before.test'), true);
|
||||||
|
|
||||||
|
$json = ($this->updateTool(admin: true))((int) $created['id'], null, 'after@x.test', null, null);
|
||||||
|
$data = json_decode($json, true);
|
||||||
|
|
||||||
|
self::assertSame('Before', $data['name']); // unchanged
|
||||||
|
self::assertSame('after@x.test', $data['email']); // changed
|
||||||
|
self::assertSame('+33000000000', $data['phone']); // unchanged
|
||||||
|
self::assertSame('https://before.test', $data['website']); // unchanged
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testListReturnsAllPrestatairesOrderedByName(): void
|
||||||
|
{
|
||||||
|
// Unique prefix isolates this test from data leaked by prior PHPUnit
|
||||||
|
// runs (DAMA rollback is not active in this project).
|
||||||
|
$prefix = 'list-test-'.uniqid().'-';
|
||||||
|
($this->createTool(admin: true))($prefix.'Zeta');
|
||||||
|
($this->createTool(admin: true))($prefix.'Alpha');
|
||||||
|
($this->createTool(admin: true))($prefix.'Mu');
|
||||||
|
|
||||||
|
$data = json_decode(($this->listTool(admin: true))(), true);
|
||||||
|
$names = array_values(array_filter(
|
||||||
|
array_column($data, 'name'),
|
||||||
|
fn ($n) => str_starts_with((string) $n, $prefix),
|
||||||
|
));
|
||||||
|
|
||||||
|
self::assertSame([$prefix.'Alpha', $prefix.'Mu', $prefix.'Zeta'], $names);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDeleteRemovesPrestataire(): void
|
||||||
|
{
|
||||||
|
$created = json_decode(($this->createTool(admin: true))('To be removed'), true);
|
||||||
|
$id = (int) $created['id'];
|
||||||
|
|
||||||
|
$json = ($this->deleteTool(admin: true))($id);
|
||||||
|
$data = json_decode($json, true);
|
||||||
|
|
||||||
|
self::assertTrue($data['success']);
|
||||||
|
self::assertStringContainsString('"To be removed"', $data['message']);
|
||||||
|
|
||||||
|
$this->em->clear();
|
||||||
|
self::assertNull(self::getContainer()->get(PrestataireRepositoryInterface::class)->findById($id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function securityFor(bool $admin): Security
|
||||||
|
{
|
||||||
|
$security = $this->createMock(Security::class);
|
||||||
|
$security->method('isGranted')->willReturn($admin);
|
||||||
|
$security->method('getUser')->willReturn($admin ? $this->admin : null);
|
||||||
|
|
||||||
|
return $security;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createTool(bool $admin): CreatePrestataireTool
|
||||||
|
{
|
||||||
|
return new CreatePrestataireTool(
|
||||||
|
$this->em,
|
||||||
|
$this->securityFor($admin),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getTool(bool $admin): GetPrestataireTool
|
||||||
|
{
|
||||||
|
$c = self::getContainer();
|
||||||
|
|
||||||
|
return new GetPrestataireTool(
|
||||||
|
$c->get(PrestataireRepositoryInterface::class),
|
||||||
|
$c->get(ContactRepositoryInterface::class),
|
||||||
|
$c->get(AddressRepositoryInterface::class),
|
||||||
|
$c->get(CommercialReportRepositoryInterface::class),
|
||||||
|
$this->securityFor($admin),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateTool(bool $admin): UpdatePrestataireTool
|
||||||
|
{
|
||||||
|
return new UpdatePrestataireTool(
|
||||||
|
self::getContainer()->get(PrestataireRepositoryInterface::class),
|
||||||
|
$this->em,
|
||||||
|
$this->securityFor($admin),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function listTool(bool $admin): ListPrestatairesTool
|
||||||
|
{
|
||||||
|
return new ListPrestatairesTool(
|
||||||
|
self::getContainer()->get(PrestataireRepositoryInterface::class),
|
||||||
|
$this->securityFor($admin),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deleteTool(bool $admin): DeletePrestataireTool
|
||||||
|
{
|
||||||
|
return new DeletePrestataireTool(
|
||||||
|
self::getContainer()->get(PrestataireRepositoryInterface::class),
|
||||||
|
$this->em,
|
||||||
|
$this->securityFor($admin),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user