Compare commits

..

6 Commits

Author SHA1 Message Date
tristan ba462a091b feat(directory) : refonte UI des fiches + onglet rapport (LST-72)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m17s
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m25s
Fiches Client / Prospect / Prestataire (onglet Rapport mis à part) :
- Champs email/téléphone : composants MalioInputEmail / MalioInputPhone
- Grilles en 4 colonnes (Info + blocs Contact/Adresse)
- Boutons « Nouveau contact/adresse » en secondary ; « Enregistrer » en
  taille Malio standard ; marge form↔bouton homogène entre onglets
- Bouton retour ghost (mdi:arrow-left-bold) comme Starseed
- Adresse : flux CP → ville → rue (rue conditionnée au CP+ville, cascade
  de reset), titre du bloc = libellé saisi
- Suppression d'un bloc Contact/Adresse : modal de confirmation (logique
  centralisée dans useDirectoryDetail)

Onglet Rapport :
- Bouton d'ajout en taille Malio standard, label « Ajouter »
- Suppression compte-rendu : passe à la ConfirmModal partagée (remplace
  l'ancienne ConfirmDeleteReportModal, supprimée)
- Suppression d'un document joint : ajout d'une modal de confirmation
- Upload via MalioInputUpload ; bouton supprimer document aligné
  (mdi:delete-outline ghost)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:09:23 +02:00
tristan 81069915c1 fix(directory) : rend var/uploads writable en local (LST-72)
Le volume Docker nommé `uploads_data` est créé root:root, or PHP-FPM tourne
en www-data (= uid host) : sans chown, l'upload de documents échoue en local
avec « mkdir(): Permission denied ». Ajoute un target `fix-uploads-perm`
idempotent, branché sur `install` (donc relancé par `reset`).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:09:07 +02:00
tristan 71c6ba1ce5 fix(auth) : renomme le cookie JWT en BEARER_LESSTIME (LST-72)
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m22s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m37s
Évite la collision de cookie sur le domaine localhost en dev : plusieurs
apps Symfony (ex: Starseed) posaient toutes un cookie `BEARER` partagé,
se faisant écraser l'une l'autre → déconnexions croisées. Le cookie est
désormais nommé par app.

- lexik : token_extractors.cookie.name + clé set_cookies
- security : logout.delete_cookies
- docs (CLAUDE.md, README) mises à jour

Note : vider une fois les cookies de localhost pour purger l'ancien BEARER.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 16:02:51 +02:00
tristan 5d1cc09a49 style(directory) : style plat sans box-shadow sur les fiches (LST-72)
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 40s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m39s
Aligne les fiches Client / Prospect / Prestataire sur le design Starseed :
- Onglet Information : grille plate (suppression box blanche + box-shadow)
- Blocs Contact & Adresse : à plat, séparés par un filet noir 1px
  (header titre + bouton supprimer, prop `last` sans bordure en bas)
Onglet Rapport inchangé.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 15:57:55 +02:00
tristan 6b65839061 feat(directory) : entête Action, hover, modal de conversion et ajustements UI (LST-72)
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 40s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m9s
- Colonne « Action » avec entête (alignée à droite) sur les 3 tableaux
- Feedback hover sur les boutons d'action (poubelle / convertir)
- Conversion prospect → client passe par une modal de confirmation
- ConfirmModal basé sur MalioModal (design Starseed), remplace ConfirmDeleteModal
- Nom (client/prospect/prestataire) en gras dans les modals via <i18n-t>
- Boutons « Ajouter » : label raccourci + taille standard Malio (180px)
- Barres d'outils à hauteur homogène (48px) : le bouton ne saute plus entre onglets

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 15:43:49 +02:00
tristan be079cdbe2 feat(directory) : conserve l'onglet actif au retour liste ↔ fiche (LST-72)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m23s
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m31s
Au retour depuis une fiche (flèche de l'app ou du navigateur), la liste
revenait toujours sur l'onglet Clients. L'onglet actif est désormais
transmis via history.state (hors URL), comme sur Starseed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:43:38 +02:00
54 changed files with 740 additions and 2810 deletions
-14
View File
@@ -91,20 +91,6 @@ ENCRYPTION_KEY=change_me_in_env_local
# POSTGRES_PORT=5435
# 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)
# ===========================================================================
-5
View File
@@ -20,11 +20,6 @@ jobs:
run: |
docker build \
-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:latest \
.
+7
View File
@@ -1,5 +1,12 @@
{
"mcpServers": {
"lesstime": {
"type": "http",
"url": "http://project.malio-dev.fr/_mcp",
"headers": {
"Authorization": "Bearer 7e8b410a5b79b5c0432951dcee3a3a81e0731e86d9f70d8784ec079a2b759c64"
}
},
"lesstime-local": {
"command": "docker",
"args": [
+1 -1
View File
@@ -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
- **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)
## Structure
+7 -166
View File
@@ -23,7 +23,6 @@ Application de gestion de projet avec suivi du temps et portail client.
- Intégration Gitea (issues, repos)
- Intégration Mail IMAP (boîte partagée OVH, voir `docs/mail-integration.md`)
- Serveur MCP pour assistants IA
- Error tracking centralisé back + front (GlitchTip / SDK Sentry, prod uniquement — voir « Error tracking »)
- Multi-langue (i18n)
## Prérequis
@@ -75,7 +74,6 @@ peuvent être surchargées dans `.env.local` (jamais committé). En prod, elles
| `CORS_ALLOW_ORIGIN` | Origines CORS autorisées | localhost | ✅ (domaine prod) |
| **`ENCRYPTION_KEY`** | **Clé hex 32 bytes chiffrant les credentials IMAP/SMTP (feature mail)** | placeholder | ✅ — doit rester **stable**, sinon les credentials mail stockés deviennent illisibles |
| **`LOCK_DSN`** | **Store de verrous Symfony pour la sync mail (anti-chevauchement)** | `flock` | `flock` suffit |
| `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.
> Détails de config et cron de synchronisation : `docs/mail-integration.md` et `docs/mail-cron-setup.md`.
@@ -190,7 +188,7 @@ Configuration : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker
Toutes les routes API sont préfixées `/api` (API Platform).
- 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
@@ -219,60 +217,28 @@ Lesstime expose un serveur MCP (Model Context Protocol) permettant aux assistant
}
```
### 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` |
### Configuration réseau (HTTP)
```json
{
"mcpServers": {
"lesstime": {
"type": "http",
"url": "http://project.malio-dev.fr/_mcp",
"headers": { "Authorization": "Bearer <api-token>" }
"type": "url",
"url": "http://<ip-serveur>:8082/_mcp",
"headers": {
"Authorization": "Bearer <api-token>"
}
}
}
}
```
Après modification, relancer la connexion avec `/mcp` dans Claude Code.
### Gestion des tokens API
Générer / régénérer un token pour un utilisateur :
```bash
# En dev (container local)
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
La prod tourne en **Docker** : l'image est buildée par la CI Gitea sur push de tag `v*`
@@ -289,131 +255,6 @@ 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) :
**`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
Propriétaire — Tous droits réservés.
-1
View File
@@ -20,7 +20,6 @@
"phpoffice/phpspreadsheet": "^5.5",
"phpstan/phpdoc-parser": "^2.3",
"sabre/vobject": "^4.5",
"sentry/sentry-symfony": "^5.10",
"symfony/asset": "8.0.*",
"symfony/console": "8.0.*",
"symfony/doctrine-messenger": "^8.0",
Generated
+1 -419
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "106755bef51fd069316cd7f3a7e1a0b6",
"content-hash": "eee87b9c0011fb88523cb5aea0de29ba",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -2508,125 +2508,6 @@
},
"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",
"version": "3.8.1",
@@ -3079,66 +2960,6 @@
},
"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",
"version": "5.6.0",
@@ -5118,50 +4939,6 @@
},
"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",
"version": "3.0.2",
@@ -5395,201 +5172,6 @@
},
"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",
"version": "v8.0.6",
-2
View File
@@ -8,7 +8,6 @@ use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle;
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
use Nelmio\CorsBundle\NelmioCorsBundle;
use Sentry\SentryBundle\SentryBundle;
use Symfony\AI\McpBundle\McpBundle;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\MonologBundle\MonologBundle;
@@ -25,5 +24,4 @@ return [
LexikJWTAuthenticationBundle::class => ['all' => true],
McpBundle::class => ['all' => true],
MonologBundle::class => ['all' => true],
SentryBundle::class => ['prod' => true],
];
@@ -9,11 +9,15 @@ lexik_jwt_authentication:
enabled: false
cookie:
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:
enabled: false
set_cookies:
BEARER:
BEARER_LESSTIME:
lifetime: '%env(int:JWT_COOKIE_TTL)%'
samesite: lax
path: /
+1 -3
View File
@@ -22,7 +22,6 @@ security:
pattern: ^/login_check
stateless: true
provider: app_user_provider
user_checker: App\Module\Core\Infrastructure\Security\ArchivedUserChecker
login_throttling:
max_attempts: 5
interval: '1 minute'
@@ -42,14 +41,13 @@ security:
pattern: ^/api
stateless: true
provider: app_user_provider
user_checker: App\Module\Core\Infrastructure\Security\ArchivedUserChecker
jwt: ~
logout:
path: /api/logout
target: /login
enable_csrf: false
delete_cookies:
BEARER:
BEARER_LESSTIME:
path: /
# Activate different ways to authenticate:
-23
View File
@@ -1,23 +0,0 @@
# 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
-85
View File
@@ -1752,90 +1752,6 @@ 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{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
@@ -1876,7 +1792,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
* mcp?: McpConfig,
* monolog?: MonologConfig,
* sentry?: SentryConfig,
* },
* "when@test"?: array{
* imports?: ImportsConfig,
-4
View File
@@ -125,10 +125,6 @@ services:
tags:
- { 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:
arguments:
$uploadDir: '%task_document_upload_dir%'
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.4.45'
app.version: '0.4.40'
@@ -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>
+13 -2
View File
@@ -424,6 +424,7 @@
"edit": "Modifier",
"delete": "Supprimer",
"add": "Ajouter",
"actions": "Action",
"loading": "Chargement...",
"archived": "Archivé",
"noClient": "Aucun client",
@@ -918,6 +919,9 @@
"editProspect": "Modifier un prospect",
"convert": "Convertir 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",
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer le prospect « {name} » ? Cette action est irréversible.",
"fields": {
@@ -1000,10 +1004,12 @@
"empty": "Aucun prestataire trouvé."
},
"contacts": {
"add": "Ajouter un contact",
"add": "Nouveau contact",
"item": "Contact {n}",
"saved": "Contact enregistré.",
"deleted": "Contact supprimé.",
"deleteConfirmTitle": "Supprimer le contact",
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer ce contact ? Cette action est irréversible.",
"fields": {
"lastName": "Nom",
"firstName": "Prénom",
@@ -1014,11 +1020,14 @@
}
},
"addresses": {
"add": "Ajouter une adresse",
"add": "Nouvelle adresse",
"item": "Adresse {n}",
"saved": "Adresse enregistré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.",
"streetHint": "Renseignez d'abord le code postal et la ville.",
"autocompleteUnavailable": "Recherche d'adresse indisponible : saisissez l'adresse manuellement.",
"fields": {
"label": "Libellé",
@@ -1040,6 +1049,8 @@
"deleted": "Compte-rendu supprimé.",
"confirmDeleteTitle": "Supprimer ce compte-rendu ?",
"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": {
"subject": "Objet",
"type": "Type d'échange",
@@ -9,8 +9,7 @@
v-if="canManage"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.reports.add')"
:label="$t('common.add')"
@click="openCreate"
/>
</div>
@@ -108,7 +107,7 @@
v-if="report.documents?.length"
:documents="report.documents"
:can-manage="canManage"
@delete="(docId) => removeDocument(docId)"
@delete="(docId) => askDeleteDocument(docId)"
/>
<ReportDocumentUpload
v-if="canManage"
@@ -127,11 +126,18 @@
:owner="owner"
@saved="reload"
/>
<ConfirmDeleteReportModal
<ConfirmModal
v-model="confirmOpen"
:busy="deleting"
:title="$t('directory.reports.confirmDeleteTitle')"
:message="$t('directory.reports.confirmDeleteMessage')"
@confirm="confirmDelete"
/>
<ConfirmModal
v-model="docConfirmOpen"
:title="$t('directory.reports.documentDeleteTitle')"
:message="$t('directory.reports.documentDeleteMessage')"
@confirm="confirmDeleteDocument"
/>
</div>
</template>
@@ -158,6 +164,11 @@ const confirmOpen = ref(false)
const pendingDelete = ref<CommercialReport | null>(null)
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).
const sortedReports = computed(() =>
[...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> {
await documentService.remove(id)
await reload()
function askDeleteDocument(id: number): void {
pendingDocId.value = id
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> {
@@ -1,58 +0,0 @@
<template>
<Teleport v-if="modelValue" to="body">
<Transition name="modal" appear>
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click="cancel" />
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-bold text-neutral-900">{{ title }}</h3>
<p class="mt-3 text-sm text-neutral-600">
{{ message }}
</p>
<div class="mt-6 flex justify-end gap-3">
<MalioButton
variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
@click="cancel"
/>
<MalioButton
variant="danger"
:label="$t('common.delete')"
button-class="w-auto px-4"
@click="$emit('confirm')"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
defineProps<{
modelValue: boolean
title: string
message: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'confirm'): void
}>()
function cancel() {
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>
@@ -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>
<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)]">
<h3 class="col-span-2 text-sm font-semibold text-neutral-700">
{{ title }}
</h3>
<MalioButtonIcon
v-if="removable && !readonly"
icon="mdi:delete-outline"
variant="ghost"
class="absolute right-3 top-3"
: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)"
<!-- Bloc à plat (sans box-shadow) : un filet noir 1px le sépare du suivant
(pas de bordure sous le dernier bloc), comme sur Starseed. -->
<div class="pb-5" :class="{ 'border-b border-black': !last }">
<div class="flex items-center justify-between">
<!-- Titre = libellé saisi ; repli sur « Adresse N » tant qu'il est vide. -->
<h3 class="text-[20px] font-semibold text-black">{{ blockTitle }}</h3>
<MalioButtonIcon
v-if="removable && !readonly"
icon="mdi:delete-outline"
variant="ghost"
button-class="p-0"
:aria-label="$t('common.delete')"
@click="$emit('remove')"
/>
</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 class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
class="col-span-2"
:label="$t('directory.addresses.fields.label')"
:model-value="modelValue.label ?? ''"
:readonly="readonly"
@update:model-value="update('label', $event)"
/>
<MalioInputText
:label="$t('directory.addresses.fields.postalCode')"
:model-value="modelValue.postalCode ?? ''"
:readonly="readonly"
@update:model-value="onPostalCodeInput"
/>
<!-- On commence par le code postal : il alimente la liste des villes (BAN)
et réinitialise ville/rue devenues incohérentes en cas de changement. -->
<MalioInputText
:label="$t('directory.addresses.fields.postalCode')"
:model-value="modelValue.postalCode ?? ''"
:readonly="readonly"
@update:model-value="onPostalCodeInput"
/>
<!-- Ville : select alimenté par le code postal (BAN). En mode dégradé
(BAN indispo) ou lecture seule, on bascule en saisie libre. -->
<MalioSelect
v-if="!readonly && !degraded"
:model-value="modelValue.city ?? ''"
:options="cityOptions"
:label="$t('directory.addresses.fields.city')"
empty-option-label=""
group-class="w-full"
@update:model-value="(v) => update('city', v === null ? '' : String(v))"
/>
<MalioInputText
v-else
:label="$t('directory.addresses.fields.city')"
:model-value="modelValue.city ?? ''"
:readonly="readonly"
@update:model-value="update('city', $event)"
/>
<!-- Ville : select alimenté par le code postal (BAN). En mode dégradé
(BAN indispo) ou lecture seule, on bascule en saisie libre. -->
<MalioSelect
v-if="!readonly && !degraded"
:model-value="modelValue.city ?? ''"
:options="cityOptions"
:label="$t('directory.addresses.fields.city')"
empty-option-label=""
group-class="w-full"
@update:model-value="onCityChange"
/>
<MalioInputText
v-else
:label="$t('directory.addresses.fields.city')"
:model-value="modelValue.city ?? ''"
:readonly="readonly"
@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>
</template>
@@ -94,6 +103,8 @@ const props = defineProps<{
title: string
removable?: boolean
readonly?: boolean
/** Dernier bloc de la liste : supprime le filet de séparation bas. */
last?: boolean
}>()
const emit = defineEmits<{
@@ -112,6 +123,16 @@ const addressOptions = ref<Option[]>([])
const fetchedCityOptions = ref<Option[]>([])
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
// 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,
@@ -140,6 +161,23 @@ function notifyUnavailable(): void {
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). */
async function onAddressSearch(query: string): Promise<void> {
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> {
update('postalCode', value)
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
try {
const suggestions = await autocomplete.searchCity(digits)
@@ -1,57 +1,61 @@
<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)]">
<h3 class="col-span-2 text-sm font-semibold text-neutral-700">
{{ title }}
</h3>
<MalioButtonIcon
v-if="removable && !readonly"
icon="mdi:delete-outline"
variant="ghost"
class="absolute right-3 top-3"
:aria-label="$t('common.delete')"
@click="$emit('remove')"
/>
<!-- Bloc à plat (sans box-shadow) : un filet noir 1px le sépare du suivant
(pas de bordure sous le dernier bloc), comme sur Starseed. -->
<div class="pb-5" :class="{ 'border-b border-black': !last }">
<div class="flex items-center justify-between">
<h3 class="text-[20px] font-semibold text-black">{{ title }}</h3>
<MalioButtonIcon
v-if="removable && !readonly"
icon="mdi:delete-outline"
variant="ghost"
button-class="p-0"
:aria-label="$t('common.delete')"
@click="$emit('remove')"
/>
</div>
<MalioInputText
:label="$t('directory.contacts.fields.lastName')"
:model-value="modelValue.lastName ?? ''"
:readonly="readonly"
@update:model-value="update('lastName', $event)"
/>
<MalioInputText
:label="$t('directory.contacts.fields.firstName')"
:model-value="modelValue.firstName ?? ''"
:readonly="readonly"
@update:model-value="update('firstName', $event)"
/>
<MalioInputText
class="col-span-2"
:label="$t('directory.contacts.fields.jobTitle')"
:model-value="modelValue.jobTitle ?? ''"
:readonly="readonly"
@update:model-value="update('jobTitle', $event)"
/>
<MalioInputText
:label="$t('directory.contacts.fields.email')"
:model-value="modelValue.email ?? ''"
:readonly="readonly"
:error="emailError"
@update:model-value="update('email', $event)"
/>
<MalioInputText
:label="$t('directory.contacts.fields.phonePrimary')"
:model-value="modelValue.phonePrimary ?? ''"
:readonly="readonly"
:error="phonePrimaryError"
@update:model-value="update('phonePrimary', $event)"
/>
<MalioInputText
:label="$t('directory.contacts.fields.phoneSecondary')"
:model-value="modelValue.phoneSecondary ?? ''"
:readonly="readonly"
:error="phoneSecondaryError"
@update:model-value="update('phoneSecondary', $event)"
/>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
:label="$t('directory.contacts.fields.lastName')"
:model-value="modelValue.lastName ?? ''"
:readonly="readonly"
@update:model-value="update('lastName', $event)"
/>
<MalioInputText
:label="$t('directory.contacts.fields.firstName')"
:model-value="modelValue.firstName ?? ''"
:readonly="readonly"
@update:model-value="update('firstName', $event)"
/>
<MalioInputText
class="col-span-2"
:label="$t('directory.contacts.fields.jobTitle')"
:model-value="modelValue.jobTitle ?? ''"
:readonly="readonly"
@update:model-value="update('jobTitle', $event)"
/>
<MalioInputEmail
:label="$t('directory.contacts.fields.email')"
:model-value="modelValue.email ?? ''"
:readonly="readonly"
:error="emailError"
@update:model-value="update('email', $event)"
/>
<MalioInputPhone
:label="$t('directory.contacts.fields.phonePrimary')"
:model-value="modelValue.phonePrimary ?? ''"
:readonly="readonly"
:error="phonePrimaryError"
@update:model-value="update('phonePrimary', $event)"
/>
<MalioInputPhone
:label="$t('directory.contacts.fields.phoneSecondary')"
:model-value="modelValue.phoneSecondary ?? ''"
:readonly="readonly"
:error="phoneSecondaryError"
@update:model-value="update('phoneSecondary', $event)"
/>
</div>
</div>
</template>
@@ -64,6 +68,8 @@ const props = defineProps<{
title: string
removable?: boolean
readonly?: boolean
/** Dernier bloc de la liste : supprime le filet de séparation bas. */
last?: boolean
}>()
const emit = defineEmits<{
@@ -16,8 +16,8 @@
</a>
<MalioButtonIcon
v-if="canManage"
icon="mdi:trash-can-outline"
button-class="!text-red-600"
icon="mdi:delete-outline"
variant="ghost"
:aria-label="$t('common.delete')"
@click="$emit('delete', doc.id)"
/>
@@ -1,20 +1,14 @@
<template>
<div class="flex items-center gap-3">
<input
ref="fileInput"
type="file"
class="hidden"
@change="onFileSelected"
>
<MalioButton
icon-name="mdi:paperclip"
icon-position="left"
button-class="w-auto px-4"
<MalioInputUpload
v-model="fileName"
class="flex-1"
:label="$t('directory.documents.add')"
: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>
</template>
@@ -25,21 +19,19 @@ const props = defineProps<{ reportId: number }>()
const emit = defineEmits<{ uploaded: [] }>()
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)
async function onFileSelected(event: Event): Promise<void> {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
// L'upload se déclenche dès la sélection (event natif du composant Malio).
async function onFile(file: File): Promise<void> {
uploading.value = true
try {
await service.upload(props.reportId, file)
emit('uploaded')
} finally {
uploading.value = false
input.value = ''
fileName.value = ''
}
}
</script>
@@ -14,6 +14,7 @@ type Owner = { client?: string, prospect?: string, prestataire?: string }
* tel quel par les deux pages.
*/
export function useDirectoryDetail(owner: Owner) {
const { t } = useI18n()
const contactService = useContactService()
const addressService = useAddressService()
@@ -59,6 +60,39 @@ export function useDirectoryDetail(owner: Owner) {
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
// blocs renseignés. Les amorces vides (sans contenu) sont ignorées.
async function saveContacts(): Promise<void> {
@@ -117,5 +151,12 @@ export function useDirectoryDetail(owner: Owner) {
removeAddress,
saveAddresses,
load,
// Suppression de bloc avec confirmation (modal partagée contact/adresse).
removeModalOpen,
removeModalTitle,
removeModalMessage,
askRemoveContact,
askRemoveAddress,
confirmRemove,
}
}
@@ -2,7 +2,14 @@
<div>
<PageHeader>
<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 ?? '…' }}
</span>
</PageHeader>
@@ -13,7 +20,7 @@
<MalioTabList v-model="activeTab" :tabs="tabs">
<template #info>
<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
v-model="info.name"
class="col-span-2"
@@ -21,12 +28,12 @@
:error="infoTouched.name && !info.name.trim() ? $t('directory.validation.nameRequired') : ''"
@blur="infoTouched.name = true"
/>
<MalioInputText
<MalioInputEmail
v-model="info.email"
:label="$t('directory.info.fields.email')"
:error="emailError"
/>
<MalioInputText
<MalioInputPhone
v-model="info.phone"
:label="$t('directory.info.fields.phone')"
:error="phoneError"
@@ -40,7 +47,6 @@
</div>
<div class="flex justify-center pt-2">
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingInfo || !infoValid"
@click="saveInfo"
@@ -57,12 +63,13 @@
:model-value="contact"
:title="$t('directory.contacts.item', { n: i + 1 })"
:removable="contacts.length > 0"
:last="i === contacts.length - 1"
@update:model-value="(v) => onContactInput(i, v)"
@remove="removeContact(i)"
@remove="askRemoveContact(i)"
/>
<div class="flex justify-center gap-3 pt-2">
<MalioButton
variant="tertiary"
variant="secondary"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
@@ -70,7 +77,6 @@
@click="addContact"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingContacts"
@click="saveContacts"
@@ -87,12 +93,13 @@
:model-value="address"
:title="$t('directory.addresses.item', { n: i + 1 })"
:removable="addresses.length > 0"
:last="i === addresses.length - 1"
@update:model-value="(v) => onAddressInput(i, v)"
@remove="removeAddress(i)"
@remove="askRemoveAddress(i)"
/>
<div class="flex justify-center gap-3 pt-2">
<MalioButton
variant="tertiary"
variant="secondary"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
@@ -100,7 +107,6 @@
@click="addAddress"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingAddresses"
@click="saveAddresses"
@@ -115,6 +121,13 @@
</MalioTabList>
</template>
</div>
<ConfirmModal
v-model="removeModalOpen"
:title="removeModalTitle"
:message="removeModalMessage"
@confirm="confirmRemove"
/>
</div>
</template>
@@ -141,13 +154,17 @@ const {
savingAddresses,
onContactInput,
addContact,
removeContact,
askRemoveContact,
saveContacts,
onAddressInput,
addAddress,
removeAddress,
askRemoveAddress,
saveAddresses,
load,
removeModalOpen,
removeModalTitle,
removeModalMessage,
confirmRemove,
} = useDirectoryDetail(owner)
const { can } = usePermissions()
@@ -192,7 +209,8 @@ async function saveInfo(): Promise<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 () => {
@@ -9,12 +9,11 @@
<!-- Clients -->
<template #clients>
<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
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.clients.add')"
:label="$t('common.add')"
@click="openCreateClient"
/>
</div>
@@ -26,6 +25,9 @@
:empty-message="$t('directory.clients.empty')"
@row-click="openEditClient"
>
<template #header-actions>
<span class="block text-right font-semibold text-black">{{ $t('common.actions') }}</span>
</template>
<template #cell-email="{ item }">
{{ (item as Client).email ?? '—' }}
</template>
@@ -37,7 +39,7 @@
<MalioButtonIcon
icon="mdi:trash-can-outline"
: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"
@click="askDeleteClient(item as Client)"
/>
@@ -50,7 +52,7 @@
<!-- Prospects -->
<template #prospects>
<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
v-model="statusFilter"
:label="$t('prospects.fields.status')"
@@ -61,8 +63,7 @@
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.prospects.add')"
:label="$t('common.add')"
@click="openCreateProspect"
/>
</div>
@@ -74,6 +75,9 @@
:empty-message="$t('directory.prospects.empty')"
@row-click="openEditProspect"
>
<template #header-actions>
<span class="block text-right font-semibold text-black">{{ $t('common.actions') }}</span>
</template>
<template #cell-status="{ item }">
<StatusBadge
:label="statusLabel((item as ProspectRow).status)"
@@ -92,14 +96,14 @@
v-if="!(item as ProspectRow).convertedClient"
icon="mdi:account-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"
@click="convertProspect(item as ProspectRow)"
@click="askConvertProspect(item as ProspectRow)"
/>
<MalioButtonIcon
icon="mdi:trash-can-outline"
: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"
@click="askDeleteProspect(item as ProspectRow)"
/>
@@ -111,12 +115,11 @@
<!-- Prestataires -->
<template #prestataires>
<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
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.prestataires.add')"
:label="$t('common.add')"
@click="openCreatePrestataire"
/>
</div>
@@ -128,6 +131,9 @@
:empty-message="$t('directory.prestataires.empty')"
@row-click="openEditPrestataire"
>
<template #header-actions>
<span class="block text-right font-semibold text-black">{{ $t('common.actions') }}</span>
</template>
<template #cell-email="{ item }">
{{ (item as Prestataire).email ?? '—' }}
</template>
@@ -139,7 +145,7 @@
<MalioButtonIcon
icon="mdi:trash-can-outline"
: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"
@click="askDeletePrestataire(item as Prestataire)"
/>
@@ -166,12 +172,31 @@
@saved="loadPrestataires"
/>
<ConfirmDeleteModal
<ConfirmModal
v-model="deleteModalOpen"
:title="deleteModalTitle"
:message="deleteModalMessage"
@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>
</template>
@@ -183,6 +208,7 @@ import type { Prospect, ProspectStatus } from '~/modules/directory/services/dto/
import { useProspectService } from '~/modules/directory/services/prospects'
import type { Prestataire } from '~/modules/directory/services/dto/prestataire'
import { usePrestataireService } from '~/modules/directory/services/prestataires'
import { readHistoryTab, stampHistoryTab } from '~/utils/historyTab'
definePageMeta({ middleware: ['admin'] })
@@ -201,6 +227,14 @@ const tabs = [
{ 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' },
]
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 ---
const clients = ref<Client[]>([])
@@ -211,7 +245,7 @@ const clientColumns = [
{ key: 'name', label: t('prospects.fields.company') },
{ key: 'email', label: t('prospects.fields.email') },
{ key: 'phone', label: t('prospects.fields.phone') },
{ key: 'actions', label: '' },
{ key: 'actions', label: t('common.actions') },
]
async function loadClients() {
@@ -224,7 +258,7 @@ function openCreateClient() {
}
function openEditClient(item: Record<string, unknown>) {
navigateTo(`/directory/clients/${(item as Client).id}`)
navigateToDetail(`/directory/clients/${(item as Client).id}`)
}
// --- Prospects ---
@@ -246,7 +280,7 @@ const prospectColumns = [
{ key: 'status', label: t('prospects.fields.status') },
{ key: 'email', label: t('prospects.fields.email') },
{ key: 'phone', label: t('prospects.fields.phone') },
{ key: 'actions', label: '' },
{ key: 'actions', label: t('common.actions') },
]
const prospectRows = computed<ProspectRow[]>(() => prospects.value)
@@ -282,13 +316,26 @@ function openCreateProspect() {
}
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)
// La conversion crée un client et retire le prospect : rafraîchir les deux listes.
await Promise.all([loadProspects(), loadClients()])
convertModalOpen.value = false
convertTarget.value = null
}
// 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: 'email', label: t('prospects.fields.email') },
{ key: 'phone', label: t('prospects.fields.phone') },
{ key: 'actions', label: '' },
{ key: 'actions', label: t('common.actions') },
]
async function loadPrestataires() {
@@ -319,7 +366,7 @@ function openCreatePrestataire() {
}
function openEditPrestataire(item: Record<string, unknown>) {
navigateTo(`/directory/prestataires/${(item as Prestataire).id}`)
navigateToDetail(`/directory/prestataires/${(item as Prestataire).id}`)
}
// --- 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
if (!target) return ''
switch (target.type) {
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 })
}
return target.type === 'prospect' ? target.item.company : target.item.name
})
function askDeleteClient(item: Client) {
@@ -392,6 +444,9 @@ async function confirmDelete() {
watch(statusFilter, loadProspects)
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()])
})
</script>
@@ -2,7 +2,14 @@
<div>
<PageHeader>
<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 ?? '…' }}
</span>
</PageHeader>
@@ -13,7 +20,7 @@
<MalioTabList v-model="activeTab" :tabs="tabs">
<template #info>
<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
v-model="info.name"
class="col-span-2"
@@ -21,12 +28,12 @@
:error="infoTouched.name && !info.name.trim() ? $t('directory.validation.nameRequired') : ''"
@blur="infoTouched.name = true"
/>
<MalioInputText
<MalioInputEmail
v-model="info.email"
:label="$t('directory.info.fields.email')"
:error="emailError"
/>
<MalioInputText
<MalioInputPhone
v-model="info.phone"
:label="$t('directory.info.fields.phone')"
:error="phoneError"
@@ -40,7 +47,6 @@
</div>
<div class="flex justify-center pt-2">
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingInfo || !infoValid"
@click="saveInfo"
@@ -57,12 +63,13 @@
:model-value="contact"
:title="$t('directory.contacts.item', { n: i + 1 })"
:removable="contacts.length > 0"
:last="i === contacts.length - 1"
@update:model-value="(v) => onContactInput(i, v)"
@remove="removeContact(i)"
@remove="askRemoveContact(i)"
/>
<div class="flex justify-center gap-3 pt-2">
<MalioButton
variant="tertiary"
variant="secondary"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
@@ -70,7 +77,6 @@
@click="addContact"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingContacts"
@click="saveContacts"
@@ -87,12 +93,13 @@
:model-value="address"
:title="$t('directory.addresses.item', { n: i + 1 })"
:removable="addresses.length > 0"
:last="i === addresses.length - 1"
@update:model-value="(v) => onAddressInput(i, v)"
@remove="removeAddress(i)"
@remove="askRemoveAddress(i)"
/>
<div class="flex justify-center gap-3 pt-2">
<MalioButton
variant="tertiary"
variant="secondary"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
@@ -100,7 +107,6 @@
@click="addAddress"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingAddresses"
@click="saveAddresses"
@@ -115,6 +121,13 @@
</MalioTabList>
</template>
</div>
<ConfirmModal
v-model="removeModalOpen"
:title="removeModalTitle"
:message="removeModalMessage"
@confirm="confirmRemove"
/>
</div>
</template>
@@ -141,13 +154,17 @@ const {
savingAddresses,
onContactInput,
addContact,
removeContact,
askRemoveContact,
saveContacts,
onAddressInput,
addAddress,
removeAddress,
askRemoveAddress,
saveAddresses,
load,
removeModalOpen,
removeModalTitle,
removeModalMessage,
confirmRemove,
} = useDirectoryDetail(owner)
const { can } = usePermissions()
@@ -190,7 +207,8 @@ async function saveInfo(): Promise<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 () => {
@@ -2,7 +2,14 @@
<div>
<PageHeader>
<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 ?? '…' }}
</span>
</PageHeader>
@@ -13,7 +20,7 @@
<MalioTabList v-model="activeTab" :tabs="tabs">
<template #info>
<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
v-model="info.company"
class="col-span-2"
@@ -32,12 +39,12 @@
:label="$t('prospects.fields.website')"
:error="websiteError"
/>
<MalioInputText
<MalioInputEmail
v-model="info.email"
:label="$t('prospects.fields.email')"
:error="emailError"
/>
<MalioInputText
<MalioInputPhone
v-model="info.phone"
:label="$t('prospects.fields.phone')"
:error="phoneError"
@@ -49,13 +56,12 @@
/>
<MalioInputTextArea
v-model="info.notes"
class="col-span-2"
class="col-span-4"
:label="$t('prospects.fields.notes')"
/>
</div>
<div class="flex justify-center pt-2">
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingInfo || !infoValid"
@click="saveInfo"
@@ -72,12 +78,13 @@
:model-value="contact"
:title="$t('directory.contacts.item', { n: i + 1 })"
:removable="contacts.length > 0"
:last="i === contacts.length - 1"
@update:model-value="(v) => onContactInput(i, v)"
@remove="removeContact(i)"
@remove="askRemoveContact(i)"
/>
<div class="flex justify-center gap-3 pt-2">
<MalioButton
variant="tertiary"
variant="secondary"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
@@ -85,7 +92,6 @@
@click="addContact"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingContacts"
@click="saveContacts"
@@ -102,12 +108,13 @@
:model-value="address"
:title="$t('directory.addresses.item', { n: i + 1 })"
:removable="addresses.length > 0"
:last="i === addresses.length - 1"
@update:model-value="(v) => onAddressInput(i, v)"
@remove="removeAddress(i)"
@remove="askRemoveAddress(i)"
/>
<div class="flex justify-center gap-3 pt-2">
<MalioButton
variant="tertiary"
variant="secondary"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
@@ -115,7 +122,6 @@
@click="addAddress"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingAddresses"
@click="saveAddresses"
@@ -130,6 +136,13 @@
</MalioTabList>
</template>
</div>
<ConfirmModal
v-model="removeModalOpen"
:title="removeModalTitle"
:message="removeModalMessage"
@confirm="confirmRemove"
/>
</div>
</template>
@@ -156,13 +169,17 @@ const {
savingAddresses,
onContactInput,
addContact,
removeContact,
askRemoveContact,
saveContacts,
onAddressInput,
addAddress,
removeAddress,
askRemoveAddress,
saveAddresses,
load,
removeModalOpen,
removeModalTitle,
removeModalMessage,
confirmRemove,
} = useDirectoryDetail(owner)
const { can } = usePermissions()
@@ -226,7 +243,8 @@ async function saveInfo(): Promise<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 () => {
@@ -32,13 +32,6 @@
empty-option-label="Aucun client"
group-class="w-full"
/>
<MalioSelect
v-if="!isEditing"
v-model="form.workflowId"
:options="workflowOptions"
label="Workflow"
group-class="w-full"
/>
<div class="mt-4">
<ColorPicker v-model="form.color" />
</div>
@@ -131,12 +124,10 @@
<script setup lang="ts">
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 { GiteaRepository } from '~/modules/integration/services/dto/gitea'
import type { BookStackShelf } from '~/modules/integration/services/dto/bookstack'
import { useProjectService } from '~/modules/project-management/services/projects'
import { useWorkflowService } from '~/modules/project-management/services/workflows'
import { useGiteaService } from '~/modules/integration/services/gitea'
import { useBookStackService } from '~/modules/integration/services/bookstack'
@@ -183,24 +174,12 @@ const bookstackShelfOptions = computed(() =>
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({
code: '',
name: '',
description: '',
color: '#222783',
clientId: null as number | null,
workflowId: null as number | null,
giteaRepoFullName: null as string | null,
bookstackShelfId: null as number | null,
})
@@ -243,7 +222,6 @@ watch(() => props.modelValue, (open) => {
form.description = ''
form.color = '#222783'
form.clientId = null
form.workflowId = defaultWorkflowId()
form.giteaRepoFullName = null
form.bookstackShelfId = null
}
@@ -291,9 +269,6 @@ async function handleSubmit() {
await update(props.project.id, payload)
} else {
payload.code = form.code
if (form.workflowId) {
payload.workflow = `/api/workflows/${form.workflowId}`
}
await create(payload)
}
@@ -333,15 +308,6 @@ async function handleArchiveToggle() {
}
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 {
giteaRepos.value = await listRepositories()
} catch {
-20
View File
@@ -33,9 +33,6 @@ export default defineNuxtConfig({
'nuxt-toast',
'@nuxtjs/i18n',
'@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: {
layouts: 'app/layouts',
@@ -59,23 +56,6 @@ export default defineNuxtConfig({
runtimeConfig: {
public: {
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: {
+132 -886
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -16,7 +16,6 @@
"@nuxtjs/i18n": "^10.2.3",
"@nuxtjs/tailwindcss": "^6.14.0",
"@pinia/nuxt": "^0.11.3",
"@sentry/nuxt": "^10.61.0",
"@tailwindcss/typography": "^0.5.19",
"@vuepic/vue-datepicker": "^12.1.0",
"chart.js": "^4.5.1",
-18
View File
@@ -1,18 +0,0 @@
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,
})
}
+35
View File
@@ -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 }, '')
}
+2 -23
View File
@@ -29,26 +29,10 @@ COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
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 \
NUXT_TELEMETRY_DISABLED=1 \
NUXT_PUBLIC_API_BASE=/api \
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
NUXT_PUBLIC_APP_BASE=/
RUN npm run generate
# --- Stage 3: Production image ---
@@ -56,15 +40,10 @@ FROM php:8.4-fpm AS production
RUN apt-get update && apt-get install -y \
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
nginx supervisor smbclient ca-certificates \
nginx supervisor smbclient \
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
&& 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
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
-8
View File
@@ -37,14 +37,6 @@ echo "==> Clearing cache..."
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
echo "==> 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..."
rm -f maintenance.on
-4
View File
@@ -2,10 +2,6 @@ services:
app:
image: gitea.malio.fr/malio-dev/lesstime:${LESSTIME_IMAGE_TAG:-latest}
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
ports:
- "8081:80"
-31
View File
@@ -1,31 +0,0 @@
-----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-----
+8 -1
View File
@@ -38,7 +38,7 @@ restart: env-init
$(DOCKER_COMPOSE) down
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)
reset: delete_built_dir remove_orphans build-without-cache start wait install
@@ -81,6 +81,13 @@ migration-migrate:
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:
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
-31
View File
@@ -1,31 +0,0 @@
<?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');
}
}
-4
View File
@@ -13,10 +13,6 @@
<php>
<ini name="display_errors" 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="SHELL_VERBOSITY" value="-1" />
<server name="KERNEL_CLASS" value="App\Kernel" />
-139
View File
@@ -1,139 +0,0 @@
<?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;
}
}
+1 -29
View File
@@ -4,8 +4,6 @@ declare(strict_types=1);
namespace App\Module\Core\Domain\Entity;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
@@ -15,7 +13,6 @@ use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Core\Domain\Enum\ContractType;
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\UserPasswordHasherProcessor;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
@@ -50,7 +47,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
),
new Post(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')", processor: UserArchiveProcessor::class),
new Delete(security: "is_granted('ROLE_ADMIN')"),
new Get(
uriTemplate: '/users/{id}/rbac',
security: "is_granted('core.users.manage')",
@@ -66,9 +63,6 @@ use Symfony\Component\Serializer\Attribute\Groups;
],
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]
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
#[ORM\Table(name: '`user`')]
@@ -117,16 +111,6 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
#[ORM\Column(length: 255, nullable: true)]
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) ---
/** Whether this user is an employee subject to absence management. */
@@ -244,18 +228,6 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
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> */
public function getRoles(): array
{
@@ -1,49 +0,0 @@
<?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));
}
}
@@ -1,53 +0,0 @@
<?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;
}
}
@@ -1,28 +0,0 @@
<?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,11 +92,10 @@ class Project implements ProjectInterface, TimestampableInterface, BlamableInter
#[Groups(['project:read', 'project:write'])]
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\JoinColumn(nullable: false, onDelete: 'RESTRICT')]
#[Groups(['project:read', 'project:write', 'task:read'])]
#[Assert\NotNull(message: 'Un projet doit avoir un workflow.')]
private ?Workflow $workflow = null;
#[ORM\Column(length: 255, nullable: true)]
@@ -1,36 +0,0 @@
<?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,7 +6,6 @@ namespace App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Project;
use App\Module\Directory\Domain\Repository\ClientRepositoryInterface;
use App\Module\ProjectManagement\Domain\Entity\Project;
use App\Module\ProjectManagement\Domain\Repository\WorkflowRepositoryInterface;
use App\Shared\Infrastructure\Mcp\Serializer;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
@@ -16,13 +15,12 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[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.')]
#[McpTool(name: 'create-project', description: 'Create a new project. Code must be 2-10 uppercase letters.')]
class CreateProjectTool
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ClientRepositoryInterface $clientRepository,
private readonly WorkflowRepositoryInterface $workflowRepository,
private readonly Security $security,
) {}
@@ -32,7 +30,6 @@ class CreateProjectTool
?string $description = null,
?string $color = null,
?int $clientId = null,
?int $workflowId = null,
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
@@ -55,14 +52,6 @@ class CreateProjectTool
}
$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->flush();
+5 -31
View File
@@ -23,13 +23,11 @@ use App\Module\ProjectManagement\Domain\Entity\TaskPriority;
use App\Module\ProjectManagement\Domain\Entity\TaskStatus;
use App\Module\ProjectManagement\Domain\Entity\TaskTag;
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
use App\Shared\Domain\Contract\ClientInterface;
use App\Shared\Domain\Contract\ProjectInterface;
use App\Shared\Domain\Contract\TaskInterface;
use App\Shared\Domain\Contract\TaskTagInterface;
use App\Shared\Domain\Contract\UserInterface;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\EntityNotFoundException;
/**
* Shared serialization helpers for MCP tools.
@@ -61,8 +59,11 @@ final class Serializer
'name' => $project->getName(),
'description' => $project->getDescription(),
'color' => $project->getColor(),
'client' => self::clientRef($project->getClient()),
'archived' => $project->isArchived(),
'client' => $project->getClient() ? [
'id' => $project->getClient()->getId(),
'name' => $project->getClient()->getName(),
] : null,
'archived' => $project->isArchived(),
];
}
@@ -515,31 +516,4 @@ final class Serializer
'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;
}
}
}
-9
View File
@@ -124,15 +124,6 @@
"bin/phpunit"
]
},
"sentry/sentry-symfony": {
"version": "5.10",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "5.0",
"ref": "aac2bc5220e9ab5b9e3838a7a4da90e7f74e6148"
}
},
"symfony/console": {
"version": "8.0",
"recipe": {
@@ -1,145 +0,0 @@
<?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();
}
}
@@ -1,92 +0,0 @@
<?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;
}
}
@@ -1,43 +0,0 @@
<?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);
}
}