Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93852875ad | |||
| bbd8a38c95 | |||
| 0ee164c302 | |||
| d56381b4b8 | |||
| edf5fcc5f5 | |||
| 89ce523019 | |||
| 3a2b268337 | |||
| f676b217bc | |||
| 8bebfe1595 | |||
| 49267ad2fb | |||
| d3abb584a9 | |||
| 98e3990fa5 | |||
| 172f79d348 | |||
| f221976573 | |||
| 133f205393 | |||
| d8d755d4c5 | |||
| 3ea1a31784 | |||
| a2dcab6ec1 | |||
| d55a088e41 | |||
| 95b192858b | |||
| 6fc6eee5b9 | |||
| 7fe427d676 | |||
| 617d70a754 | |||
| a7bf3101c5 | |||
| d68e3d42f3 | |||
| 1dd7053ebd | |||
| e59c5c510a |
@@ -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": [
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Application de gestion de projet. Monorepo Symfony 8 (API Platform 4) + Nuxt 4.
|
|||||||
|
|
||||||
- **Backend** : PHP 8.4, Symfony 8.0, API Platform 4, Doctrine ORM, PostgreSQL 16
|
- **Backend** : PHP 8.4, Symfony 8.0, API Platform 4, Doctrine ORM, PostgreSQL 16
|
||||||
- **Frontend** : Nuxt 4 (SSR off / SPA), Vue 3, Pinia, Tailwind CSS, @malio/layer-ui, nuxt-toast, @nuxtjs/i18n, @nuxt/icon
|
- **Frontend** : Nuxt 4 (SSR off / SPA), Vue 3, Pinia, Tailwind CSS, @malio/layer-ui, nuxt-toast, @nuxtjs/i18n, @nuxt/icon
|
||||||
- **Auth** : JWT HTTP-only cookie (lexik/jwt-authentication-bundle), login à `/login_check`, cookie `BEARER`
|
- **Auth** : JWT HTTP-only cookie (lexik/jwt-authentication-bundle), login à `/login_check`, cookie `BEARER_LESSTIME` (nommé par app pour éviter la collision avec d'autres apps Symfony sur `localhost` en dev)
|
||||||
- **Docker** : PHP-FPM + Node 24, Nginx (port 8082), PostgreSQL (port 5435)
|
- **Docker** : PHP-FPM + Node 24, Nginx (port 8082), PostgreSQL (port 5435)
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -188,7 +190,7 @@ Configuration : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker
|
|||||||
Toutes les routes API sont préfixées `/api` (API Platform).
|
Toutes les routes API sont préfixées `/api` (API Platform).
|
||||||
|
|
||||||
- Documentation auto-générée : **http://localhost:8082/api**
|
- Documentation auto-générée : **http://localhost:8082/api**
|
||||||
- Auth : `POST /login_check` avec `{ username, password }` → cookie JWT `BEARER`
|
- Auth : `POST /login_check` avec `{ username, password }` → cookie JWT `BEARER_LESSTIME`
|
||||||
|
|
||||||
## Serveur MCP
|
## Serveur MCP
|
||||||
|
|
||||||
@@ -217,28 +219,60 @@ Lesstime expose un serveur MCP (Model Context Protocol) permettant aux assistant
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Configuration réseau (HTTP)
|
### Configuration réseau (HTTP) — par poste, hors git
|
||||||
|
|
||||||
|
Le transport HTTP nécessite un **token API** (Bearer), qui est un **secret** : il ne va **jamais**
|
||||||
|
dans le `.mcp.json` versionné (celui-ci ne contient que le serveur STDIO local, sans secret).
|
||||||
|
Chaque développeur configure le serveur HTTP dans sa **config Claude Code locale**.
|
||||||
|
|
||||||
|
**Méthode recommandée (identique sur Fedora, Windows et macOS) :**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude mcp add --transport http --scope user lesstime \
|
||||||
|
http://project.malio-dev.fr/_mcp \
|
||||||
|
--header "Authorization: Bearer <api-token>"
|
||||||
|
```
|
||||||
|
- En prod : `http://project.malio-dev.fr/_mcp`
|
||||||
|
- En réseau local : `http://<ip-serveur>:8082/_mcp`
|
||||||
|
|
||||||
|
**Où c'est stocké** (si tu édites le fichier à la main, sous la clé `mcpServers`) :
|
||||||
|
|
||||||
|
| OS | Fichier de config Claude Code |
|
||||||
|
|----|-------------------------------|
|
||||||
|
| **Fedora / Linux** | `~/.claude.json` |
|
||||||
|
| **Windows** (collègue) | `%USERPROFILE%\.claude.json` (ex. `C:\Users\<user>\.claude.json`) |
|
||||||
|
| **macOS** | `~/.claude.json` |
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"lesstime": {
|
"lesstime": {
|
||||||
"type": "url",
|
"type": "http",
|
||||||
"url": "http://<ip-serveur>:8082/_mcp",
|
"url": "http://project.malio-dev.fr/_mcp",
|
||||||
"headers": {
|
"headers": { "Authorization": "Bearer <api-token>" }
|
||||||
"Authorization": "Bearer <api-token>"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Après modification, relancer la connexion avec `/mcp` dans Claude Code.
|
||||||
|
|
||||||
### Gestion des tokens API
|
### Gestion des tokens API
|
||||||
|
|
||||||
|
Générer / régénérer un token pour un utilisateur :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# En dev (container local)
|
||||||
docker exec -u www-data php-lesstime-fpm php bin/console app:generate-api-token <username>
|
docker exec -u www-data php-lesstime-fpm php bin/console app:generate-api-token <username>
|
||||||
|
|
||||||
|
# En prod (sur le serveur, dans infra/prod)
|
||||||
|
sudo docker compose exec -T -u www-data app php bin/console app:generate-api-token <username>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
⚠️ Le token est **invalidé à chaque reset/reseed de la base**. Symptôme : `/mcp` renvoie
|
||||||
|
`HTTP 401 "Invalid API token"`. Il faut alors le **régénérer** (commande ci-dessus) puis remplacer
|
||||||
|
la valeur `Bearer ...` dans ta config locale (par poste).
|
||||||
|
|
||||||
## Déploiement
|
## Déploiement
|
||||||
|
|
||||||
La prod tourne en **Docker** : l'image est buildée par la CI Gitea sur push de tag `v*`
|
La prod tourne en **Docker** : l'image est buildée par la CI Gitea sur push de tag `v*`
|
||||||
@@ -255,6 +289,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 | `.env` du serveur (`/var/www/lesstime/.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 `.env` du serveur** (`/var/www/lesstime/.env`, chargé via `env_file` ; le repo ne fournit que le template `infra/prod/.env.example`) :
|
||||||
|
```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 le `.env` du serveur (`/var/www/lesstime/.env`).
|
||||||
|
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],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -9,11 +9,15 @@ lexik_jwt_authentication:
|
|||||||
enabled: false
|
enabled: false
|
||||||
cookie:
|
cookie:
|
||||||
enabled: true
|
enabled: true
|
||||||
name: BEARER
|
# Cookie nommé par app (BEARER_LESSTIME) pour éviter la collision avec
|
||||||
|
# d'autres apps Symfony servies sur le même domaine localhost en dev
|
||||||
|
# (ex: Starseed reste sur BEARER) : un cookie `BEARER` partagé se ferait
|
||||||
|
# écraser d'une app à l'autre → déconnexions croisées.
|
||||||
|
name: BEARER_LESSTIME
|
||||||
query_parameter:
|
query_parameter:
|
||||||
enabled: false
|
enabled: false
|
||||||
set_cookies:
|
set_cookies:
|
||||||
BEARER:
|
BEARER_LESSTIME:
|
||||||
lifetime: '%env(int:JWT_COOKIE_TTL)%'
|
lifetime: '%env(int:JWT_COOKIE_TTL)%'
|
||||||
samesite: lax
|
samesite: lax
|
||||||
path: /
|
path: /
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ security:
|
|||||||
pattern: ^/login_check
|
pattern: ^/login_check
|
||||||
stateless: true
|
stateless: true
|
||||||
provider: app_user_provider
|
provider: app_user_provider
|
||||||
|
user_checker: App\Module\Core\Infrastructure\Security\ArchivedUserChecker
|
||||||
login_throttling:
|
login_throttling:
|
||||||
max_attempts: 5
|
max_attempts: 5
|
||||||
interval: '1 minute'
|
interval: '1 minute'
|
||||||
@@ -41,13 +42,14 @@ security:
|
|||||||
pattern: ^/api
|
pattern: ^/api
|
||||||
stateless: true
|
stateless: true
|
||||||
provider: app_user_provider
|
provider: app_user_provider
|
||||||
|
user_checker: App\Module\Core\Infrastructure\Security\ArchivedUserChecker
|
||||||
jwt: ~
|
jwt: ~
|
||||||
logout:
|
logout:
|
||||||
path: /api/logout
|
path: /api/logout
|
||||||
target: /login
|
target: /login
|
||||||
enable_csrf: false
|
enable_csrf: false
|
||||||
delete_cookies:
|
delete_cookies:
|
||||||
BEARER:
|
BEARER_LESSTIME:
|
||||||
path: /
|
path: /
|
||||||
|
|
||||||
# Activate different ways to authenticate:
|
# Activate different ways to authenticate:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -125,6 +125,10 @@ services:
|
|||||||
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 }
|
||||||
|
|
||||||
|
App\Module\ProjectManagement\Infrastructure\EventListener\ProjectDefaultWorkflowListener:
|
||||||
|
tags:
|
||||||
|
- { name: doctrine.orm.entity_listener, entity: 'App\Module\ProjectManagement\Domain\Entity\Project', event: prePersist }
|
||||||
|
|
||||||
App\Module\Directory\Infrastructure\ApiPlatform\State\ReportDocumentProcessor:
|
App\Module\Directory\Infrastructure\ApiPlatform\State\ReportDocumentProcessor:
|
||||||
arguments:
|
arguments:
|
||||||
$uploadDir: '%task_document_upload_dir%'
|
$uploadDir: '%task_document_upload_dir%'
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.4.40'
|
app.version: '0.4.48'
|
||||||
|
|||||||
@@ -11,15 +11,33 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<MalioCheckbox
|
||||||
|
v-model="showArchived"
|
||||||
|
:label="$t('users.showArchived')"
|
||||||
|
:reserve-message-space="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:items="items"
|
:items="items"
|
||||||
:loading="isLoading"
|
:loading="isLoading"
|
||||||
empty-message="Aucun utilisateur trouvé."
|
empty-message="Aucun utilisateur trouvé."
|
||||||
deletable
|
|
||||||
@row-click="openEdit"
|
@row-click="openEdit"
|
||||||
@delete="(item) => handleDelete(item.id)"
|
|
||||||
>
|
>
|
||||||
|
<template #cell-username="{ item }">
|
||||||
|
<span :class="{ 'text-neutral-400 line-through': item.archived }">
|
||||||
|
{{ item.username }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="item.archived"
|
||||||
|
class="ml-2 rounded-full bg-red-100 px-2 py-0.5 text-xs font-semibold text-red-700"
|
||||||
|
>
|
||||||
|
{{ $t('users.archivedBadge') }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #cell-roles="{ item }">
|
<template #cell-roles="{ item }">
|
||||||
<span
|
<span
|
||||||
v-for="role in item.roles"
|
v-for="role in item.roles"
|
||||||
@@ -29,6 +47,27 @@
|
|||||||
{{ role }}
|
{{ role }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #actions="{ item }">
|
||||||
|
<MalioButtonIcon
|
||||||
|
v-if="item.archived"
|
||||||
|
icon="mdi:restore"
|
||||||
|
:aria-label="$t('users.restore')"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="20"
|
||||||
|
button-class="text-neutral-400 hover:text-primary-500"
|
||||||
|
@click.stop="handleRestore(item)"
|
||||||
|
/>
|
||||||
|
<MalioButtonIcon
|
||||||
|
v-else-if="item.id !== currentUserId"
|
||||||
|
icon="mdi:delete-outline"
|
||||||
|
:aria-label="$t('users.archive')"
|
||||||
|
variant="ghost"
|
||||||
|
icon-size="20"
|
||||||
|
button-class="text-neutral-400 hover:text-red-500"
|
||||||
|
@click.stop="openArchiveConfirm(item)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
|
|
||||||
<UserDrawer
|
<UserDrawer
|
||||||
@@ -36,12 +75,19 @@
|
|||||||
:item="selectedItem"
|
:item="selectedItem"
|
||||||
@saved="onSaved"
|
@saved="onSaved"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmArchiveUserModal
|
||||||
|
v-model="archiveConfirmOpen"
|
||||||
|
:username="userToArchive?.username ?? ''"
|
||||||
|
@confirm="confirmArchive"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { UserData } from '~/services/dto/user-data'
|
import type { UserData } from '~/services/dto/user-data'
|
||||||
import { useUserService } from '~/services/users'
|
import { useUserService } from '~/services/users'
|
||||||
|
import { useAuthStore } from '~/shared/stores/auth'
|
||||||
|
|
||||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||||
|
|
||||||
@@ -50,16 +96,27 @@ const columns: DataTableColumn[] = [
|
|||||||
{ key: 'roles', label: 'Rôles' },
|
{ key: 'roles', label: 'Rôles' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const { getAll, remove } = useUserService()
|
const { getAll, getArchived, remove, restore } = useUserService()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const currentUserId = computed(() => authStore.user?.id)
|
||||||
|
|
||||||
const items = ref<UserData[]>([])
|
const items = ref<UserData[]>([])
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
const drawerOpen = ref(false)
|
const drawerOpen = ref(false)
|
||||||
const selectedItem = ref<UserData | null>(null)
|
const selectedItem = ref<UserData | null>(null)
|
||||||
|
const showArchived = ref(false)
|
||||||
|
const archiveConfirmOpen = ref(false)
|
||||||
|
const userToArchive = ref<UserData | null>(null)
|
||||||
|
|
||||||
async function loadItems() {
|
async function loadItems() {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
items.value = await getAll()
|
if (showArchived.value) {
|
||||||
|
const [active, archived] = await Promise.all([getAll(), getArchived()])
|
||||||
|
items.value = [...active, ...archived]
|
||||||
|
} else {
|
||||||
|
items.value = await getAll()
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -75,8 +132,23 @@ function openEdit(item: UserData) {
|
|||||||
drawerOpen.value = true
|
drawerOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(id: number) {
|
function openArchiveConfirm(item: UserData) {
|
||||||
await remove(id)
|
userToArchive.value = item
|
||||||
|
archiveConfirmOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmArchive() {
|
||||||
|
if (!userToArchive.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await remove(userToArchive.value.id)
|
||||||
|
archiveConfirmOpen.value = false
|
||||||
|
userToArchive.value = null
|
||||||
|
await loadItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRestore(item: UserData) {
|
||||||
|
await restore(item.id)
|
||||||
await loadItems()
|
await loadItems()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +156,10 @@ async function onSaved() {
|
|||||||
await loadItems()
|
await loadItems()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(showArchived, () => {
|
||||||
|
loadItems()
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadItems()
|
loadItems()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Teleport v-if="modelValue" to="body">
|
|
||||||
<Transition name="modal" appear>
|
|
||||||
<div class="fixed inset-0 z-[70] flex items-center justify-center">
|
|
||||||
<div class="absolute inset-0 bg-black/30" @click.stop="cancel" />
|
|
||||||
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
|
||||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('directory.reports.confirmDeleteTitle') }}</h3>
|
|
||||||
<p class="mt-3 text-sm text-neutral-600">
|
|
||||||
{{ $t('directory.reports.confirmDeleteMessage') }}
|
|
||||||
</p>
|
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
|
||||||
<MalioButton
|
|
||||||
variant="tertiary"
|
|
||||||
:label="$t('common.cancel')"
|
|
||||||
button-class="w-auto px-4"
|
|
||||||
:disabled="busy"
|
|
||||||
@click="cancel"
|
|
||||||
/>
|
|
||||||
<MalioButton
|
|
||||||
variant="danger"
|
|
||||||
:label="$t('common.delete')"
|
|
||||||
button-class="w-auto px-4"
|
|
||||||
:disabled="busy"
|
|
||||||
@click="$emit('confirm')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const props = defineProps<{
|
|
||||||
modelValue: boolean
|
|
||||||
// Suppression en cours : on désactive les actions pour éviter un double envoi.
|
|
||||||
busy?: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'update:modelValue', value: boolean): void
|
|
||||||
(e: 'confirm'): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
function cancel() {
|
|
||||||
if (props.busy) return
|
|
||||||
emit('update:modelValue', false)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.modal-enter-active,
|
|
||||||
.modal-leave-active {
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-enter-from,
|
|
||||||
.modal-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
+5
-6
@@ -4,20 +4,20 @@
|
|||||||
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
<div class="absolute inset-0 bg-black/30" @click="cancel" />
|
<div class="absolute inset-0 bg-black/30" @click="cancel" />
|
||||||
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||||
<h3 class="text-lg font-bold text-neutral-900">{{ title }}</h3>
|
<h3 class="text-lg font-bold text-neutral-900">{{ $t('users.archiveConfirmTitle') }}</h3>
|
||||||
<p class="mt-3 text-sm text-neutral-600">
|
<p class="mt-3 text-sm text-neutral-600">
|
||||||
{{ message }}
|
{{ $t('users.archiveConfirmMessage', { username }) }}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
:label="$t('common.cancel')"
|
label="Annuler"
|
||||||
button-class="w-auto px-4"
|
button-class="w-auto px-4"
|
||||||
@click="cancel"
|
@click="cancel"
|
||||||
/>
|
/>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
variant="danger"
|
variant="danger"
|
||||||
:label="$t('common.delete')"
|
:label="$t('users.archive')"
|
||||||
button-class="w-auto px-4"
|
button-class="w-auto px-4"
|
||||||
@click="$emit('confirm')"
|
@click="$emit('confirm')"
|
||||||
/>
|
/>
|
||||||
@@ -31,8 +31,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
title: string
|
username: string
|
||||||
message: string
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -194,8 +194,16 @@
|
|||||||
"created": "Utilisateur créé avec succès.",
|
"created": "Utilisateur créé avec succès.",
|
||||||
"updated": "Utilisateur mis à jour avec succès.",
|
"updated": "Utilisateur mis à jour avec succès.",
|
||||||
"deleted": "Utilisateur supprimé avec succès.",
|
"deleted": "Utilisateur supprimé avec succès.",
|
||||||
|
"archived": "Utilisateur archivé avec succès.",
|
||||||
|
"restored": "Utilisateur restauré avec succès.",
|
||||||
"addUser": "Ajouter un utilisateur",
|
"addUser": "Ajouter un utilisateur",
|
||||||
"editUser": "Modifier un utilisateur"
|
"editUser": "Modifier un utilisateur",
|
||||||
|
"archivedBadge": "Archivé",
|
||||||
|
"showArchived": "Afficher les utilisateurs archivés",
|
||||||
|
"archive": "Archiver",
|
||||||
|
"restore": "Restaurer",
|
||||||
|
"archiveConfirmTitle": "Archiver l'utilisateur",
|
||||||
|
"archiveConfirmMessage": "Êtes-vous sûr de vouloir archiver l'utilisateur « {username} » ? Son compte sera désactivé (il ne pourra plus se connecter), mais ses données et son historique restent conservés. Vous pourrez le restaurer plus tard."
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
"roles": {
|
"roles": {
|
||||||
@@ -424,6 +432,7 @@
|
|||||||
"edit": "Modifier",
|
"edit": "Modifier",
|
||||||
"delete": "Supprimer",
|
"delete": "Supprimer",
|
||||||
"add": "Ajouter",
|
"add": "Ajouter",
|
||||||
|
"actions": "Action",
|
||||||
"loading": "Chargement...",
|
"loading": "Chargement...",
|
||||||
"archived": "Archivé",
|
"archived": "Archivé",
|
||||||
"noClient": "Aucun client",
|
"noClient": "Aucun client",
|
||||||
@@ -918,6 +927,9 @@
|
|||||||
"editProspect": "Modifier un prospect",
|
"editProspect": "Modifier un prospect",
|
||||||
"convert": "Convertir en client",
|
"convert": "Convertir en client",
|
||||||
"alreadyConverted": "Déjà converti en client",
|
"alreadyConverted": "Déjà converti en client",
|
||||||
|
"convertConfirmTitle": "Convertir le prospect",
|
||||||
|
"convertConfirmMessage": "Êtes-vous sûr de vouloir convertir le prospect « {name} » en client ? Le prospect deviendra un client.",
|
||||||
|
"convertConfirm": "Convertir",
|
||||||
"deleteConfirmTitle": "Supprimer le prospect",
|
"deleteConfirmTitle": "Supprimer le prospect",
|
||||||
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer le prospect « {name} » ? Cette action est irréversible.",
|
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer le prospect « {name} » ? Cette action est irréversible.",
|
||||||
"fields": {
|
"fields": {
|
||||||
@@ -1000,10 +1012,12 @@
|
|||||||
"empty": "Aucun prestataire trouvé."
|
"empty": "Aucun prestataire trouvé."
|
||||||
},
|
},
|
||||||
"contacts": {
|
"contacts": {
|
||||||
"add": "Ajouter un contact",
|
"add": "Nouveau contact",
|
||||||
"item": "Contact {n}",
|
"item": "Contact {n}",
|
||||||
"saved": "Contact enregistré.",
|
"saved": "Contact enregistré.",
|
||||||
"deleted": "Contact supprimé.",
|
"deleted": "Contact supprimé.",
|
||||||
|
"deleteConfirmTitle": "Supprimer le contact",
|
||||||
|
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer ce contact ? Cette action est irréversible.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"lastName": "Nom",
|
"lastName": "Nom",
|
||||||
"firstName": "Prénom",
|
"firstName": "Prénom",
|
||||||
@@ -1014,11 +1028,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"addresses": {
|
"addresses": {
|
||||||
"add": "Ajouter une adresse",
|
"add": "Nouvelle adresse",
|
||||||
"item": "Adresse {n}",
|
"item": "Adresse {n}",
|
||||||
"saved": "Adresse enregistrée.",
|
"saved": "Adresse enregistrée.",
|
||||||
"deleted": "Adresse supprimée.",
|
"deleted": "Adresse supprimée.",
|
||||||
|
"deleteConfirmTitle": "Supprimer l'adresse",
|
||||||
|
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer cette adresse ? Cette action est irréversible.",
|
||||||
"streetNotFound": "Aucune adresse trouvée — saisie libre possible.",
|
"streetNotFound": "Aucune adresse trouvée — saisie libre possible.",
|
||||||
|
"streetHint": "Renseignez d'abord le code postal et la ville.",
|
||||||
"autocompleteUnavailable": "Recherche d'adresse indisponible : saisissez l'adresse manuellement.",
|
"autocompleteUnavailable": "Recherche d'adresse indisponible : saisissez l'adresse manuellement.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"label": "Libellé",
|
"label": "Libellé",
|
||||||
@@ -1040,6 +1057,8 @@
|
|||||||
"deleted": "Compte-rendu supprimé.",
|
"deleted": "Compte-rendu supprimé.",
|
||||||
"confirmDeleteTitle": "Supprimer ce compte-rendu ?",
|
"confirmDeleteTitle": "Supprimer ce compte-rendu ?",
|
||||||
"confirmDeleteMessage": "Cette action est irréversible. Les documents joints seront également supprimés.",
|
"confirmDeleteMessage": "Cette action est irréversible. Les documents joints seront également supprimés.",
|
||||||
|
"documentDeleteTitle": "Supprimer le document",
|
||||||
|
"documentDeleteMessage": "Êtes-vous sûr de vouloir supprimer ce document ? Cette action est irréversible.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"subject": "Objet",
|
"subject": "Objet",
|
||||||
"type": "Type d'échange",
|
"type": "Type d'échange",
|
||||||
|
|||||||
@@ -9,8 +9,7 @@
|
|||||||
v-if="canManage"
|
v-if="canManage"
|
||||||
icon-name="mdi:plus"
|
icon-name="mdi:plus"
|
||||||
icon-position="left"
|
icon-position="left"
|
||||||
button-class="w-auto px-4"
|
:label="$t('common.add')"
|
||||||
:label="$t('directory.reports.add')"
|
|
||||||
@click="openCreate"
|
@click="openCreate"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,7 +107,7 @@
|
|||||||
v-if="report.documents?.length"
|
v-if="report.documents?.length"
|
||||||
:documents="report.documents"
|
:documents="report.documents"
|
||||||
:can-manage="canManage"
|
:can-manage="canManage"
|
||||||
@delete="(docId) => removeDocument(docId)"
|
@delete="(docId) => askDeleteDocument(docId)"
|
||||||
/>
|
/>
|
||||||
<ReportDocumentUpload
|
<ReportDocumentUpload
|
||||||
v-if="canManage"
|
v-if="canManage"
|
||||||
@@ -127,11 +126,18 @@
|
|||||||
:owner="owner"
|
:owner="owner"
|
||||||
@saved="reload"
|
@saved="reload"
|
||||||
/>
|
/>
|
||||||
<ConfirmDeleteReportModal
|
<ConfirmModal
|
||||||
v-model="confirmOpen"
|
v-model="confirmOpen"
|
||||||
:busy="deleting"
|
:title="$t('directory.reports.confirmDeleteTitle')"
|
||||||
|
:message="$t('directory.reports.confirmDeleteMessage')"
|
||||||
@confirm="confirmDelete"
|
@confirm="confirmDelete"
|
||||||
/>
|
/>
|
||||||
|
<ConfirmModal
|
||||||
|
v-model="docConfirmOpen"
|
||||||
|
:title="$t('directory.reports.documentDeleteTitle')"
|
||||||
|
:message="$t('directory.reports.documentDeleteMessage')"
|
||||||
|
@confirm="confirmDeleteDocument"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -158,6 +164,11 @@ const confirmOpen = ref(false)
|
|||||||
const pendingDelete = ref<CommercialReport | null>(null)
|
const pendingDelete = ref<CommercialReport | null>(null)
|
||||||
const deleting = ref(false)
|
const deleting = ref(false)
|
||||||
|
|
||||||
|
// Suppression d'un document joint : passe désormais par une modal de confirmation.
|
||||||
|
const docConfirmOpen = ref(false)
|
||||||
|
const pendingDocId = ref<number | null>(null)
|
||||||
|
const deletingDoc = ref(false)
|
||||||
|
|
||||||
// Le plus récent en haut (l'API ne garantit pas l'ordre).
|
// Le plus récent en haut (l'API ne garantit pas l'ordre).
|
||||||
const sortedReports = computed(() =>
|
const sortedReports = computed(() =>
|
||||||
[...reports.value].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)),
|
[...reports.value].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)),
|
||||||
@@ -222,9 +233,22 @@ async function confirmDelete(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeDocument(id: number): Promise<void> {
|
function askDeleteDocument(id: number): void {
|
||||||
await documentService.remove(id)
|
pendingDocId.value = id
|
||||||
await reload()
|
docConfirmOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteDocument(): Promise<void> {
|
||||||
|
if (pendingDocId.value === null || deletingDoc.value) return
|
||||||
|
deletingDoc.value = true
|
||||||
|
try {
|
||||||
|
await documentService.remove(pendingDocId.value)
|
||||||
|
docConfirmOpen.value = false
|
||||||
|
pendingDocId.value = null
|
||||||
|
await reload()
|
||||||
|
} finally {
|
||||||
|
deletingDoc.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reload(): Promise<void> {
|
async function reload(): Promise<void> {
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<template>
|
||||||
|
<MalioModal
|
||||||
|
:model-value="modelValue"
|
||||||
|
modal-class="max-w-md"
|
||||||
|
@update:model-value="$emit('update:modelValue', $event)"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-[24px] font-bold">{{ title }}</h2>
|
||||||
|
</template>
|
||||||
|
<!-- Corps : slot par défaut pour permettre du texte enrichi (nom en gras
|
||||||
|
via <i18n-t>) ; sinon repli sur le message texte simple. -->
|
||||||
|
<slot>
|
||||||
|
<p>{{ message }}</p>
|
||||||
|
</slot>
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
variant="secondary"
|
||||||
|
button-class="flex-1"
|
||||||
|
:label="cancelLabel ?? $t('common.cancel')"
|
||||||
|
@click="$emit('update:modelValue', false)"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
:variant="confirmVariant"
|
||||||
|
button-class="flex-1"
|
||||||
|
:label="confirmLabel ?? $t('common.delete')"
|
||||||
|
@click="$emit('confirm')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MalioModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
title: string
|
||||||
|
message?: string
|
||||||
|
confirmLabel?: string
|
||||||
|
cancelLabel?: string
|
||||||
|
confirmVariant?: 'primary' | 'secondary' | 'tertiary' | 'danger'
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
message: undefined,
|
||||||
|
confirmLabel: undefined,
|
||||||
|
cancelLabel: undefined,
|
||||||
|
confirmVariant: 'danger',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
(e: 'confirm'): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
@@ -1,84 +1,93 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]">
|
<!-- Bloc à plat (sans box-shadow) : un filet noir 1px le sépare du suivant
|
||||||
<h3 class="col-span-2 text-sm font-semibold text-neutral-700">
|
(pas de bordure sous le dernier bloc), comme sur Starseed. -->
|
||||||
{{ title }}
|
<div class="pb-5" :class="{ 'border-b border-black': !last }">
|
||||||
</h3>
|
<div class="flex items-center justify-between">
|
||||||
<MalioButtonIcon
|
<!-- Titre = libellé saisi ; repli sur « Adresse N » tant qu'il est vide. -->
|
||||||
v-if="removable && !readonly"
|
<h3 class="text-[20px] font-semibold text-black">{{ blockTitle }}</h3>
|
||||||
icon="mdi:delete-outline"
|
<MalioButtonIcon
|
||||||
variant="ghost"
|
v-if="removable && !readonly"
|
||||||
class="absolute right-3 top-3"
|
icon="mdi:delete-outline"
|
||||||
:aria-label="$t('common.delete')"
|
variant="ghost"
|
||||||
@click="$emit('remove')"
|
button-class="p-0"
|
||||||
/>
|
:aria-label="$t('common.delete')"
|
||||||
|
@click="$emit('remove')"
|
||||||
<MalioInputText
|
|
||||||
class="col-span-2"
|
|
||||||
:label="$t('directory.addresses.fields.label')"
|
|
||||||
:model-value="modelValue.label ?? ''"
|
|
||||||
:readonly="readonly"
|
|
||||||
@update:model-value="update('label', $event)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Rue : saisie assistée (BAN) en édition, champ texte en lecture seule.
|
|
||||||
allow-create conserve le texte saisi si la BAN ne propose rien
|
|
||||||
(erreur/timeout). Choisir une suggestion remplit rue + CP + ville. -->
|
|
||||||
<div class="col-span-2">
|
|
||||||
<MalioInputAutocomplete
|
|
||||||
v-if="!readonly"
|
|
||||||
:model-value="modelValue.street ?? ''"
|
|
||||||
:options="addressOptions"
|
|
||||||
:loading="addressLoading"
|
|
||||||
:min-search-length="3"
|
|
||||||
:allow-create="true"
|
|
||||||
:label="$t('directory.addresses.fields.street')"
|
|
||||||
:no-results-text="$t('directory.addresses.streetNotFound')"
|
|
||||||
@update:model-value="(v) => update('street', v === null ? '' : String(v))"
|
|
||||||
@search="onAddressSearch"
|
|
||||||
@select="onAddressSelect"
|
|
||||||
/>
|
|
||||||
<MalioInputText
|
|
||||||
v-else
|
|
||||||
:label="$t('directory.addresses.fields.street')"
|
|
||||||
:model-value="modelValue.street ?? ''"
|
|
||||||
:readonly="readonly"
|
|
||||||
@update:model-value="update('street', $event)"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MalioInputText
|
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
class="col-span-2"
|
<MalioInputText
|
||||||
:label="$t('directory.addresses.fields.streetComplement')"
|
class="col-span-2"
|
||||||
:model-value="modelValue.streetComplement ?? ''"
|
:label="$t('directory.addresses.fields.label')"
|
||||||
:readonly="readonly"
|
:model-value="modelValue.label ?? ''"
|
||||||
@update:model-value="update('streetComplement', $event)"
|
:readonly="readonly"
|
||||||
/>
|
@update:model-value="update('label', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
<MalioInputText
|
<!-- On commence par le code postal : il alimente la liste des villes (BAN)
|
||||||
:label="$t('directory.addresses.fields.postalCode')"
|
et réinitialise ville/rue devenues incohérentes en cas de changement. -->
|
||||||
:model-value="modelValue.postalCode ?? ''"
|
<MalioInputText
|
||||||
:readonly="readonly"
|
:label="$t('directory.addresses.fields.postalCode')"
|
||||||
@update:model-value="onPostalCodeInput"
|
:model-value="modelValue.postalCode ?? ''"
|
||||||
/>
|
:readonly="readonly"
|
||||||
|
@update:model-value="onPostalCodeInput"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Ville : select alimenté par le code postal (BAN). En mode dégradé
|
<!-- Ville : select alimenté par le code postal (BAN). En mode dégradé
|
||||||
(BAN indispo) ou lecture seule, on bascule en saisie libre. -->
|
(BAN indispo) ou lecture seule, on bascule en saisie libre. -->
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-if="!readonly && !degraded"
|
v-if="!readonly && !degraded"
|
||||||
:model-value="modelValue.city ?? ''"
|
:model-value="modelValue.city ?? ''"
|
||||||
:options="cityOptions"
|
:options="cityOptions"
|
||||||
:label="$t('directory.addresses.fields.city')"
|
:label="$t('directory.addresses.fields.city')"
|
||||||
empty-option-label=""
|
empty-option-label=""
|
||||||
group-class="w-full"
|
group-class="w-full"
|
||||||
@update:model-value="(v) => update('city', v === null ? '' : String(v))"
|
@update:model-value="onCityChange"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-else
|
v-else
|
||||||
:label="$t('directory.addresses.fields.city')"
|
:label="$t('directory.addresses.fields.city')"
|
||||||
:model-value="modelValue.city ?? ''"
|
:model-value="modelValue.city ?? ''"
|
||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
@update:model-value="update('city', $event)"
|
@update:model-value="update('city', $event)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Rue : conditionnée au code postal + ville (comme Starseed). Saisie
|
||||||
|
assistée (BAN) filtrée par le code postal ; désactivée tant que CP et
|
||||||
|
ville ne sont pas renseignés. Champ texte simple en lecture seule. -->
|
||||||
|
<div class="col-span-2">
|
||||||
|
<MalioInputAutocomplete
|
||||||
|
v-if="!readonly"
|
||||||
|
:model-value="modelValue.street ?? ''"
|
||||||
|
:options="addressOptions"
|
||||||
|
:loading="addressLoading"
|
||||||
|
:min-search-length="3"
|
||||||
|
:allow-create="true"
|
||||||
|
:disabled="!canEditStreet"
|
||||||
|
:hint="canEditStreet ? '' : $t('directory.addresses.streetHint')"
|
||||||
|
:label="$t('directory.addresses.fields.street')"
|
||||||
|
:no-results-text="$t('directory.addresses.streetNotFound')"
|
||||||
|
@update:model-value="(v) => update('street', v === null ? '' : String(v))"
|
||||||
|
@search="onAddressSearch"
|
||||||
|
@select="onAddressSelect"
|
||||||
|
/>
|
||||||
|
<MalioInputText
|
||||||
|
v-else
|
||||||
|
:label="$t('directory.addresses.fields.street')"
|
||||||
|
:model-value="modelValue.street ?? ''"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="update('street', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MalioInputText
|
||||||
|
class="col-span-2"
|
||||||
|
:label="$t('directory.addresses.fields.streetComplement')"
|
||||||
|
:model-value="modelValue.streetComplement ?? ''"
|
||||||
|
:readonly="readonly"
|
||||||
|
@update:model-value="update('streetComplement', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -94,6 +103,8 @@ const props = defineProps<{
|
|||||||
title: string
|
title: string
|
||||||
removable?: boolean
|
removable?: boolean
|
||||||
readonly?: boolean
|
readonly?: boolean
|
||||||
|
/** Dernier bloc de la liste : supprime le filet de séparation bas. */
|
||||||
|
last?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -112,6 +123,16 @@ const addressOptions = ref<Option[]>([])
|
|||||||
const fetchedCityOptions = ref<Option[]>([])
|
const fetchedCityOptions = ref<Option[]>([])
|
||||||
const addressLoading = ref(false)
|
const addressLoading = ref(false)
|
||||||
|
|
||||||
|
// Titre du bloc : le libellé saisi prime ; repli sur « Adresse N » (prop `title`).
|
||||||
|
const blockTitle = computed(() => (props.modelValue.label ?? '').trim() || props.title)
|
||||||
|
|
||||||
|
// La rue n'est éditable qu'une fois le code postal (5 chiffres) ET la ville
|
||||||
|
// renseignés — conditionnement métier repris de Starseed.
|
||||||
|
const canEditStreet = computed(() => {
|
||||||
|
const digits = (props.modelValue.postalCode ?? '').replace(/\D/g, '')
|
||||||
|
return digits.length === 5 && !!(props.modelValue.city ?? '').trim()
|
||||||
|
})
|
||||||
|
|
||||||
// Le select Ville n'affiche que les valeurs présentes dans ses options : on
|
// Le select Ville n'affiche que les valeurs présentes dans ses options : on
|
||||||
// garantit donc que la ville déjà enregistrée (chargement d'une fiche) ou
|
// garantit donc que la ville déjà enregistrée (chargement d'une fiche) ou
|
||||||
// pré-remplie par l'autocomplétion d'adresse figure toujours dans la liste,
|
// pré-remplie par l'autocomplétion d'adresse figure toujours dans la liste,
|
||||||
@@ -140,6 +161,23 @@ function notifyUnavailable(): void {
|
|||||||
toast.info({ title: '', message: t('directory.addresses.autocompleteUnavailable') })
|
toast.info({ title: '', message: t('directory.addresses.autocompleteUnavailable') })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sélection d'une ville → vide rue + complément (devenus incohérents avec la
|
||||||
|
* nouvelle ville). Ne réagit qu'à un vrai changement de valeur.
|
||||||
|
*/
|
||||||
|
function onCityChange(value: string | number | null): void {
|
||||||
|
const next = value === null ? '' : String(value)
|
||||||
|
if (next === (props.modelValue.city ?? '')) return
|
||||||
|
addressOptions.value = []
|
||||||
|
lastAddressSuggestions = []
|
||||||
|
emit('update:modelValue', {
|
||||||
|
...props.modelValue,
|
||||||
|
city: next === '' ? null : next,
|
||||||
|
street: null,
|
||||||
|
streetComplement: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** Recherche d'adresse assistée (event de MalioInputAutocomplete). */
|
/** Recherche d'adresse assistée (event de MalioInputAutocomplete). */
|
||||||
async function onAddressSearch(query: string): Promise<void> {
|
async function onAddressSearch(query: string): Promise<void> {
|
||||||
if (query.trim().length < 3) {
|
if (query.trim().length < 3) {
|
||||||
@@ -180,10 +218,30 @@ function onAddressSelect(option: Option | null): void {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Saisie du code postal → met à jour le champ + interroge la BAN pour la ville. */
|
/**
|
||||||
|
* Saisie du code postal → réinitialise ville/rue/complément quand le CP est
|
||||||
|
* complet (5 chiffres) ET réellement modifié, puis interroge la BAN pour les
|
||||||
|
* villes. Sinon simple mise à jour du champ (correction partielle).
|
||||||
|
*/
|
||||||
async function onPostalCodeInput(value: string): Promise<void> {
|
async function onPostalCodeInput(value: string): Promise<void> {
|
||||||
update('postalCode', value)
|
|
||||||
const digits = (value ?? '').replace(/\D/g, '')
|
const digits = (value ?? '').replace(/\D/g, '')
|
||||||
|
const previousDigits = (props.modelValue.postalCode ?? '').replace(/\D/g, '')
|
||||||
|
|
||||||
|
if (digits.length === 5 && digits !== previousDigits) {
|
||||||
|
addressOptions.value = []
|
||||||
|
lastAddressSuggestions = []
|
||||||
|
emit('update:modelValue', {
|
||||||
|
...props.modelValue,
|
||||||
|
postalCode: value,
|
||||||
|
city: null,
|
||||||
|
street: null,
|
||||||
|
streetComplement: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
update('postalCode', value)
|
||||||
|
}
|
||||||
|
|
||||||
if (digits.length < 5) return
|
if (digits.length < 5) return
|
||||||
try {
|
try {
|
||||||
const suggestions = await autocomplete.searchCity(digits)
|
const suggestions = await autocomplete.searchCity(digits)
|
||||||
|
|||||||
@@ -1,57 +1,61 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]">
|
<!-- Bloc à plat (sans box-shadow) : un filet noir 1px le sépare du suivant
|
||||||
<h3 class="col-span-2 text-sm font-semibold text-neutral-700">
|
(pas de bordure sous le dernier bloc), comme sur Starseed. -->
|
||||||
{{ title }}
|
<div class="pb-5" :class="{ 'border-b border-black': !last }">
|
||||||
</h3>
|
<div class="flex items-center justify-between">
|
||||||
<MalioButtonIcon
|
<h3 class="text-[20px] font-semibold text-black">{{ title }}</h3>
|
||||||
v-if="removable && !readonly"
|
<MalioButtonIcon
|
||||||
icon="mdi:delete-outline"
|
v-if="removable && !readonly"
|
||||||
variant="ghost"
|
icon="mdi:delete-outline"
|
||||||
class="absolute right-3 top-3"
|
variant="ghost"
|
||||||
:aria-label="$t('common.delete')"
|
button-class="p-0"
|
||||||
@click="$emit('remove')"
|
:aria-label="$t('common.delete')"
|
||||||
/>
|
@click="$emit('remove')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<MalioInputText
|
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
|
||||||
:label="$t('directory.contacts.fields.lastName')"
|
<MalioInputText
|
||||||
:model-value="modelValue.lastName ?? ''"
|
:label="$t('directory.contacts.fields.lastName')"
|
||||||
:readonly="readonly"
|
:model-value="modelValue.lastName ?? ''"
|
||||||
@update:model-value="update('lastName', $event)"
|
:readonly="readonly"
|
||||||
/>
|
@update:model-value="update('lastName', $event)"
|
||||||
<MalioInputText
|
/>
|
||||||
:label="$t('directory.contacts.fields.firstName')"
|
<MalioInputText
|
||||||
:model-value="modelValue.firstName ?? ''"
|
:label="$t('directory.contacts.fields.firstName')"
|
||||||
:readonly="readonly"
|
:model-value="modelValue.firstName ?? ''"
|
||||||
@update:model-value="update('firstName', $event)"
|
:readonly="readonly"
|
||||||
/>
|
@update:model-value="update('firstName', $event)"
|
||||||
<MalioInputText
|
/>
|
||||||
class="col-span-2"
|
<MalioInputText
|
||||||
:label="$t('directory.contacts.fields.jobTitle')"
|
class="col-span-2"
|
||||||
:model-value="modelValue.jobTitle ?? ''"
|
:label="$t('directory.contacts.fields.jobTitle')"
|
||||||
:readonly="readonly"
|
:model-value="modelValue.jobTitle ?? ''"
|
||||||
@update:model-value="update('jobTitle', $event)"
|
:readonly="readonly"
|
||||||
/>
|
@update:model-value="update('jobTitle', $event)"
|
||||||
<MalioInputText
|
/>
|
||||||
:label="$t('directory.contacts.fields.email')"
|
<MalioInputEmail
|
||||||
:model-value="modelValue.email ?? ''"
|
:label="$t('directory.contacts.fields.email')"
|
||||||
:readonly="readonly"
|
:model-value="modelValue.email ?? ''"
|
||||||
:error="emailError"
|
:readonly="readonly"
|
||||||
@update:model-value="update('email', $event)"
|
:error="emailError"
|
||||||
/>
|
@update:model-value="update('email', $event)"
|
||||||
<MalioInputText
|
/>
|
||||||
:label="$t('directory.contacts.fields.phonePrimary')"
|
<MalioInputPhone
|
||||||
:model-value="modelValue.phonePrimary ?? ''"
|
:label="$t('directory.contacts.fields.phonePrimary')"
|
||||||
:readonly="readonly"
|
:model-value="modelValue.phonePrimary ?? ''"
|
||||||
:error="phonePrimaryError"
|
:readonly="readonly"
|
||||||
@update:model-value="update('phonePrimary', $event)"
|
:error="phonePrimaryError"
|
||||||
/>
|
@update:model-value="update('phonePrimary', $event)"
|
||||||
<MalioInputText
|
/>
|
||||||
:label="$t('directory.contacts.fields.phoneSecondary')"
|
<MalioInputPhone
|
||||||
:model-value="modelValue.phoneSecondary ?? ''"
|
:label="$t('directory.contacts.fields.phoneSecondary')"
|
||||||
:readonly="readonly"
|
:model-value="modelValue.phoneSecondary ?? ''"
|
||||||
:error="phoneSecondaryError"
|
:readonly="readonly"
|
||||||
@update:model-value="update('phoneSecondary', $event)"
|
:error="phoneSecondaryError"
|
||||||
/>
|
@update:model-value="update('phoneSecondary', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -64,6 +68,8 @@ const props = defineProps<{
|
|||||||
title: string
|
title: string
|
||||||
removable?: boolean
|
removable?: boolean
|
||||||
readonly?: boolean
|
readonly?: boolean
|
||||||
|
/** Dernier bloc de la liste : supprime le filet de séparation bas. */
|
||||||
|
last?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|||||||
@@ -16,8 +16,8 @@
|
|||||||
</a>
|
</a>
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
v-if="canManage"
|
v-if="canManage"
|
||||||
icon="mdi:trash-can-outline"
|
icon="mdi:delete-outline"
|
||||||
button-class="!text-red-600"
|
variant="ghost"
|
||||||
:aria-label="$t('common.delete')"
|
:aria-label="$t('common.delete')"
|
||||||
@click="$emit('delete', doc.id)"
|
@click="$emit('delete', doc.id)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,20 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<input
|
<MalioInputUpload
|
||||||
ref="fileInput"
|
v-model="fileName"
|
||||||
type="file"
|
class="flex-1"
|
||||||
class="hidden"
|
|
||||||
@change="onFileSelected"
|
|
||||||
>
|
|
||||||
<MalioButton
|
|
||||||
icon-name="mdi:paperclip"
|
|
||||||
icon-position="left"
|
|
||||||
button-class="w-auto px-4"
|
|
||||||
:label="$t('directory.documents.add')"
|
:label="$t('directory.documents.add')"
|
||||||
:disabled="uploading"
|
:disabled="uploading"
|
||||||
@click="fileInput?.click()"
|
:reserve-message-space="false"
|
||||||
|
@file-selected="onFile"
|
||||||
/>
|
/>
|
||||||
<span v-if="uploading" class="text-sm text-neutral-500">{{ $t('directory.documents.uploading') }}</span>
|
<span v-if="uploading" class="shrink-0 text-sm text-neutral-500">{{ $t('directory.documents.uploading') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -25,21 +19,19 @@ const props = defineProps<{ reportId: number }>()
|
|||||||
const emit = defineEmits<{ uploaded: [] }>()
|
const emit = defineEmits<{ uploaded: [] }>()
|
||||||
|
|
||||||
const service = useReportDocumentService()
|
const service = useReportDocumentService()
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
// Nom du fichier affiché par le champ Malio (v-model) ; réinitialisé après envoi.
|
||||||
|
const fileName = ref('')
|
||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
|
|
||||||
async function onFileSelected(event: Event): Promise<void> {
|
// L'upload se déclenche dès la sélection (event natif du composant Malio).
|
||||||
const input = event.target as HTMLInputElement
|
async function onFile(file: File): Promise<void> {
|
||||||
const file = input.files?.[0]
|
|
||||||
if (!file) return
|
|
||||||
|
|
||||||
uploading.value = true
|
uploading.value = true
|
||||||
try {
|
try {
|
||||||
await service.upload(props.reportId, file)
|
await service.upload(props.reportId, file)
|
||||||
emit('uploaded')
|
emit('uploaded')
|
||||||
} finally {
|
} finally {
|
||||||
uploading.value = false
|
uploading.value = false
|
||||||
input.value = ''
|
fileName.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type Owner = { client?: string, prospect?: string, prestataire?: string }
|
|||||||
* tel quel par les deux pages.
|
* tel quel par les deux pages.
|
||||||
*/
|
*/
|
||||||
export function useDirectoryDetail(owner: Owner) {
|
export function useDirectoryDetail(owner: Owner) {
|
||||||
|
const { t } = useI18n()
|
||||||
const contactService = useContactService()
|
const contactService = useContactService()
|
||||||
const addressService = useAddressService()
|
const addressService = useAddressService()
|
||||||
|
|
||||||
@@ -59,6 +60,39 @@ export function useDirectoryDetail(owner: Owner) {
|
|||||||
addresses.value.splice(index, 1)
|
addresses.value.splice(index, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Confirmation de suppression d'un bloc (contact / adresse) : la corbeille du
|
||||||
|
// bloc ouvre une modal ; la suppression effective n'a lieu qu'à la confirmation.
|
||||||
|
const removeModalOpen = ref(false)
|
||||||
|
const pendingRemoval = ref<{ type: 'contact' | 'address', index: number } | null>(null)
|
||||||
|
|
||||||
|
const removeModalTitle = computed(() =>
|
||||||
|
pendingRemoval.value?.type === 'address'
|
||||||
|
? t('directory.addresses.deleteConfirmTitle')
|
||||||
|
: t('directory.contacts.deleteConfirmTitle'),
|
||||||
|
)
|
||||||
|
const removeModalMessage = computed(() =>
|
||||||
|
pendingRemoval.value?.type === 'address'
|
||||||
|
? t('directory.addresses.deleteConfirmMessage')
|
||||||
|
: t('directory.contacts.deleteConfirmMessage'),
|
||||||
|
)
|
||||||
|
|
||||||
|
function askRemoveContact(index: number): void {
|
||||||
|
pendingRemoval.value = { type: 'contact', index }
|
||||||
|
removeModalOpen.value = true
|
||||||
|
}
|
||||||
|
function askRemoveAddress(index: number): void {
|
||||||
|
pendingRemoval.value = { type: 'address', index }
|
||||||
|
removeModalOpen.value = true
|
||||||
|
}
|
||||||
|
async function confirmRemove(): Promise<void> {
|
||||||
|
const p = pendingRemoval.value
|
||||||
|
if (!p) return
|
||||||
|
if (p.type === 'contact') await removeContact(p.index)
|
||||||
|
else await removeAddress(p.index)
|
||||||
|
removeModalOpen.value = false
|
||||||
|
pendingRemoval.value = null
|
||||||
|
}
|
||||||
|
|
||||||
// Persistance au clic : met à jour les blocs existants, crée les nouveaux
|
// Persistance au clic : met à jour les blocs existants, crée les nouveaux
|
||||||
// blocs renseignés. Les amorces vides (sans contenu) sont ignorées.
|
// blocs renseignés. Les amorces vides (sans contenu) sont ignorées.
|
||||||
async function saveContacts(): Promise<void> {
|
async function saveContacts(): Promise<void> {
|
||||||
@@ -117,5 +151,12 @@ export function useDirectoryDetail(owner: Owner) {
|
|||||||
removeAddress,
|
removeAddress,
|
||||||
saveAddresses,
|
saveAddresses,
|
||||||
load,
|
load,
|
||||||
|
// Suppression de bloc avec confirmation (modal partagée contact/adresse).
|
||||||
|
removeModalOpen,
|
||||||
|
removeModalTitle,
|
||||||
|
removeModalMessage,
|
||||||
|
askRemoveContact,
|
||||||
|
askRemoveAddress,
|
||||||
|
confirmRemove,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,14 @@
|
|||||||
<div>
|
<div>
|
||||||
<PageHeader>
|
<PageHeader>
|
||||||
<span class="inline-flex items-center gap-3">
|
<span class="inline-flex items-center gap-3">
|
||||||
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" />
|
<MalioButtonIcon
|
||||||
|
icon="mdi:arrow-left-bold"
|
||||||
|
icon-size="24"
|
||||||
|
variant="ghost"
|
||||||
|
:title="$t('common.back')"
|
||||||
|
:aria-label="$t('common.back')"
|
||||||
|
@click="goBack"
|
||||||
|
/>
|
||||||
{{ client?.name ?? '…' }}
|
{{ client?.name ?? '…' }}
|
||||||
</span>
|
</span>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
@@ -13,7 +20,7 @@
|
|||||||
<MalioTabList v-model="activeTab" :tabs="tabs">
|
<MalioTabList v-model="activeTab" :tabs="tabs">
|
||||||
<template #info>
|
<template #info>
|
||||||
<div class="flex flex-col gap-4 pt-6">
|
<div class="flex flex-col gap-4 pt-6">
|
||||||
<div class="relative grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]">
|
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4 pb-5">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="info.name"
|
v-model="info.name"
|
||||||
class="col-span-2"
|
class="col-span-2"
|
||||||
@@ -21,12 +28,12 @@
|
|||||||
:error="infoTouched.name && !info.name.trim() ? $t('directory.validation.nameRequired') : ''"
|
:error="infoTouched.name && !info.name.trim() ? $t('directory.validation.nameRequired') : ''"
|
||||||
@blur="infoTouched.name = true"
|
@blur="infoTouched.name = true"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputEmail
|
||||||
v-model="info.email"
|
v-model="info.email"
|
||||||
:label="$t('directory.info.fields.email')"
|
:label="$t('directory.info.fields.email')"
|
||||||
:error="emailError"
|
:error="emailError"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputPhone
|
||||||
v-model="info.phone"
|
v-model="info.phone"
|
||||||
:label="$t('directory.info.fields.phone')"
|
:label="$t('directory.info.fields.phone')"
|
||||||
:error="phoneError"
|
:error="phoneError"
|
||||||
@@ -40,7 +47,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex justify-center pt-2">
|
<div class="flex justify-center pt-2">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
button-class="w-auto px-6"
|
|
||||||
:label="$t('common.save')"
|
:label="$t('common.save')"
|
||||||
:disabled="savingInfo || !infoValid"
|
:disabled="savingInfo || !infoValid"
|
||||||
@click="saveInfo"
|
@click="saveInfo"
|
||||||
@@ -57,12 +63,13 @@
|
|||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
:title="$t('directory.contacts.item', { n: i + 1 })"
|
:title="$t('directory.contacts.item', { n: i + 1 })"
|
||||||
:removable="contacts.length > 0"
|
:removable="contacts.length > 0"
|
||||||
|
:last="i === contacts.length - 1"
|
||||||
@update:model-value="(v) => onContactInput(i, v)"
|
@update:model-value="(v) => onContactInput(i, v)"
|
||||||
@remove="removeContact(i)"
|
@remove="askRemoveContact(i)"
|
||||||
/>
|
/>
|
||||||
<div class="flex justify-center gap-3 pt-2">
|
<div class="flex justify-center gap-3 pt-2">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
variant="tertiary"
|
variant="secondary"
|
||||||
icon-name="mdi:plus"
|
icon-name="mdi:plus"
|
||||||
icon-position="left"
|
icon-position="left"
|
||||||
button-class="w-auto px-4"
|
button-class="w-auto px-4"
|
||||||
@@ -70,7 +77,6 @@
|
|||||||
@click="addContact"
|
@click="addContact"
|
||||||
/>
|
/>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
button-class="w-auto px-6"
|
|
||||||
:label="$t('common.save')"
|
:label="$t('common.save')"
|
||||||
:disabled="savingContacts"
|
:disabled="savingContacts"
|
||||||
@click="saveContacts"
|
@click="saveContacts"
|
||||||
@@ -87,12 +93,13 @@
|
|||||||
:model-value="address"
|
:model-value="address"
|
||||||
:title="$t('directory.addresses.item', { n: i + 1 })"
|
:title="$t('directory.addresses.item', { n: i + 1 })"
|
||||||
:removable="addresses.length > 0"
|
:removable="addresses.length > 0"
|
||||||
|
:last="i === addresses.length - 1"
|
||||||
@update:model-value="(v) => onAddressInput(i, v)"
|
@update:model-value="(v) => onAddressInput(i, v)"
|
||||||
@remove="removeAddress(i)"
|
@remove="askRemoveAddress(i)"
|
||||||
/>
|
/>
|
||||||
<div class="flex justify-center gap-3 pt-2">
|
<div class="flex justify-center gap-3 pt-2">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
variant="tertiary"
|
variant="secondary"
|
||||||
icon-name="mdi:plus"
|
icon-name="mdi:plus"
|
||||||
icon-position="left"
|
icon-position="left"
|
||||||
button-class="w-auto px-4"
|
button-class="w-auto px-4"
|
||||||
@@ -100,7 +107,6 @@
|
|||||||
@click="addAddress"
|
@click="addAddress"
|
||||||
/>
|
/>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
button-class="w-auto px-6"
|
|
||||||
:label="$t('common.save')"
|
:label="$t('common.save')"
|
||||||
:disabled="savingAddresses"
|
:disabled="savingAddresses"
|
||||||
@click="saveAddresses"
|
@click="saveAddresses"
|
||||||
@@ -115,6 +121,13 @@
|
|||||||
</MalioTabList>
|
</MalioTabList>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
v-model="removeModalOpen"
|
||||||
|
:title="removeModalTitle"
|
||||||
|
:message="removeModalMessage"
|
||||||
|
@confirm="confirmRemove"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -141,13 +154,17 @@ const {
|
|||||||
savingAddresses,
|
savingAddresses,
|
||||||
onContactInput,
|
onContactInput,
|
||||||
addContact,
|
addContact,
|
||||||
removeContact,
|
askRemoveContact,
|
||||||
saveContacts,
|
saveContacts,
|
||||||
onAddressInput,
|
onAddressInput,
|
||||||
addAddress,
|
addAddress,
|
||||||
removeAddress,
|
askRemoveAddress,
|
||||||
saveAddresses,
|
saveAddresses,
|
||||||
load,
|
load,
|
||||||
|
removeModalOpen,
|
||||||
|
removeModalTitle,
|
||||||
|
removeModalMessage,
|
||||||
|
confirmRemove,
|
||||||
} = useDirectoryDetail(owner)
|
} = useDirectoryDetail(owner)
|
||||||
|
|
||||||
const { can } = usePermissions()
|
const { can } = usePermissions()
|
||||||
@@ -192,7 +209,8 @@ async function saveInfo(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function goBack(): void {
|
function goBack(): void {
|
||||||
router.push('/directory')
|
// Retour sur l'onglet Clients de la liste (via history.state, hors URL).
|
||||||
|
router.push({ path: '/directory', state: { tab: 'clients' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|||||||
@@ -9,12 +9,11 @@
|
|||||||
<!-- Clients -->
|
<!-- Clients -->
|
||||||
<template #clients>
|
<template #clients>
|
||||||
<div class="flex min-h-[30rem] flex-col gap-4 pt-10">
|
<div class="flex min-h-[30rem] flex-col gap-4 pt-10">
|
||||||
<div class="flex items-center justify-end">
|
<div class="flex min-h-[48px] items-center justify-end">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
icon-name="mdi:plus"
|
icon-name="mdi:plus"
|
||||||
icon-position="left"
|
icon-position="left"
|
||||||
button-class="w-auto px-4"
|
:label="$t('common.add')"
|
||||||
:label="$t('directory.clients.add')"
|
|
||||||
@click="openCreateClient"
|
@click="openCreateClient"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -26,6 +25,9 @@
|
|||||||
:empty-message="$t('directory.clients.empty')"
|
:empty-message="$t('directory.clients.empty')"
|
||||||
@row-click="openEditClient"
|
@row-click="openEditClient"
|
||||||
>
|
>
|
||||||
|
<template #header-actions>
|
||||||
|
<span class="block text-right font-semibold text-black">{{ $t('common.actions') }}</span>
|
||||||
|
</template>
|
||||||
<template #cell-email="{ item }">
|
<template #cell-email="{ item }">
|
||||||
{{ (item as Client).email ?? '—' }}
|
{{ (item as Client).email ?? '—' }}
|
||||||
</template>
|
</template>
|
||||||
@@ -37,7 +39,7 @@
|
|||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
icon="mdi:trash-can-outline"
|
icon="mdi:trash-can-outline"
|
||||||
:aria-label="$t('common.delete')"
|
:aria-label="$t('common.delete')"
|
||||||
button-class="!bg-red-100 !text-red-700"
|
button-class="!bg-red-100 !text-red-700 hover:!bg-red-200"
|
||||||
:icon-size="18"
|
:icon-size="18"
|
||||||
@click="askDeleteClient(item as Client)"
|
@click="askDeleteClient(item as Client)"
|
||||||
/>
|
/>
|
||||||
@@ -50,7 +52,7 @@
|
|||||||
<!-- Prospects -->
|
<!-- Prospects -->
|
||||||
<template #prospects>
|
<template #prospects>
|
||||||
<div class="flex min-h-[30rem] flex-col gap-4 pt-10">
|
<div class="flex min-h-[30rem] flex-col gap-4 pt-10">
|
||||||
<div class="flex flex-wrap items-end justify-between gap-3">
|
<div class="flex min-h-[48px] flex-wrap items-center justify-between gap-3">
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="statusFilter"
|
v-model="statusFilter"
|
||||||
:label="$t('prospects.fields.status')"
|
:label="$t('prospects.fields.status')"
|
||||||
@@ -61,8 +63,7 @@
|
|||||||
<MalioButton
|
<MalioButton
|
||||||
icon-name="mdi:plus"
|
icon-name="mdi:plus"
|
||||||
icon-position="left"
|
icon-position="left"
|
||||||
button-class="w-auto px-4"
|
:label="$t('common.add')"
|
||||||
:label="$t('directory.prospects.add')"
|
|
||||||
@click="openCreateProspect"
|
@click="openCreateProspect"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,6 +75,9 @@
|
|||||||
:empty-message="$t('directory.prospects.empty')"
|
:empty-message="$t('directory.prospects.empty')"
|
||||||
@row-click="openEditProspect"
|
@row-click="openEditProspect"
|
||||||
>
|
>
|
||||||
|
<template #header-actions>
|
||||||
|
<span class="block text-right font-semibold text-black">{{ $t('common.actions') }}</span>
|
||||||
|
</template>
|
||||||
<template #cell-status="{ item }">
|
<template #cell-status="{ item }">
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
:label="statusLabel((item as ProspectRow).status)"
|
:label="statusLabel((item as ProspectRow).status)"
|
||||||
@@ -92,14 +96,14 @@
|
|||||||
v-if="!(item as ProspectRow).convertedClient"
|
v-if="!(item as ProspectRow).convertedClient"
|
||||||
icon="mdi:account-convert"
|
icon="mdi:account-convert"
|
||||||
:aria-label="$t('prospects.convert')"
|
:aria-label="$t('prospects.convert')"
|
||||||
button-class="!bg-green-100 !text-green-700"
|
button-class="!bg-green-100 !text-green-700 hover:!bg-green-200"
|
||||||
:icon-size="18"
|
:icon-size="18"
|
||||||
@click="convertProspect(item as ProspectRow)"
|
@click="askConvertProspect(item as ProspectRow)"
|
||||||
/>
|
/>
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
icon="mdi:trash-can-outline"
|
icon="mdi:trash-can-outline"
|
||||||
:aria-label="$t('common.delete')"
|
:aria-label="$t('common.delete')"
|
||||||
button-class="!bg-red-100 !text-red-700"
|
button-class="!bg-red-100 !text-red-700 hover:!bg-red-200"
|
||||||
:icon-size="18"
|
:icon-size="18"
|
||||||
@click="askDeleteProspect(item as ProspectRow)"
|
@click="askDeleteProspect(item as ProspectRow)"
|
||||||
/>
|
/>
|
||||||
@@ -111,12 +115,11 @@
|
|||||||
<!-- Prestataires -->
|
<!-- Prestataires -->
|
||||||
<template #prestataires>
|
<template #prestataires>
|
||||||
<div class="flex min-h-[30rem] flex-col gap-4 pt-10">
|
<div class="flex min-h-[30rem] flex-col gap-4 pt-10">
|
||||||
<div class="flex items-center justify-end">
|
<div class="flex min-h-[48px] items-center justify-end">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
icon-name="mdi:plus"
|
icon-name="mdi:plus"
|
||||||
icon-position="left"
|
icon-position="left"
|
||||||
button-class="w-auto px-4"
|
:label="$t('common.add')"
|
||||||
:label="$t('directory.prestataires.add')"
|
|
||||||
@click="openCreatePrestataire"
|
@click="openCreatePrestataire"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -128,6 +131,9 @@
|
|||||||
:empty-message="$t('directory.prestataires.empty')"
|
:empty-message="$t('directory.prestataires.empty')"
|
||||||
@row-click="openEditPrestataire"
|
@row-click="openEditPrestataire"
|
||||||
>
|
>
|
||||||
|
<template #header-actions>
|
||||||
|
<span class="block text-right font-semibold text-black">{{ $t('common.actions') }}</span>
|
||||||
|
</template>
|
||||||
<template #cell-email="{ item }">
|
<template #cell-email="{ item }">
|
||||||
{{ (item as Prestataire).email ?? '—' }}
|
{{ (item as Prestataire).email ?? '—' }}
|
||||||
</template>
|
</template>
|
||||||
@@ -139,7 +145,7 @@
|
|||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
icon="mdi:trash-can-outline"
|
icon="mdi:trash-can-outline"
|
||||||
:aria-label="$t('common.delete')"
|
:aria-label="$t('common.delete')"
|
||||||
button-class="!bg-red-100 !text-red-700"
|
button-class="!bg-red-100 !text-red-700 hover:!bg-red-200"
|
||||||
:icon-size="18"
|
:icon-size="18"
|
||||||
@click="askDeletePrestataire(item as Prestataire)"
|
@click="askDeletePrestataire(item as Prestataire)"
|
||||||
/>
|
/>
|
||||||
@@ -166,12 +172,31 @@
|
|||||||
@saved="loadPrestataires"
|
@saved="loadPrestataires"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDeleteModal
|
<ConfirmModal
|
||||||
v-model="deleteModalOpen"
|
v-model="deleteModalOpen"
|
||||||
:title="deleteModalTitle"
|
:title="deleteModalTitle"
|
||||||
:message="deleteModalMessage"
|
|
||||||
@confirm="confirmDelete"
|
@confirm="confirmDelete"
|
||||||
/>
|
>
|
||||||
|
<i18n-t :keypath="deleteModalKeypath" tag="p" scope="global">
|
||||||
|
<template #name>
|
||||||
|
<strong class="font-semibold">{{ deleteTargetName }}</strong>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
|
</ConfirmModal>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
v-model="convertModalOpen"
|
||||||
|
:title="$t('prospects.convertConfirmTitle')"
|
||||||
|
:confirm-label="$t('prospects.convertConfirm')"
|
||||||
|
confirm-variant="primary"
|
||||||
|
@confirm="confirmConvert"
|
||||||
|
>
|
||||||
|
<i18n-t keypath="prospects.convertConfirmMessage" tag="p" scope="global">
|
||||||
|
<template #name>
|
||||||
|
<strong class="font-semibold">{{ convertTarget?.company }}</strong>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
|
</ConfirmModal>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -183,6 +208,7 @@ import type { Prospect, ProspectStatus } from '~/modules/directory/services/dto/
|
|||||||
import { useProspectService } from '~/modules/directory/services/prospects'
|
import { useProspectService } from '~/modules/directory/services/prospects'
|
||||||
import type { Prestataire } from '~/modules/directory/services/dto/prestataire'
|
import type { Prestataire } from '~/modules/directory/services/dto/prestataire'
|
||||||
import { usePrestataireService } from '~/modules/directory/services/prestataires'
|
import { usePrestataireService } from '~/modules/directory/services/prestataires'
|
||||||
|
import { readHistoryTab, stampHistoryTab } from '~/utils/historyTab'
|
||||||
|
|
||||||
definePageMeta({ middleware: ['admin'] })
|
definePageMeta({ middleware: ['admin'] })
|
||||||
|
|
||||||
@@ -201,6 +227,14 @@ const tabs = [
|
|||||||
{ key: 'prospects', label: t('directory.tabs.prospects'), icon: 'mdi:account-search-outline' },
|
{ key: 'prospects', label: t('directory.tabs.prospects'), icon: 'mdi:account-search-outline' },
|
||||||
{ key: 'prestataires', label: t('directory.tabs.prestataires'), icon: 'mdi:account-hard-hat-outline' },
|
{ key: 'prestataires', label: t('directory.tabs.prestataires'), icon: 'mdi:account-hard-hat-outline' },
|
||||||
]
|
]
|
||||||
|
const tabKeys = tabs.map((tab) => tab.key)
|
||||||
|
|
||||||
|
// Avant d'ouvrir une fiche : on estampille l'entrée d'historique courante avec
|
||||||
|
// l'onglet actif → la flèche « précédent » du navigateur restaure le bon onglet.
|
||||||
|
function navigateToDetail(path: string): void {
|
||||||
|
stampHistoryTab(activeTab.value)
|
||||||
|
navigateTo(path)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Clients ---
|
// --- Clients ---
|
||||||
const clients = ref<Client[]>([])
|
const clients = ref<Client[]>([])
|
||||||
@@ -211,7 +245,7 @@ const clientColumns = [
|
|||||||
{ key: 'name', label: t('prospects.fields.company') },
|
{ key: 'name', label: t('prospects.fields.company') },
|
||||||
{ key: 'email', label: t('prospects.fields.email') },
|
{ key: 'email', label: t('prospects.fields.email') },
|
||||||
{ key: 'phone', label: t('prospects.fields.phone') },
|
{ key: 'phone', label: t('prospects.fields.phone') },
|
||||||
{ key: 'actions', label: '' },
|
{ key: 'actions', label: t('common.actions') },
|
||||||
]
|
]
|
||||||
|
|
||||||
async function loadClients() {
|
async function loadClients() {
|
||||||
@@ -224,7 +258,7 @@ function openCreateClient() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openEditClient(item: Record<string, unknown>) {
|
function openEditClient(item: Record<string, unknown>) {
|
||||||
navigateTo(`/directory/clients/${(item as Client).id}`)
|
navigateToDetail(`/directory/clients/${(item as Client).id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Prospects ---
|
// --- Prospects ---
|
||||||
@@ -246,7 +280,7 @@ const prospectColumns = [
|
|||||||
{ key: 'status', label: t('prospects.fields.status') },
|
{ key: 'status', label: t('prospects.fields.status') },
|
||||||
{ key: 'email', label: t('prospects.fields.email') },
|
{ key: 'email', label: t('prospects.fields.email') },
|
||||||
{ key: 'phone', label: t('prospects.fields.phone') },
|
{ key: 'phone', label: t('prospects.fields.phone') },
|
||||||
{ key: 'actions', label: '' },
|
{ key: 'actions', label: t('common.actions') },
|
||||||
]
|
]
|
||||||
|
|
||||||
const prospectRows = computed<ProspectRow[]>(() => prospects.value)
|
const prospectRows = computed<ProspectRow[]>(() => prospects.value)
|
||||||
@@ -282,13 +316,26 @@ function openCreateProspect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openEditProspect(item: Record<string, unknown>) {
|
function openEditProspect(item: Record<string, unknown>) {
|
||||||
navigateTo(`/directory/prospects/${(item as Prospect).id}`)
|
navigateToDetail(`/directory/prospects/${(item as Prospect).id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function convertProspect(row: ProspectRow) {
|
// La conversion passe par une modal de confirmation (le prospect devient client).
|
||||||
|
const convertModalOpen = ref(false)
|
||||||
|
const convertTarget = ref<ProspectRow | null>(null)
|
||||||
|
|
||||||
|
function askConvertProspect(row: ProspectRow) {
|
||||||
|
convertTarget.value = row
|
||||||
|
convertModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmConvert() {
|
||||||
|
const row = convertTarget.value
|
||||||
|
if (!row) return
|
||||||
await prospectService.convert(row.id)
|
await prospectService.convert(row.id)
|
||||||
// La conversion crée un client et retire le prospect : rafraîchir les deux listes.
|
// La conversion crée un client et retire le prospect : rafraîchir les deux listes.
|
||||||
await Promise.all([loadProspects(), loadClients()])
|
await Promise.all([loadProspects(), loadClients()])
|
||||||
|
convertModalOpen.value = false
|
||||||
|
convertTarget.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Le ProspectDrawer porte aussi le bouton « Convertir » : son event 'saved' peut
|
// Le ProspectDrawer porte aussi le bouton « Convertir » : son event 'saved' peut
|
||||||
@@ -306,7 +353,7 @@ const prestataireColumns = [
|
|||||||
{ key: 'name', label: t('prospects.fields.company') },
|
{ key: 'name', label: t('prospects.fields.company') },
|
||||||
{ key: 'email', label: t('prospects.fields.email') },
|
{ key: 'email', label: t('prospects.fields.email') },
|
||||||
{ key: 'phone', label: t('prospects.fields.phone') },
|
{ key: 'phone', label: t('prospects.fields.phone') },
|
||||||
{ key: 'actions', label: '' },
|
{ key: 'actions', label: t('common.actions') },
|
||||||
]
|
]
|
||||||
|
|
||||||
async function loadPrestataires() {
|
async function loadPrestataires() {
|
||||||
@@ -319,7 +366,7 @@ function openCreatePrestataire() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openEditPrestataire(item: Record<string, unknown>) {
|
function openEditPrestataire(item: Record<string, unknown>) {
|
||||||
navigateTo(`/directory/prestataires/${(item as Prestataire).id}`)
|
navigateToDetail(`/directory/prestataires/${(item as Prestataire).id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Suppression (clients, prospects & prestataires) ---
|
// --- Suppression (clients, prospects & prestataires) ---
|
||||||
@@ -342,17 +389,22 @@ const deleteModalTitle = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const deleteModalMessage = computed(() => {
|
// Clé i18n du message (le nom y est injecté en gras via <i18n-t> côté template).
|
||||||
|
const deleteModalKeypath = computed(() => {
|
||||||
|
switch (deleteTarget.value?.type) {
|
||||||
|
case 'prospect':
|
||||||
|
return 'prospects.deleteConfirmMessage'
|
||||||
|
case 'prestataire':
|
||||||
|
return 'prestataires.deleteConfirmMessage'
|
||||||
|
default:
|
||||||
|
return 'clients.deleteConfirmMessage'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteTargetName = computed(() => {
|
||||||
const target = deleteTarget.value
|
const target = deleteTarget.value
|
||||||
if (!target) return ''
|
if (!target) return ''
|
||||||
switch (target.type) {
|
return target.type === 'prospect' ? target.item.company : target.item.name
|
||||||
case 'prospect':
|
|
||||||
return t('prospects.deleteConfirmMessage', { name: target.item.company })
|
|
||||||
case 'prestataire':
|
|
||||||
return t('prestataires.deleteConfirmMessage', { name: target.item.name })
|
|
||||||
default:
|
|
||||||
return t('clients.deleteConfirmMessage', { name: target.item.name })
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function askDeleteClient(item: Client) {
|
function askDeleteClient(item: Client) {
|
||||||
@@ -392,6 +444,9 @@ async function confirmDelete() {
|
|||||||
watch(statusFilter, loadProspects)
|
watch(statusFilter, loadProspects)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
// Restaure l'onglet quitté lors d'un retour depuis une fiche (flèche app ou
|
||||||
|
// navigateur). `null` (deep link / reload) → onglet Clients par défaut.
|
||||||
|
activeTab.value = readHistoryTab(tabKeys) ?? 'clients'
|
||||||
await Promise.all([loadClients(), loadProspects(), loadPrestataires()])
|
await Promise.all([loadClients(), loadProspects(), loadPrestataires()])
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,7 +2,14 @@
|
|||||||
<div>
|
<div>
|
||||||
<PageHeader>
|
<PageHeader>
|
||||||
<span class="inline-flex items-center gap-3">
|
<span class="inline-flex items-center gap-3">
|
||||||
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" />
|
<MalioButtonIcon
|
||||||
|
icon="mdi:arrow-left-bold"
|
||||||
|
icon-size="24"
|
||||||
|
variant="ghost"
|
||||||
|
:title="$t('common.back')"
|
||||||
|
:aria-label="$t('common.back')"
|
||||||
|
@click="goBack"
|
||||||
|
/>
|
||||||
{{ prestataire?.name ?? '…' }}
|
{{ prestataire?.name ?? '…' }}
|
||||||
</span>
|
</span>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
@@ -13,7 +20,7 @@
|
|||||||
<MalioTabList v-model="activeTab" :tabs="tabs">
|
<MalioTabList v-model="activeTab" :tabs="tabs">
|
||||||
<template #info>
|
<template #info>
|
||||||
<div class="flex flex-col gap-4 pt-6">
|
<div class="flex flex-col gap-4 pt-6">
|
||||||
<div class="relative grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]">
|
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4 pb-5">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="info.name"
|
v-model="info.name"
|
||||||
class="col-span-2"
|
class="col-span-2"
|
||||||
@@ -21,12 +28,12 @@
|
|||||||
:error="infoTouched.name && !info.name.trim() ? $t('directory.validation.nameRequired') : ''"
|
:error="infoTouched.name && !info.name.trim() ? $t('directory.validation.nameRequired') : ''"
|
||||||
@blur="infoTouched.name = true"
|
@blur="infoTouched.name = true"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputEmail
|
||||||
v-model="info.email"
|
v-model="info.email"
|
||||||
:label="$t('directory.info.fields.email')"
|
:label="$t('directory.info.fields.email')"
|
||||||
:error="emailError"
|
:error="emailError"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputPhone
|
||||||
v-model="info.phone"
|
v-model="info.phone"
|
||||||
:label="$t('directory.info.fields.phone')"
|
:label="$t('directory.info.fields.phone')"
|
||||||
:error="phoneError"
|
:error="phoneError"
|
||||||
@@ -40,7 +47,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex justify-center pt-2">
|
<div class="flex justify-center pt-2">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
button-class="w-auto px-6"
|
|
||||||
:label="$t('common.save')"
|
:label="$t('common.save')"
|
||||||
:disabled="savingInfo || !infoValid"
|
:disabled="savingInfo || !infoValid"
|
||||||
@click="saveInfo"
|
@click="saveInfo"
|
||||||
@@ -57,12 +63,13 @@
|
|||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
:title="$t('directory.contacts.item', { n: i + 1 })"
|
:title="$t('directory.contacts.item', { n: i + 1 })"
|
||||||
:removable="contacts.length > 0"
|
:removable="contacts.length > 0"
|
||||||
|
:last="i === contacts.length - 1"
|
||||||
@update:model-value="(v) => onContactInput(i, v)"
|
@update:model-value="(v) => onContactInput(i, v)"
|
||||||
@remove="removeContact(i)"
|
@remove="askRemoveContact(i)"
|
||||||
/>
|
/>
|
||||||
<div class="flex justify-center gap-3 pt-2">
|
<div class="flex justify-center gap-3 pt-2">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
variant="tertiary"
|
variant="secondary"
|
||||||
icon-name="mdi:plus"
|
icon-name="mdi:plus"
|
||||||
icon-position="left"
|
icon-position="left"
|
||||||
button-class="w-auto px-4"
|
button-class="w-auto px-4"
|
||||||
@@ -70,7 +77,6 @@
|
|||||||
@click="addContact"
|
@click="addContact"
|
||||||
/>
|
/>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
button-class="w-auto px-6"
|
|
||||||
:label="$t('common.save')"
|
:label="$t('common.save')"
|
||||||
:disabled="savingContacts"
|
:disabled="savingContacts"
|
||||||
@click="saveContacts"
|
@click="saveContacts"
|
||||||
@@ -87,12 +93,13 @@
|
|||||||
:model-value="address"
|
:model-value="address"
|
||||||
:title="$t('directory.addresses.item', { n: i + 1 })"
|
:title="$t('directory.addresses.item', { n: i + 1 })"
|
||||||
:removable="addresses.length > 0"
|
:removable="addresses.length > 0"
|
||||||
|
:last="i === addresses.length - 1"
|
||||||
@update:model-value="(v) => onAddressInput(i, v)"
|
@update:model-value="(v) => onAddressInput(i, v)"
|
||||||
@remove="removeAddress(i)"
|
@remove="askRemoveAddress(i)"
|
||||||
/>
|
/>
|
||||||
<div class="flex justify-center gap-3 pt-2">
|
<div class="flex justify-center gap-3 pt-2">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
variant="tertiary"
|
variant="secondary"
|
||||||
icon-name="mdi:plus"
|
icon-name="mdi:plus"
|
||||||
icon-position="left"
|
icon-position="left"
|
||||||
button-class="w-auto px-4"
|
button-class="w-auto px-4"
|
||||||
@@ -100,7 +107,6 @@
|
|||||||
@click="addAddress"
|
@click="addAddress"
|
||||||
/>
|
/>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
button-class="w-auto px-6"
|
|
||||||
:label="$t('common.save')"
|
:label="$t('common.save')"
|
||||||
:disabled="savingAddresses"
|
:disabled="savingAddresses"
|
||||||
@click="saveAddresses"
|
@click="saveAddresses"
|
||||||
@@ -115,6 +121,13 @@
|
|||||||
</MalioTabList>
|
</MalioTabList>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
v-model="removeModalOpen"
|
||||||
|
:title="removeModalTitle"
|
||||||
|
:message="removeModalMessage"
|
||||||
|
@confirm="confirmRemove"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -141,13 +154,17 @@ const {
|
|||||||
savingAddresses,
|
savingAddresses,
|
||||||
onContactInput,
|
onContactInput,
|
||||||
addContact,
|
addContact,
|
||||||
removeContact,
|
askRemoveContact,
|
||||||
saveContacts,
|
saveContacts,
|
||||||
onAddressInput,
|
onAddressInput,
|
||||||
addAddress,
|
addAddress,
|
||||||
removeAddress,
|
askRemoveAddress,
|
||||||
saveAddresses,
|
saveAddresses,
|
||||||
load,
|
load,
|
||||||
|
removeModalOpen,
|
||||||
|
removeModalTitle,
|
||||||
|
removeModalMessage,
|
||||||
|
confirmRemove,
|
||||||
} = useDirectoryDetail(owner)
|
} = useDirectoryDetail(owner)
|
||||||
|
|
||||||
const { can } = usePermissions()
|
const { can } = usePermissions()
|
||||||
@@ -190,7 +207,8 @@ async function saveInfo(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function goBack(): void {
|
function goBack(): void {
|
||||||
router.push('/directory')
|
// Retour sur l'onglet Prestataires de la liste (via history.state, hors URL).
|
||||||
|
router.push({ path: '/directory', state: { tab: 'prestataires' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|||||||
@@ -2,7 +2,14 @@
|
|||||||
<div>
|
<div>
|
||||||
<PageHeader>
|
<PageHeader>
|
||||||
<span class="inline-flex items-center gap-3">
|
<span class="inline-flex items-center gap-3">
|
||||||
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" />
|
<MalioButtonIcon
|
||||||
|
icon="mdi:arrow-left-bold"
|
||||||
|
icon-size="24"
|
||||||
|
variant="ghost"
|
||||||
|
:title="$t('common.back')"
|
||||||
|
:aria-label="$t('common.back')"
|
||||||
|
@click="goBack"
|
||||||
|
/>
|
||||||
{{ prospect?.company ?? '…' }}
|
{{ prospect?.company ?? '…' }}
|
||||||
</span>
|
</span>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
@@ -13,7 +20,7 @@
|
|||||||
<MalioTabList v-model="activeTab" :tabs="tabs">
|
<MalioTabList v-model="activeTab" :tabs="tabs">
|
||||||
<template #info>
|
<template #info>
|
||||||
<div class="flex flex-col gap-4 pt-6">
|
<div class="flex flex-col gap-4 pt-6">
|
||||||
<div class="relative grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]">
|
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4 pb-5">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
v-model="info.company"
|
v-model="info.company"
|
||||||
class="col-span-2"
|
class="col-span-2"
|
||||||
@@ -32,12 +39,12 @@
|
|||||||
:label="$t('prospects.fields.website')"
|
:label="$t('prospects.fields.website')"
|
||||||
:error="websiteError"
|
:error="websiteError"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputEmail
|
||||||
v-model="info.email"
|
v-model="info.email"
|
||||||
:label="$t('prospects.fields.email')"
|
:label="$t('prospects.fields.email')"
|
||||||
:error="emailError"
|
:error="emailError"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputPhone
|
||||||
v-model="info.phone"
|
v-model="info.phone"
|
||||||
:label="$t('prospects.fields.phone')"
|
:label="$t('prospects.fields.phone')"
|
||||||
:error="phoneError"
|
:error="phoneError"
|
||||||
@@ -47,15 +54,21 @@
|
|||||||
class="col-span-2"
|
class="col-span-2"
|
||||||
:label="$t('prospects.fields.source')"
|
:label="$t('prospects.fields.source')"
|
||||||
/>
|
/>
|
||||||
|
<!-- Notes : 2 colonnes, hauteur fixe (~2 lignes) avec scroll
|
||||||
|
interne. Pas de row-span (il déréglait l'auto-placement).
|
||||||
|
!max-w-none : neutralise le max-width:640px inline du
|
||||||
|
composant Malio (sinon la textarea ne remplit pas 2 colonnes). -->
|
||||||
<MalioInputTextArea
|
<MalioInputTextArea
|
||||||
v-model="info.notes"
|
v-model="info.notes"
|
||||||
class="col-span-2"
|
group-class="col-span-2"
|
||||||
|
text-input="!h-28 !max-w-none text-lg"
|
||||||
|
resize="none"
|
||||||
|
:reserve-message-space="false"
|
||||||
:label="$t('prospects.fields.notes')"
|
:label="$t('prospects.fields.notes')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-center pt-2">
|
<div class="flex justify-center pt-2">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
button-class="w-auto px-6"
|
|
||||||
:label="$t('common.save')"
|
:label="$t('common.save')"
|
||||||
:disabled="savingInfo || !infoValid"
|
:disabled="savingInfo || !infoValid"
|
||||||
@click="saveInfo"
|
@click="saveInfo"
|
||||||
@@ -72,12 +85,13 @@
|
|||||||
:model-value="contact"
|
:model-value="contact"
|
||||||
:title="$t('directory.contacts.item', { n: i + 1 })"
|
:title="$t('directory.contacts.item', { n: i + 1 })"
|
||||||
:removable="contacts.length > 0"
|
:removable="contacts.length > 0"
|
||||||
|
:last="i === contacts.length - 1"
|
||||||
@update:model-value="(v) => onContactInput(i, v)"
|
@update:model-value="(v) => onContactInput(i, v)"
|
||||||
@remove="removeContact(i)"
|
@remove="askRemoveContact(i)"
|
||||||
/>
|
/>
|
||||||
<div class="flex justify-center gap-3 pt-2">
|
<div class="flex justify-center gap-3 pt-2">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
variant="tertiary"
|
variant="secondary"
|
||||||
icon-name="mdi:plus"
|
icon-name="mdi:plus"
|
||||||
icon-position="left"
|
icon-position="left"
|
||||||
button-class="w-auto px-4"
|
button-class="w-auto px-4"
|
||||||
@@ -85,7 +99,6 @@
|
|||||||
@click="addContact"
|
@click="addContact"
|
||||||
/>
|
/>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
button-class="w-auto px-6"
|
|
||||||
:label="$t('common.save')"
|
:label="$t('common.save')"
|
||||||
:disabled="savingContacts"
|
:disabled="savingContacts"
|
||||||
@click="saveContacts"
|
@click="saveContacts"
|
||||||
@@ -102,12 +115,13 @@
|
|||||||
:model-value="address"
|
:model-value="address"
|
||||||
:title="$t('directory.addresses.item', { n: i + 1 })"
|
:title="$t('directory.addresses.item', { n: i + 1 })"
|
||||||
:removable="addresses.length > 0"
|
:removable="addresses.length > 0"
|
||||||
|
:last="i === addresses.length - 1"
|
||||||
@update:model-value="(v) => onAddressInput(i, v)"
|
@update:model-value="(v) => onAddressInput(i, v)"
|
||||||
@remove="removeAddress(i)"
|
@remove="askRemoveAddress(i)"
|
||||||
/>
|
/>
|
||||||
<div class="flex justify-center gap-3 pt-2">
|
<div class="flex justify-center gap-3 pt-2">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
variant="tertiary"
|
variant="secondary"
|
||||||
icon-name="mdi:plus"
|
icon-name="mdi:plus"
|
||||||
icon-position="left"
|
icon-position="left"
|
||||||
button-class="w-auto px-4"
|
button-class="w-auto px-4"
|
||||||
@@ -115,7 +129,6 @@
|
|||||||
@click="addAddress"
|
@click="addAddress"
|
||||||
/>
|
/>
|
||||||
<MalioButton
|
<MalioButton
|
||||||
button-class="w-auto px-6"
|
|
||||||
:label="$t('common.save')"
|
:label="$t('common.save')"
|
||||||
:disabled="savingAddresses"
|
:disabled="savingAddresses"
|
||||||
@click="saveAddresses"
|
@click="saveAddresses"
|
||||||
@@ -130,6 +143,13 @@
|
|||||||
</MalioTabList>
|
</MalioTabList>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
v-model="removeModalOpen"
|
||||||
|
:title="removeModalTitle"
|
||||||
|
:message="removeModalMessage"
|
||||||
|
@confirm="confirmRemove"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -156,13 +176,17 @@ const {
|
|||||||
savingAddresses,
|
savingAddresses,
|
||||||
onContactInput,
|
onContactInput,
|
||||||
addContact,
|
addContact,
|
||||||
removeContact,
|
askRemoveContact,
|
||||||
saveContacts,
|
saveContacts,
|
||||||
onAddressInput,
|
onAddressInput,
|
||||||
addAddress,
|
addAddress,
|
||||||
removeAddress,
|
askRemoveAddress,
|
||||||
saveAddresses,
|
saveAddresses,
|
||||||
load,
|
load,
|
||||||
|
removeModalOpen,
|
||||||
|
removeModalTitle,
|
||||||
|
removeModalMessage,
|
||||||
|
confirmRemove,
|
||||||
} = useDirectoryDetail(owner)
|
} = useDirectoryDetail(owner)
|
||||||
|
|
||||||
const { can } = usePermissions()
|
const { can } = usePermissions()
|
||||||
@@ -226,7 +250,8 @@ async function saveInfo(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function goBack(): void {
|
function goBack(): void {
|
||||||
router.push('/directory')
|
// Retour sur l'onglet Prospects de la liste (via history.state, hors URL).
|
||||||
|
router.push({ path: '/directory', state: { tab: 'prospects' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|||||||
@@ -32,6 +32,13 @@
|
|||||||
empty-option-label="Aucun client"
|
empty-option-label="Aucun client"
|
||||||
group-class="w-full"
|
group-class="w-full"
|
||||||
/>
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
v-if="!isEditing"
|
||||||
|
v-model="form.workflowId"
|
||||||
|
:options="workflowOptions"
|
||||||
|
label="Workflow"
|
||||||
|
group-class="w-full"
|
||||||
|
/>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<ColorPicker v-model="form.color" />
|
<ColorPicker v-model="form.color" />
|
||||||
</div>
|
</div>
|
||||||
@@ -124,10 +131,12 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Project, ProjectWrite } from '~/modules/project-management/services/dto/project'
|
import type { Project, ProjectWrite } from '~/modules/project-management/services/dto/project'
|
||||||
|
import type { Workflow } from '~/modules/project-management/services/dto/workflow'
|
||||||
import type { Client } from '~/modules/directory/services/dto/client'
|
import type { Client } from '~/modules/directory/services/dto/client'
|
||||||
import type { GiteaRepository } from '~/modules/integration/services/dto/gitea'
|
import type { GiteaRepository } from '~/modules/integration/services/dto/gitea'
|
||||||
import type { BookStackShelf } from '~/modules/integration/services/dto/bookstack'
|
import type { BookStackShelf } from '~/modules/integration/services/dto/bookstack'
|
||||||
import { useProjectService } from '~/modules/project-management/services/projects'
|
import { useProjectService } from '~/modules/project-management/services/projects'
|
||||||
|
import { useWorkflowService } from '~/modules/project-management/services/workflows'
|
||||||
import { useGiteaService } from '~/modules/integration/services/gitea'
|
import { useGiteaService } from '~/modules/integration/services/gitea'
|
||||||
import { useBookStackService } from '~/modules/integration/services/bookstack'
|
import { useBookStackService } from '~/modules/integration/services/bookstack'
|
||||||
|
|
||||||
@@ -174,12 +183,24 @@ const bookstackShelfOptions = computed(() =>
|
|||||||
bookstackShelves.value.map(s => ({ label: s.name, value: s.id }))
|
bookstackShelves.value.map(s => ({ label: s.name, value: s.id }))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const { getAll: getAllWorkflows } = useWorkflowService()
|
||||||
|
const workflows = ref<Workflow[]>([])
|
||||||
|
|
||||||
|
const workflowOptions = computed(() =>
|
||||||
|
workflows.value.map(w => ({ label: w.name, value: w.id }))
|
||||||
|
)
|
||||||
|
|
||||||
|
function defaultWorkflowId(): number | null {
|
||||||
|
return (workflows.value.find(w => w.isDefault) ?? workflows.value[0])?.id ?? null
|
||||||
|
}
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
code: '',
|
code: '',
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
color: '#222783',
|
color: '#222783',
|
||||||
clientId: null as number | null,
|
clientId: null as number | null,
|
||||||
|
workflowId: null as number | null,
|
||||||
giteaRepoFullName: null as string | null,
|
giteaRepoFullName: null as string | null,
|
||||||
bookstackShelfId: null as number | null,
|
bookstackShelfId: null as number | null,
|
||||||
})
|
})
|
||||||
@@ -222,6 +243,7 @@ watch(() => props.modelValue, (open) => {
|
|||||||
form.description = ''
|
form.description = ''
|
||||||
form.color = '#222783'
|
form.color = '#222783'
|
||||||
form.clientId = null
|
form.clientId = null
|
||||||
|
form.workflowId = defaultWorkflowId()
|
||||||
form.giteaRepoFullName = null
|
form.giteaRepoFullName = null
|
||||||
form.bookstackShelfId = null
|
form.bookstackShelfId = null
|
||||||
}
|
}
|
||||||
@@ -269,6 +291,9 @@ async function handleSubmit() {
|
|||||||
await update(props.project.id, payload)
|
await update(props.project.id, payload)
|
||||||
} else {
|
} else {
|
||||||
payload.code = form.code
|
payload.code = form.code
|
||||||
|
if (form.workflowId) {
|
||||||
|
payload.workflow = `/api/workflows/${form.workflowId}`
|
||||||
|
}
|
||||||
await create(payload)
|
await create(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,6 +333,15 @@ async function handleArchiveToggle() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
workflows.value = await getAllWorkflows()
|
||||||
|
// Si le drawer est déjà ouvert en création, pré-remplir une fois les workflows chargés.
|
||||||
|
if (props.modelValue && !props.project && !form.workflowId) {
|
||||||
|
form.workflowId = defaultWorkflowId()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Workflows indisponibles, ignore (le serveur assignera le défaut)
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
giteaRepos.value = await listRepositories()
|
giteaRepos.value = await listRepositories()
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -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
+886
-132
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@
|
|||||||
"@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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ export type UserData = {
|
|||||||
effectivePermissions?: string[]
|
effectivePermissions?: string[]
|
||||||
avatarUrl?: string | null
|
avatarUrl?: string | null
|
||||||
apiToken?: string | null
|
apiToken?: string | null
|
||||||
|
// Soft-delete flag: an archived user keeps its data but cannot log in
|
||||||
|
archived?: boolean
|
||||||
// HR / absence management
|
// HR / absence management
|
||||||
isEmployee?: boolean
|
isEmployee?: boolean
|
||||||
hireDate?: string | null
|
hireDate?: string | null
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ export function useUserService() {
|
|||||||
return extractHydraMembers(data)
|
return extractHydraMembers(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Archived users are hidden from the default collection; an admin lists
|
||||||
|
// them explicitly via the `archived` filter (handled server-side).
|
||||||
|
async function getArchived(): Promise<UserData[]> {
|
||||||
|
const data = await api.get<HydraCollection<UserData>>('/users?archived=true')
|
||||||
|
return extractHydraMembers(data)
|
||||||
|
}
|
||||||
|
|
||||||
async function getById(id: number): Promise<UserData> {
|
async function getById(id: number): Promise<UserData> {
|
||||||
return api.get<UserData>(`/users/${id}`)
|
return api.get<UserData>(`/users/${id}`)
|
||||||
}
|
}
|
||||||
@@ -26,11 +33,19 @@ export function useUserService() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deleting a user is a soft delete server-side: the account is archived
|
||||||
|
// (kept for referential integrity) rather than removed.
|
||||||
async function remove(id: number): Promise<void> {
|
async function remove(id: number): Promise<void> {
|
||||||
await api.delete(`/users/${id}`, {}, {
|
await api.delete(`/users/${id}`, {}, {
|
||||||
toastSuccessKey: 'users.deleted',
|
toastSuccessKey: 'users.archived',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return { getAll, getById, create, update, remove }
|
async function restore(id: number): Promise<UserData> {
|
||||||
|
return api.patch<UserData>(`/users/${id}`, { archived: false }, {
|
||||||
|
toastSuccessKey: 'users.restored',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getAll, getArchived, getById, create, update, remove, restore }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Onglet actif transmis d'une page à l'autre via l'état d'historique
|
||||||
|
* (`history.state`), SANS le mettre dans l'URL. Sert à préserver l'onglet courant
|
||||||
|
* du Répertoire (Clients / Prospects / Prestataires) lors de l'aller-retour
|
||||||
|
* liste ↔ fiche, dans les deux sens (flèche de l'app ET flèche du navigateur).
|
||||||
|
*
|
||||||
|
* On reste fidèle à la règle « état d'UI local, pas dans l'URL » : l'onglet
|
||||||
|
* voyage dans l'entrée d'historique de la navigation, l'URL ne change pas.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lit la clé d'onglet posée dans `history.state.tab` si elle fait partie des
|
||||||
|
* onglets valides. Retourne `null` sinon : navigation directe / deep link,
|
||||||
|
* rechargement de page, ou onglet inexistant.
|
||||||
|
*/
|
||||||
|
export function readHistoryTab(validKeys: string[]): string | null {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const tab = (window.history.state as Record<string, unknown> | null)?.tab
|
||||||
|
return typeof tab === 'string' && validKeys.includes(tab) ? tab : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estampille l'entrée d'historique COURANTE avec l'onglet actif, sans créer de
|
||||||
|
* nouvelle entrée ni changer l'URL. À appeler juste avant de naviguer vers une
|
||||||
|
* fiche : au retour via la flèche du navigateur (popstate), cette entrée
|
||||||
|
* « liste » est restaurée avec son onglet.
|
||||||
|
*/
|
||||||
|
export function stampHistoryTab(tab: string): void {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.history.replaceState({ ...window.history.state, tab }, '')
|
||||||
|
}
|
||||||
+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-----
|
||||||
@@ -38,7 +38,7 @@ restart: env-init
|
|||||||
$(DOCKER_COMPOSE) down
|
$(DOCKER_COMPOSE) down
|
||||||
CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d
|
CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d
|
||||||
|
|
||||||
install: copy-git-hook composer-install cache-clear node-use build-nuxtJS migration-migrate sync-permissions
|
install: copy-git-hook composer-install cache-clear node-use build-nuxtJS migration-migrate sync-permissions fix-uploads-perm
|
||||||
|
|
||||||
# Supprime tout est réinstalle tout (Attention ça supprime la bdd aussi)
|
# Supprime tout est réinstalle tout (Attention ça supprime la bdd aussi)
|
||||||
reset: delete_built_dir remove_orphans build-without-cache start wait install
|
reset: delete_built_dir remove_orphans build-without-cache start wait install
|
||||||
@@ -81,6 +81,13 @@ migration-migrate:
|
|||||||
sync-permissions:
|
sync-permissions:
|
||||||
$(SYMFONY_CONSOLE) app:sync-permissions
|
$(SYMFONY_CONSOLE) app:sync-permissions
|
||||||
|
|
||||||
|
# Le volume nommé `uploads_data` est créé root:root par Docker (il masque le
|
||||||
|
# bind-mount), or PHP-FPM tourne en www-data (= uid host) : sans ce chown, les
|
||||||
|
# uploads (documents de compte-rendu, avatars, justificatifs…) échouent en local
|
||||||
|
# avec « mkdir(): Permission denied ». Idempotent — relancé par `install`/`reset`.
|
||||||
|
fix-uploads-perm:
|
||||||
|
$(EXEC_PHP_ROOT) chown -R www-data:www-data /var/www/html/var/uploads
|
||||||
|
|
||||||
fixtures:
|
fixtures:
|
||||||
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
|
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a soft-delete flag on user. Deleting a user now archives it instead of
|
||||||
|
* removing the row, preserving referential integrity (tasks, time entries,
|
||||||
|
* notifications…). Existing users are kept active (archived = false).
|
||||||
|
*/
|
||||||
|
final class Version20260626153721 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add archived flag on user (soft delete)';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE "user" ADD archived BOOLEAN DEFAULT false NOT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE "user" DROP archived');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,10 @@
|
|||||||
<php>
|
<php>
|
||||||
<ini name="display_errors" value="1" />
|
<ini name="display_errors" value="1" />
|
||||||
<ini name="error_reporting" value="-1" />
|
<ini name="error_reporting" value="-1" />
|
||||||
|
<!-- API Platform's serializer/metadata boot is memory-hungry on the first
|
||||||
|
call in a process (cold phpdoc + serializer metadata). 128M is too
|
||||||
|
tight for non-paginated collections such as GET /api/users. -->
|
||||||
|
<ini name="memory_limit" value="512M" />
|
||||||
<server name="APP_ENV" value="test" force="true" />
|
<server name="APP_ENV" value="test" force="true" />
|
||||||
<server name="SHELL_VERBOSITY" value="-1" />
|
<server name="SHELL_VERBOSITY" value="-1" />
|
||||||
<server name="KERNEL_CLASS" value="App\Kernel" />
|
<server name="KERNEL_CLASS" value="App\Kernel" />
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
|
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recreates user rows that are still referenced by other tables but no longer
|
||||||
|
* exist (legacy hard-deletes performed before the foreign keys enforced
|
||||||
|
* ON DELETE SET NULL / CASCADE). Recreated accounts are archived: their data
|
||||||
|
* (tasks, time entries, notifications…) is preserved and references become
|
||||||
|
* valid again, fixing the serialization crash (EntityNotFoundException), but
|
||||||
|
* the accounts cannot log in and are hidden from selectable user lists.
|
||||||
|
*
|
||||||
|
* Idempotent and non-destructive — nothing is deleted.
|
||||||
|
*/
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:restore-missing-users',
|
||||||
|
description: 'Recreate (as archived) users that are still referenced but were hard-deleted, to restore referential integrity',
|
||||||
|
)]
|
||||||
|
final class RestoreMissingUsersCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly Connection $connection,
|
||||||
|
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'List missing user ids without recreating them');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$dryRun = (bool) $input->getOption('dry-run');
|
||||||
|
|
||||||
|
// 1. Discover every (table, column) that references "user" via a foreign key.
|
||||||
|
$references = $this->connection->fetchAllAssociative(<<<'SQL'
|
||||||
|
SELECT t.relname AS child_table, a.attname AS child_col
|
||||||
|
FROM pg_constraint c
|
||||||
|
JOIN pg_class t ON t.oid = c.conrelid
|
||||||
|
JOIN pg_class rt ON rt.oid = c.confrelid
|
||||||
|
JOIN unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord) ON true
|
||||||
|
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
|
||||||
|
WHERE c.contype = 'f' AND rt.relname = 'user'
|
||||||
|
ORDER BY t.relname, a.attname
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
// 2. Collect distinct orphan ids across all those columns.
|
||||||
|
$missingIds = [];
|
||||||
|
foreach ($references as $ref) {
|
||||||
|
$table = $ref['child_table'];
|
||||||
|
$col = $ref['child_col'];
|
||||||
|
|
||||||
|
$ids = $this->connection->fetchFirstColumn(sprintf(
|
||||||
|
'SELECT DISTINCT %1$s FROM %2$s WHERE %1$s IS NOT NULL AND %1$s NOT IN (SELECT id FROM "user")',
|
||||||
|
$this->connection->quoteIdentifier($col),
|
||||||
|
$this->connection->quoteIdentifier($table),
|
||||||
|
));
|
||||||
|
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
$missingIds[(int) $id] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$missingIds = array_keys($missingIds);
|
||||||
|
sort($missingIds);
|
||||||
|
|
||||||
|
$io->section(sprintf('%d foreign-key column(s) scanned', count($references)));
|
||||||
|
|
||||||
|
if ([] === $missingIds) {
|
||||||
|
$io->success('No missing users referenced. Nothing to restore.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->writeln(sprintf('Missing user id(s): %s', implode(', ', $missingIds)));
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$io->note('Dry run — no user recreated.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Recreate each missing user as an archived placeholder, preserving its id.
|
||||||
|
$hash = $this->passwordHasher->hashPassword(new User(), bin2hex(random_bytes(16)));
|
||||||
|
$created = 0;
|
||||||
|
|
||||||
|
foreach ($missingIds as $id) {
|
||||||
|
$inserted = $this->connection->executeStatement(
|
||||||
|
<<<'SQL'
|
||||||
|
INSERT INTO "user"
|
||||||
|
(id, username, first_name, last_name, roles, password, created_at,
|
||||||
|
is_employee, work_time_ratio, annual_leave_days, reference_period_start,
|
||||||
|
initial_leave_balance, archived)
|
||||||
|
VALUES
|
||||||
|
(:id, :username, :firstName, :lastName, :roles, :password, NOW(),
|
||||||
|
false, 1.0, 25.0, '06-01', 0.0, true)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
SQL,
|
||||||
|
[
|
||||||
|
'id' => $id,
|
||||||
|
'username' => sprintf('deleted-user-%d', $id),
|
||||||
|
'firstName' => 'Compte',
|
||||||
|
'lastName' => sprintf('supprimé #%d', $id),
|
||||||
|
'roles' => json_encode(['ROLE_USER'], JSON_THROW_ON_ERROR),
|
||||||
|
'password' => $hash,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ON CONFLICT may have skipped an already-present row — only count real inserts.
|
||||||
|
if ($inserted > 0) {
|
||||||
|
++$created;
|
||||||
|
$io->writeln(sprintf(' ✓ user #%d recreated (archived)', $id));
|
||||||
|
} else {
|
||||||
|
$io->writeln(sprintf(' • user #%d already present — skipped', $id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->success(sprintf('%d user(s) restored as archived. References are valid again — no data lost.', $created));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Module\Core\Domain\Entity;
|
namespace App\Module\Core\Domain\Entity;
|
||||||
|
|
||||||
|
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
||||||
|
use ApiPlatform\Metadata\ApiFilter;
|
||||||
use ApiPlatform\Metadata\ApiProperty;
|
use ApiPlatform\Metadata\ApiProperty;
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
use ApiPlatform\Metadata\Delete;
|
use ApiPlatform\Metadata\Delete;
|
||||||
@@ -13,6 +15,7 @@ use ApiPlatform\Metadata\Patch;
|
|||||||
use ApiPlatform\Metadata\Post;
|
use ApiPlatform\Metadata\Post;
|
||||||
use App\Module\Core\Domain\Enum\ContractType;
|
use App\Module\Core\Domain\Enum\ContractType;
|
||||||
use App\Module\Core\Infrastructure\ApiPlatform\State\MeProvider;
|
use App\Module\Core\Infrastructure\ApiPlatform\State\MeProvider;
|
||||||
|
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserArchiveProcessor;
|
||||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
|
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
|
||||||
use App\Module\Core\Infrastructure\ApiPlatform\State\UserPasswordHasherProcessor;
|
use App\Module\Core\Infrastructure\ApiPlatform\State\UserPasswordHasherProcessor;
|
||||||
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
|
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
|
||||||
@@ -47,7 +50,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
),
|
),
|
||||||
new Post(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
|
new Post(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
|
||||||
new Patch(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
|
new Patch(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
|
||||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
new Delete(security: "is_granted('ROLE_ADMIN')", processor: UserArchiveProcessor::class),
|
||||||
new Get(
|
new Get(
|
||||||
uriTemplate: '/users/{id}/rbac',
|
uriTemplate: '/users/{id}/rbac',
|
||||||
security: "is_granted('core.users.manage')",
|
security: "is_granted('core.users.manage')",
|
||||||
@@ -63,6 +66,9 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
],
|
],
|
||||||
denormalizationContext: ['groups' => ['user:write']],
|
denormalizationContext: ['groups' => ['user:write']],
|
||||||
)]
|
)]
|
||||||
|
// Archived users are hidden from the default /users collection by
|
||||||
|
// ExcludeArchivedUserExtension; an admin can still list them with ?archived=true.
|
||||||
|
#[ApiFilter(BooleanFilter::class, properties: ['archived'])]
|
||||||
#[Auditable]
|
#[Auditable]
|
||||||
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
|
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
|
||||||
#[ORM\Table(name: '`user`')]
|
#[ORM\Table(name: '`user`')]
|
||||||
@@ -111,6 +117,16 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
|
|||||||
#[ORM\Column(length: 255, nullable: true)]
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
private ?string $avatarFileName = null;
|
private ?string $avatarFileName = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft-delete flag. Archived users are kept for referential integrity
|
||||||
|
* (tasks, time entries, notifications…) but cannot log in and are hidden
|
||||||
|
* from selectable user lists.
|
||||||
|
*/
|
||||||
|
#[ORM\Column(options: ['default' => false])]
|
||||||
|
#[ApiProperty(security: "is_granted('ROLE_ADMIN')")]
|
||||||
|
#[Groups(['me:read', 'user:list', 'user:write'])]
|
||||||
|
private bool $archived = false;
|
||||||
|
|
||||||
// --- HR / absence management fields (readable only by an admin or the user themselves) ---
|
// --- HR / absence management fields (readable only by an admin or the user themselves) ---
|
||||||
|
|
||||||
/** Whether this user is an employee subject to absence management. */
|
/** Whether this user is an employee subject to absence management. */
|
||||||
@@ -228,6 +244,18 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
|
|||||||
return (string) $this->username;
|
return (string) $this->username;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isArchived(): bool
|
||||||
|
{
|
||||||
|
return $this->archived;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setArchived(bool $archived): static
|
||||||
|
{
|
||||||
|
$this->archived = $archived;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
/** @return list<string> */
|
/** @return list<string> */
|
||||||
public function getRoles(): array
|
public function getRoles(): array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Core\Infrastructure\ApiPlatform\Extension;
|
||||||
|
|
||||||
|
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
|
||||||
|
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use Doctrine\ORM\QueryBuilder;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
|
||||||
|
use function array_key_exists;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hides archived (soft-deleted) users from the `/users` collection so they are
|
||||||
|
* no longer offered as assignees/collaborators, while existing references to
|
||||||
|
* them (already stored on tasks, time entries…) keep resolving normally.
|
||||||
|
*
|
||||||
|
* An admin can opt back in to see archived users — e.g. to restore one — by
|
||||||
|
* passing the `archived` query filter explicitly (`?archived=true`), in which
|
||||||
|
* case the BooleanFilter declared on User handles the predicate instead.
|
||||||
|
*/
|
||||||
|
final readonly class ExcludeArchivedUserExtension implements QueryCollectionExtensionInterface
|
||||||
|
{
|
||||||
|
public function __construct(private Security $security) {}
|
||||||
|
|
||||||
|
public function applyToCollection(
|
||||||
|
QueryBuilder $queryBuilder,
|
||||||
|
QueryNameGeneratorInterface $queryNameGenerator,
|
||||||
|
string $resourceClass,
|
||||||
|
?Operation $operation = null,
|
||||||
|
array $context = [],
|
||||||
|
): void {
|
||||||
|
if (User::class !== $resourceClass) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let an admin explicitly query archived users via ?archived=...
|
||||||
|
$filters = $context['filters'] ?? [];
|
||||||
|
if (array_key_exists('archived', $filters) && $this->security->isGranted('ROLE_ADMIN')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$alias = $queryBuilder->getRootAliases()[0];
|
||||||
|
$queryBuilder->andWhere(sprintf('%s.archived = false', $alias));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Core\Infrastructure\ApiPlatform\State\Processor;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
|
||||||
|
use function assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft-delete processor wired on the User `Delete` operation: instead of
|
||||||
|
* removing the row (which would orphan every task / time entry / notification
|
||||||
|
* referencing it and break their serialization), the user is archived. The
|
||||||
|
* account is kept for referential integrity but can no longer log in
|
||||||
|
* (ArchivedUserChecker) and is hidden from selectable user lists
|
||||||
|
* (ExcludeArchivedUserExtension).
|
||||||
|
*
|
||||||
|
* @implements ProcessorInterface<User, null|User>
|
||||||
|
*/
|
||||||
|
final readonly class UserArchiveProcessor implements ProcessorInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EntityManagerInterface $em,
|
||||||
|
private Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ?User
|
||||||
|
{
|
||||||
|
assert($data instanceof User);
|
||||||
|
|
||||||
|
// Prevent an admin from archiving (locking out) their own account.
|
||||||
|
$current = $this->security->getUser();
|
||||||
|
if ($current instanceof User && $current->getId() === $data->getId()) {
|
||||||
|
throw new AccessDeniedHttpException('You cannot archive your own account.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($data->isArchived()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data->setArchived(true);
|
||||||
|
$data->setApiToken(null);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Core\Infrastructure\Security;
|
||||||
|
|
||||||
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||||
|
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
|
||||||
|
use Symfony\Component\Security\Core\User\UserCheckerInterface;
|
||||||
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rejects authentication for archived (soft-deleted) users, both at password
|
||||||
|
* login and on every JWT-authenticated request, so an archived account is
|
||||||
|
* effectively locked out while its data is preserved.
|
||||||
|
*/
|
||||||
|
final class ArchivedUserChecker implements UserCheckerInterface
|
||||||
|
{
|
||||||
|
public function checkPreAuth(UserInterface $user, ?TokenInterface $token = null): void
|
||||||
|
{
|
||||||
|
if ($user instanceof User && $user->isArchived()) {
|
||||||
|
throw new CustomUserMessageAccountStatusException('This account has been archived.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function checkPostAuth(UserInterface $user, ?TokenInterface $token = null): void {}
|
||||||
|
}
|
||||||
@@ -92,10 +92,11 @@ class Project implements ProjectInterface, TimestampableInterface, BlamableInter
|
|||||||
#[Groups(['project:read', 'project:write'])]
|
#[Groups(['project:read', 'project:write'])]
|
||||||
private ?ClientInterface $client = null;
|
private ?ClientInterface $client = null;
|
||||||
|
|
||||||
|
// workflow_id reste NOT NULL en base ; quand l'appelant n'en fournit pas,
|
||||||
|
// ProjectDefaultWorkflowListener assigne le workflow par défaut au prePersist.
|
||||||
#[ORM\ManyToOne(targetEntity: Workflow::class)]
|
#[ORM\ManyToOne(targetEntity: Workflow::class)]
|
||||||
#[ORM\JoinColumn(nullable: false, onDelete: 'RESTRICT')]
|
#[ORM\JoinColumn(nullable: false, onDelete: 'RESTRICT')]
|
||||||
#[Groups(['project:read', 'project:write', 'task:read'])]
|
#[Groups(['project:read', 'project:write', 'task:read'])]
|
||||||
#[Assert\NotNull(message: 'Un projet doit avoir un workflow.')]
|
|
||||||
private ?Workflow $workflow = null;
|
private ?Workflow $workflow = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 255, nullable: true)]
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
|||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\ProjectManagement\Infrastructure\EventListener;
|
||||||
|
|
||||||
|
use App\Module\ProjectManagement\Domain\Entity\Project;
|
||||||
|
use App\Module\ProjectManagement\Domain\Repository\WorkflowRepositoryInterface;
|
||||||
|
use Doctrine\ORM\Event\PrePersistEventArgs;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assigns the default workflow to a project when none was provided.
|
||||||
|
* Guarantees the NOT NULL workflow_id constraint across every persistence
|
||||||
|
* path (API Platform, raw API, MCP) without forcing the caller to supply one.
|
||||||
|
*/
|
||||||
|
final readonly class ProjectDefaultWorkflowListener
|
||||||
|
{
|
||||||
|
public function __construct(private WorkflowRepositoryInterface $workflowRepository) {}
|
||||||
|
|
||||||
|
public function prePersist(Project $project, PrePersistEventArgs $args): void
|
||||||
|
{
|
||||||
|
if (null !== $project->getWorkflow()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$default = $this->workflowRepository->findDefault()
|
||||||
|
?? ($this->workflowRepository->findBy([], ['position' => 'ASC'], 1)[0] ?? null);
|
||||||
|
|
||||||
|
if (null === $default) {
|
||||||
|
throw new RuntimeException('Cannot create a project: no workflow exists. Seed at least one workflow first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$project->setWorkflow($default);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Project;
|
|||||||
|
|
||||||
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
|
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
|
||||||
use App\Module\ProjectManagement\Domain\Entity\Project;
|
use App\Module\ProjectManagement\Domain\Entity\Project;
|
||||||
|
use App\Module\ProjectManagement\Domain\Repository\WorkflowRepositoryInterface;
|
||||||
use App\Shared\Infrastructure\Mcp\Serializer;
|
use App\Shared\Infrastructure\Mcp\Serializer;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
@@ -15,12 +16,13 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
|||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
#[McpTool(name: 'create-project', description: 'Create a new project. Code must be 2-10 uppercase letters.')]
|
#[McpTool(name: 'create-project', description: 'Create a new project. Code must be 2-10 uppercase letters. Optional workflowId selects the kanban workflow; the default workflow is used when omitted.')]
|
||||||
class CreateProjectTool
|
class CreateProjectTool
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly EntityManagerInterface $entityManager,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
private readonly ClientRepositoryInterface $clientRepository,
|
private readonly ClientRepositoryInterface $clientRepository,
|
||||||
|
private readonly WorkflowRepositoryInterface $workflowRepository,
|
||||||
private readonly Security $security,
|
private readonly Security $security,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -30,6 +32,7 @@ class CreateProjectTool
|
|||||||
?string $description = null,
|
?string $description = null,
|
||||||
?string $color = null,
|
?string $color = null,
|
||||||
?int $clientId = null,
|
?int $clientId = null,
|
||||||
|
?int $workflowId = null,
|
||||||
): string {
|
): string {
|
||||||
if (!$this->security->isGranted('ROLE_USER')) {
|
if (!$this->security->isGranted('ROLE_USER')) {
|
||||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||||
@@ -52,6 +55,14 @@ class CreateProjectTool
|
|||||||
}
|
}
|
||||||
$project->setClient($client);
|
$project->setClient($client);
|
||||||
}
|
}
|
||||||
|
if (null !== $workflowId) {
|
||||||
|
$workflow = $this->workflowRepository->findById($workflowId);
|
||||||
|
if (null === $workflow) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Workflow with ID %d not found.', $workflowId));
|
||||||
|
}
|
||||||
|
$project->setWorkflow($workflow);
|
||||||
|
}
|
||||||
|
// When no workflow is supplied, ProjectDefaultWorkflowListener assigns the default at prePersist.
|
||||||
|
|
||||||
$this->entityManager->persist($project);
|
$this->entityManager->persist($project);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|||||||
@@ -23,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.
|
||||||
@@ -59,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(),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,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,145 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Functional\Module\Core;
|
||||||
|
|
||||||
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Covers the soft-delete behaviour: deleting a user archives it (the row is
|
||||||
|
* kept so referencing tasks/time entries still serialize), archived users are
|
||||||
|
* hidden from the default collection but an admin can list and restore them.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class UserArchiveApiTest extends WebTestCase
|
||||||
|
{
|
||||||
|
public function testDeleteArchivesUserInsteadOfRemovingIt(): void
|
||||||
|
{
|
||||||
|
$client = self::createClient();
|
||||||
|
$em = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
$target = $this->createUser($em, 'archive-target-'.uniqid());
|
||||||
|
$em->flush();
|
||||||
|
$targetId = $target->getId();
|
||||||
|
|
||||||
|
$this->loginAdmin($client);
|
||||||
|
$client->request('DELETE', '/api/users/'.$targetId);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(204);
|
||||||
|
|
||||||
|
$em->clear();
|
||||||
|
$reloaded = $em->getRepository(User::class)->find($targetId);
|
||||||
|
self::assertInstanceOf(User::class, $reloaded, 'Row must still exist (soft delete)');
|
||||||
|
self::assertTrue($reloaded->isArchived(), 'User must be flagged archived');
|
||||||
|
self::assertNull($reloaded->getApiToken(), 'API token must be cleared on archive');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAdminCannotArchiveOwnAccount(): void
|
||||||
|
{
|
||||||
|
$client = self::createClient();
|
||||||
|
$em = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
$this->loginAdmin($client);
|
||||||
|
$adminId = $this->userId('admin');
|
||||||
|
|
||||||
|
$client->request('DELETE', '/api/users/'.$adminId);
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(403);
|
||||||
|
|
||||||
|
$em->clear();
|
||||||
|
$admin = $em->getRepository(User::class)->find($adminId);
|
||||||
|
self::assertFalse($admin->isArchived(), 'Admin must not have archived itself');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testArchivedUserIsHiddenFromDefaultCollection(): void
|
||||||
|
{
|
||||||
|
$client = self::createClient();
|
||||||
|
$username = $this->createArchivedUser();
|
||||||
|
|
||||||
|
$this->loginAdmin($client);
|
||||||
|
$client->request('GET', '/api/users', server: ['HTTP_ACCEPT' => 'application/json']);
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
$usernames = array_column(json_decode($client->getResponse()->getContent(), true), 'username');
|
||||||
|
self::assertNotContains($username, $usernames, 'Archived user must not appear in the default list');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAdminCanListArchivedUsersViaFilter(): void
|
||||||
|
{
|
||||||
|
$client = self::createClient();
|
||||||
|
$username = $this->createArchivedUser();
|
||||||
|
|
||||||
|
$this->loginAdmin($client);
|
||||||
|
$client->request('GET', '/api/users?archived=true', server: ['HTTP_ACCEPT' => 'application/json']);
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
$usernames = array_column(json_decode($client->getResponse()->getContent(), true), 'username');
|
||||||
|
self::assertContains($username, $usernames, 'Admin must be able to list archived users via ?archived=true');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAdminCanRestoreUserViaPatch(): void
|
||||||
|
{
|
||||||
|
$client = self::createClient();
|
||||||
|
$em = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$user = $this->createUser($em, 'restore-target-'.uniqid());
|
||||||
|
$user->setArchived(true);
|
||||||
|
$em->flush();
|
||||||
|
$userId = $user->getId();
|
||||||
|
$em->clear();
|
||||||
|
|
||||||
|
$this->loginAdmin($client);
|
||||||
|
$client->request('PATCH', '/api/users/'.$userId, server: [
|
||||||
|
'CONTENT_TYPE' => 'application/merge-patch+json',
|
||||||
|
], content: json_encode(['archived' => false]));
|
||||||
|
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
$em->clear();
|
||||||
|
$reloaded = $em->getRepository(User::class)->find($userId);
|
||||||
|
self::assertFalse($reloaded->isArchived(), 'Admin PATCH must be able to un-archive a user');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createArchivedUser(): string
|
||||||
|
{
|
||||||
|
$em = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
$username = 'archived-'.uniqid();
|
||||||
|
$user = $this->createUser($em, $username);
|
||||||
|
$user->setArchived(true);
|
||||||
|
$em->flush();
|
||||||
|
$em->clear();
|
||||||
|
|
||||||
|
return $username;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createUser(EntityManagerInterface $em, string $username): User
|
||||||
|
{
|
||||||
|
$user = new User();
|
||||||
|
$user->setUsername($username);
|
||||||
|
$user->setPassword('x');
|
||||||
|
$user->setRoles(['ROLE_USER']);
|
||||||
|
$em->persist($user);
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loginAdmin(KernelBrowser $client): void
|
||||||
|
{
|
||||||
|
$em = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
$user = $em->getRepository(User::class)->findOneBy(['username' => 'admin']);
|
||||||
|
self::assertInstanceOf(User::class, $user);
|
||||||
|
$client->loginUser($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function userId(string $username): int
|
||||||
|
{
|
||||||
|
$em = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
$user = $em->getRepository(User::class)->findOneBy(['username' => $username]);
|
||||||
|
self::assertInstanceOf(User::class, $user);
|
||||||
|
|
||||||
|
return $user->getId();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Functional\Module\ProjectManagement;
|
||||||
|
|
||||||
|
use App\Module\Core\Domain\Entity\Permission;
|
||||||
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use App\Module\ProjectManagement\Domain\Entity\Workflow;
|
||||||
|
use App\Module\ProjectManagement\Domain\Repository\WorkflowRepositoryInterface;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie que la création d'un projet fonctionne avec ou sans workflow fourni :
|
||||||
|
* - sans workflow → le workflow par défaut est assigné par le listener prePersist
|
||||||
|
* - avec workflow → le workflow choisi est conservé.
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class ProjectCreationWorkflowTest extends WebTestCase
|
||||||
|
{
|
||||||
|
public function testCreateProjectWithoutWorkflowAssignsDefault(): void
|
||||||
|
{
|
||||||
|
$client = self::createClient();
|
||||||
|
$em = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$client->loginUser($this->createManager($em));
|
||||||
|
|
||||||
|
$client->request('POST', '/api/projects', server: [
|
||||||
|
'CONTENT_TYPE' => 'application/ld+json',
|
||||||
|
], content: json_encode([
|
||||||
|
'code' => $this->randomCode(),
|
||||||
|
'name' => 'Projet sans workflow',
|
||||||
|
]));
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
$data = json_decode($client->getResponse()->getContent(), true);
|
||||||
|
self::assertArrayHasKey('workflow', $data);
|
||||||
|
self::assertNotNull($data['workflow'], 'Un workflow par défaut doit avoir été assigné.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateProjectWithExplicitWorkflow(): void
|
||||||
|
{
|
||||||
|
$client = self::createClient();
|
||||||
|
$em = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
$workflow = self::getContainer()->get(WorkflowRepositoryInterface::class)->findDefault()
|
||||||
|
?? $em->getRepository(Workflow::class)->findOneBy([]);
|
||||||
|
self::assertInstanceOf(Workflow::class, $workflow, 'Les fixtures doivent fournir au moins un workflow.');
|
||||||
|
|
||||||
|
$client->loginUser($this->createManager($em));
|
||||||
|
|
||||||
|
$client->request('POST', '/api/projects', server: [
|
||||||
|
'CONTENT_TYPE' => 'application/ld+json',
|
||||||
|
], content: json_encode([
|
||||||
|
'code' => $this->randomCode(),
|
||||||
|
'name' => 'Projet avec workflow',
|
||||||
|
'workflow' => '/api/workflows/'.$workflow->getId(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
self::assertResponseStatusCodeSame(201);
|
||||||
|
$data = json_decode($client->getResponse()->getContent(), true);
|
||||||
|
self::assertSame($workflow->getId(), $data['workflow']['id'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createManager(EntityManagerInterface $em): User
|
||||||
|
{
|
||||||
|
$permission = $em->getRepository(Permission::class)->findOneBy(['code' => 'project-management.projects.manage']);
|
||||||
|
self::assertInstanceOf(Permission::class, $permission, 'Lancer app:sync-permissions pour project-management.projects.manage.');
|
||||||
|
|
||||||
|
$user = new User();
|
||||||
|
$user->setUsername('proj-create-'.uniqid());
|
||||||
|
$user->setPassword('x');
|
||||||
|
$user->setRoles(['ROLE_USER']);
|
||||||
|
$user->addDirectPermission($permission);
|
||||||
|
$em->persist($user);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function randomCode(): string
|
||||||
|
{
|
||||||
|
$letters = '';
|
||||||
|
for ($i = 0; $i < 6; ++$i) {
|
||||||
|
$letters .= chr(random_int(65, 90));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $letters;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Unit\Module\Core\Infrastructure\Security;
|
||||||
|
|
||||||
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use App\Module\Core\Infrastructure\Security\ArchivedUserChecker;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
|
||||||
|
use Symfony\Component\Security\Core\User\InMemoryUser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class ArchivedUserCheckerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testArchivedUserIsRejectedPreAuth(): void
|
||||||
|
{
|
||||||
|
$user = new User()->setArchived(true);
|
||||||
|
|
||||||
|
$this->expectException(CustomUserMessageAccountStatusException::class);
|
||||||
|
|
||||||
|
new ArchivedUserChecker()->checkPreAuth($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testActiveUserPassesPreAuth(): void
|
||||||
|
{
|
||||||
|
$user = new User()->setArchived(false);
|
||||||
|
|
||||||
|
new ArchivedUserChecker()->checkPreAuth($user);
|
||||||
|
|
||||||
|
$this->addToAssertionCount(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNonAppUserIsIgnored(): void
|
||||||
|
{
|
||||||
|
// A user that is not our entity must not be rejected by this checker.
|
||||||
|
new ArchivedUserChecker()->checkPreAuth(new InMemoryUser('someone', null));
|
||||||
|
|
||||||
|
$this->addToAssertionCount(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user