Compare commits

..

35 Commits

Author SHA1 Message Date
Matthieu 4760c386ed docs : log LST-57 rbac fin session learnings 2026-06-19 17:38:26 +02:00
Matthieu 511353c3f5 feat(core) : add usePermissions composable and rbac roles admin front 2026-06-19 17:35:51 +02:00
Matthieu 544d4cf44f feat(core) : gate sidebar by effective permissions 2026-06-19 17:28:42 +02:00
Matthieu 1a9eba93a0 feat(core) : add rbac seeder and seed-rbac command for system roles 2026-06-19 17:22:42 +02:00
Matthieu 48c67a5fb9 feat(core) : expose role and user-rbac api endpoints with processors 2026-06-19 17:16:38 +02:00
Matthieu 5060fb689b feat(core) : add permission voter and expose effective permissions on /api/me 2026-06-19 17:03:34 +02:00
Matthieu ac662e701b feat(core) : aggregate module permissions and add sync-permissions command 2026-06-19 17:00:14 +02:00
Matthieu ffed224979 feat(core) : add rbac role and permission entities with user relations 2026-06-19 16:56:07 +02:00
Matthieu fdc72573ea docs : add implementation plan for rbac fin (LST-57 / 1.2) 2026-06-19 16:47:04 +02:00
Matthieu 52de07ce23 docs : log LST-63 module core session learnings 2026-06-19 16:34:02 +02:00
Matthieu 117c2ff2e3 feat(core) : add core front layer with login and profile pages 2026-06-19 16:31:42 +02:00
Matthieu a98ea3df37 feat(core) : activate core module in modules registry 2026-06-19 16:27:10 +02:00
Matthieu f1a9b42930 feat(core) : move notification into core and expose notifier contract 2026-06-19 16:25:03 +02:00
Matthieu 0b4874e94d refactor(core) : move user repository/providers to core and migrate all consumers off App\Entity\User 2026-06-19 16:16:44 +02:00
Matthieu d70925b812 refactor(core) : point user relations to the shared contract via resolve_target_entities 2026-06-19 16:04:14 +02:00
Matthieu f8fc4d6bd9 feat(core) : move user entity into core module and repoint security/doctrine (temp legacy alias) 2026-06-19 16:03:52 +02:00
Matthieu 6ca91cbd3b feat(core) : add CoreModule, user repository contract, notifier contract and enriched user contract 2026-06-19 15:53:38 +02:00
Matthieu 8865bf51e6 docs : add implementation plan for module core (LST-63 / 1.1) 2026-06-19 15:50:32 +02:00
Matthieu d1a980d1c2 docs : log LST-62 socle front session learnings 2026-06-19 15:37:03 +02:00
Matthieu fdcf8df518 feat(front) : add sidebar i18n labels 2026-06-19 15:33:59 +02:00
Matthieu 977e74f669 feat(front) : render dynamic sidebar from /api/sidebar in default layout 2026-06-19 15:32:23 +02:00
Matthieu a620833550 feat(front) : load sidebar/modules after login and redirect disabled routes 2026-06-19 15:28:16 +02:00
Matthieu fcfb16fc5b docs : correct LST-62 front verification gate (typecheck is not green on this stack) 2026-06-19 15:25:39 +02:00
Matthieu b00e92bdd3 feat(front) : modular nuxt config with app/ shell dirs and modules/* layer auto-detection 2026-06-19 15:24:57 +02:00
Matthieu 1aa43a5356 refactor(front) : move useApi and shared stores (auth, ui) to shared/ 2026-06-19 15:06:50 +02:00
Matthieu 51de96c797 feat(front) : add shared useModules/useSidebar composables and sidebar types 2026-06-19 15:05:35 +02:00
Matthieu 0ee82c8b62 feat(sidebar) : add role gate to sidebar provider and global nav config 2026-06-19 15:03:45 +02:00
Matthieu 111f37a0c9 docs : add implementation plan for socle front (LST-62 / 0.2) 2026-06-19 15:00:23 +02:00
Matthieu 5fbdda1983 docs : log LST-56 socle back session learnings 2026-06-19 15:00:17 +02:00
Matthieu b301c543bb feat(shared) : add column comments catalog helper for migrations 2026-06-19 14:38:40 +02:00
Matthieu 3053c09522 feat(shared) : add timestampable/blamable trait and doctrine subscriber 2026-06-19 14:37:28 +02:00
Matthieu 52399b35d9 feat(sidebar) : expose GET /api/sidebar filtered by active modules 2026-06-19 14:35:17 +02:00
Matthieu 748289b61a feat(modules) : expose GET /api/modules and module registry 2026-06-19 14:33:53 +02:00
Matthieu 2d0e9de155 docs : add implementation plan for socle back (LST-56 / 0.1)
Plan TDD en 4 tâches : endpoints /api/modules et /api/sidebar, garde-fou Timestampable/Blamable, helper ColumnCommentsCatalog.
2026-06-19 10:56:27 +02:00
Matthieu a510b2ca73 docs : add modular monolith migration roadmap and socle design
Plan de migration complet Lesstime vers modular monolith DDD (archi Starseed) : roadmap en 14 tickets ordonnés par dépendances + design technique détaillé du socle (Shared/, contrats, endpoints modules/sidebar, plan strangler).
2026-06-19 10:50:14 +02:00
623 changed files with 3383 additions and 24526 deletions
@@ -145,25 +145,3 @@
- **MCP status**: Toujours mettre "En cours" AVANT de commencer, "Terminé" APRÈS validation
- **PostgreSQL gotchas**: Tester les queries SQL avec agrégation + locking sur PostgreSQL, pas MySQL
- **Agents**: Les agents simples (1-3 fichiers) terminent en ~30s, les complexes (22 fichiers) en ~8min
## Session 2026-06-19 (LST-61 / 1.3 — Audit log : #[Auditable], audit_log, AuditListener, resource)
### Contexte
- Plan TDD dédié (`docs/superpowers/plans/2026-06-19-lst-61-audit-log.md`, Tasks A→F). Exécution : 1 sous-agent par task (A, B, C, D, E) en séquence, vérif + smoke par la session principale entre chaque ; Task F (validation finale + correctif front + learnings + push + statut) en direct.
- Infra portée VERBATIM depuis Starseed (réf canonique `/home/matthieu/dev_malio/Starseed`) : `AuditListener` byte-identique (`diff -q` OK), + 6 fichiers API (DTO/paginator/providers/resources) copiés tels quels — namespaces `App\Module\Core\...` et `App\Shared\Domain\Attribute\...` DÉJÀ alignés entre les deux projets, zéro adaptation.
- 6 commits impl (`934cf08` A, `d8553f0` B, `8c3699a` C, `90b8ca1` D, `e7af415` E, `9b26b43` fix front) + plan `fda03bd`. Tests : 147→157 verts. Branche `feat/lst-61-audit-log` empilée sur `feat/lst-57-rbac-fin`.
### Patterns
- **Audit en 4 couches additives** : (1) marquage déclaratif `#[Auditable]`(TARGET_CLASS) / `#[AuditIgnore]`(TARGET_PROPERTY) dans `src/Shared/Domain/Attribute/` (Shared, pas Core → aucun module n'a de dépendance circulaire) ; (2) capture `AuditListener` Doctrine sur `onFlush` (lit `UnitOfWork` : insertions/updates/deletions + `getScheduledCollectionUpdates/Deletions` pour le M2M) puis `postFlush` (écrit, swap-and-clear anti-réentrance) ; (3) écriture `AuditLogWriter` sur connexion DBAL dédiée `audit` (hors transaction ORM → survit aux rollbacks) ; (4) lecture `AuditLogProvider` DBAL (pas d'entité ORM) + `DbalPaginator implements PaginatorInterface` (API Platform génère `hydra:view` seul).
- **Connexion DBAL dédiée + `schema_filter`** : restructurer `doctrine.yaml` de connexion unique → `connections: {default, audit}` (même DSN), `default_connection: default`, `schema_filter: '~^(?!audit_log$).+~'` sur `default` (la table n'a PAS d'entité → exclue de `migrations:diff`/`schema:validate`). Le bloc `orm` reste INCHANGÉ (l'EM par défaut se lie à `default_connection`). En `when@test`, propager `dbname_suffix` aux DEUX connexions (sinon `audit` écrit en base dev pendant que l'ORM écrit en test).
- **Table append-only hors ORM** : créée par migration manuelle (squelette via `doctrine:migrations:generate` puis contenu écrit à la main — JAMAIS `migrations:diff`, qui ne voit pas la table). `id uuid` natif PG, `changes JSONB`, `performed_at TIMESTAMP(6) WITH TIME ZONE`. UUID v7 (writer, tri monotone) / v4 (requestId par requête HTTP). `entity_type` au format `module.Entity` (regex `App\Module\<module>\...\<Entity>``core.User`).
- **Marquage scope = entités migrées** : `#[Auditable]` posé sur User/Role/Permission (Core) uniquement ; `#[AuditIgnore]` sur `User.password` ET `User.apiToken` (Lesstime n'a pas de `plainPassword`). Défense en profondeur : `AuditLogWriter::SENSITIVE_KEYS` strippe aussi `password/plainPassword/apiToken/token/secret`. Les entités métier legacy (`src/Entity/*`) seront marquées à leur migration en modules (2.x).
### Gotchas
- **Tests fonctionnels Lesstime SANS rollback transactionnel** (pas de DAMADoctrineTestBundle) : les entités persistées survivent d'un run à l'autre → violation d'unicité `username`. Convention projet : `uniqid()` OU nettoyage explicite en `setUp()` (`DELETE FROM "user" WHERE username LIKE 'audit\_%'`). Les données d'audit de test se seedent directement via `doctrine.dbal.audit_connection` (DELETE + inserts UUID v7) pour du déterministe.
- **`migrations:diff` génère un fichier jetable** même quand on ne veut que vérifier : toujours supprimer le `Version<ts>.php` non suivi créé après un diff de contrôle (`git ls-files --others migrations/`). Une dérive préexistante `messenger_messages` (DROP) pollue le diff — sans rapport, ne pas committer.
- **`/audit-log-entity-types` = ressource item unique, pas une collection** : `Get` API Platform avec `uriTemplate` fixe sans `{id}` → renvoie `{ entityTypes: string[] }` (PAS d'enveloppe hydra `member`). Le service front ne doit PAS passer par `extractHydraMembers` ici (bug livré par le sous-agent E, corrigé en `9b26b43`). `/audit-logs` en revanche est bien une collection paginée hydra.
- **Login en curl = `/login_check` (POST), pas `/api/login`** ; le JWT json_login est capricieux en curl pur (405/cookie). La preuve d'auth faisant autorité reste le test fonctionnel (client `loginUser()`), pas un smoke curl.
### Time-tracking / orchestration
- **Interdire explicitement aux sous-agents de toucher au MCP lesstime** (timer + statut ticket) : un sous-agent a spontanément créé/stoppé une time entry (1016) alors que le chrono est piloté par la session principale. Ajouter la consigne « NE TOUCHE PAS au time-tracking » dans chaque prompt de sous-agent. Pas de conflit ici (il avait stoppé l'actif avant), mais découpage involontaire.
-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 \
.
-115
View File
@@ -1,115 +0,0 @@
name: Pull Request — Quality gate
# Lance les tests back + le build front sur chaque PR ciblant develop.
# Deux jobs en parallele (backend / frontend) pour reduire le temps de feedback.
# Pas d'E2E ici : la quality gate se limite a "le back passe les tests, le front compile".
on:
pull_request:
branches:
- develop
# Annule les runs obsoletes quand on repush sur la meme PR.
concurrency:
group: pr-${{ gitea.event.pull_request.number }}
cancel-in-progress: true
jobs:
backend:
name: Backend (PHP CS + PHPUnit)
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
# Doivent matcher la DATABASE_URL ci-dessous. Doctrine ajoute le
# suffixe `_test` automatiquement en APP_ENV=test (when@test
# dbname_suffix) → la base reellement utilisee est `app_test`.
POSTGRES_USER: app
POSTGRES_PASSWORD: '!ChangeMe!'
POSTGRES_DB: app
# Pas de `ports:` host mapping : les jobs Gitea Actions tournent en
# container sur un reseau Docker dedie, le service est joignable via
# son nom (`postgres`), pas via 127.0.0.1.
options: >-
--health-cmd "pg_isready -U app"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
APP_ENV: test
APP_SECRET: ci-secret-not-used
APP_DEBUG: 0
DEFAULT_URI: http://localhost/
DATABASE_URL: postgresql://app:!ChangeMe!@postgres:5432/app?serverVersion=16&charset=utf8
JWT_SECRET_KEY: '%kernel.project_dir%/config/jwt/private.pem'
JWT_PUBLIC_KEY: '%kernel.project_dir%/config/jwt/public.pem'
JWT_PASSPHRASE: ci-passphrase
# Cle de chiffrement (sodium) des secrets Mail / Integration / CalDav que
# les fixtures persistent (ZimbraConfiguration, tokens...). Valeur de test
# alignee sur phpunit.dist.xml.
ENCRYPTION_KEY: ccd250183ea853179562d458e645585f3d46ddebb0701743236196f60fc1a0b8
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP 8.4
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
# zip + gd requis par phpoffice/phpspreadsheet (export XLSX), sodium par
# le chiffrement des secrets, ctype/iconv par le require de composer.json.
extensions: pdo, pdo_pgsql, intl, opcache, zip, mbstring, sodium, gd, ctype, iconv
coverage: none
tools: composer:v2
- name: Install PHP dependencies
run: composer install --no-interaction --no-progress --prefer-dist
- name: Generate JWT keypair
run: php bin/console lexik:jwt:generate-keypair --skip-if-exists --no-interaction
- name: PHP CS Fixer (dry-run)
run: vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --allow-risky=yes --dry-run --diff
- name: Bootstrap test database
# Miroir de la cible `db-reset` du makefile (create + migrate + fixtures),
# en --env=test. Les fixtures sement les roles systeme (RbacSeeder) ;
# sync-permissions complete le catalogue de permissions comme en install reelle.
run: |
php bin/console doctrine:database:create --env=test --if-not-exists --no-interaction
php bin/console doctrine:migrations:migrate --env=test --no-interaction
php bin/console doctrine:fixtures:load --env=test --no-interaction
php bin/console app:sync-permissions --env=test --no-interaction
- name: Run PHPUnit
run: php -d memory_limit=512M vendor/bin/phpunit
frontend:
name: Frontend (build)
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node 24
uses: actions/setup-node@v4
with:
node-version: '24'
# `npm ci` declenche le postinstall `nuxt prepare` (genere .nuxt/).
- name: Install Node dependencies
run: npm ci
# `nuxt build` (et non `build:dist`/`nuxt generate`) : l'app est en SSR off
# (SPA), le prerender n'apporte rien a une quality gate — on valide seulement
# que le bundle compile.
- name: Build production (nuxt build)
run: npm run build
+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": [
-6
View File
@@ -126,12 +126,6 @@ La librairie `@malio/layer-ui` fournit les composants de formulaire et d'action.
- Config Docker : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`)
- Après modif nginx : `docker restart nginx-lesstime`
## Déploiement (prod Docker)
- Script : `infra/prod/deploy.sh` (`./deploy.sh [tag]`) — doc complète : `doc/deployment-docker.md`
- Étapes : maintenance → pull image → up → migrations → **`app:seed-rbac`** → **`app:sync-permissions`** → cache clear/warmup
- **RBAC** : les migrations créent les tables `role`/`permission` mais **n'insèrent aucune donnée**. Les rôles système (`admin`, `user`) viennent de `app:seed-rbac` (idempotent) et le catalogue des permissions de `app:sync-permissions` (à relancer à chaque ajout de permission). Symptôme si oubliées : page admin Rôles vide (« Aucun rôle trouvé »).
## Fixtures
- User admin : `admin` / `admin` (ROLE_ADMIN)
+6 -165
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`.
@@ -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],
];
-14
View File
@@ -7,22 +7,8 @@ declare(strict_types=1);
* Activer/désactiver un module = ajouter/commenter sa ligne. Exposé par GET /api/modules.
*/
use App\Module\Absence\AbsenceModule;
use App\Module\Core\CoreModule;
use App\Module\Directory\DirectoryModule;
use App\Module\Integration\IntegrationModule;
use App\Module\Mail\MailModule;
use App\Module\ProjectManagement\ProjectManagementModule;
use App\Module\Reporting\ReportingModule;
use App\Module\TimeTracking\TimeTrackingModule;
return [
CoreModule::class,
TimeTrackingModule::class,
ProjectManagementModule::class,
AbsenceModule::class,
DirectoryModule::class,
MailModule::class,
IntegrationModule::class,
ReportingModule::class,
];
-14
View File
@@ -1,20 +1,6 @@
api_platform:
title: Lesstime API
version: 1.0.0
# Modular monolith: entities (and their #[ApiFilter] attributes) live under
# src/Module/*/Domain/Entity, not the default src/Entity. Resources are still
# discovered via service autoconfiguration, but #[ApiFilter] services are only
# registered for classes found in these paths — without them, every filter is
# silently ignored. Decoupled ApiResource classes stay discovered via tags.
mapping:
paths:
- '%kernel.project_dir%/src/Module/Core/Domain/Entity'
- '%kernel.project_dir%/src/Module/TimeTracking/Domain/Entity'
- '%kernel.project_dir%/src/Module/ProjectManagement/Domain/Entity'
- '%kernel.project_dir%/src/Module/Absence/Domain/Entity'
- '%kernel.project_dir%/src/Module/Directory/Domain/Entity'
- '%kernel.project_dir%/src/Module/Mail/Domain/Entity'
- '%kernel.project_dir%/src/Module/Integration/Domain/Entity'
formats:
jsonld: ['application/ld+json']
json: ['application/json']
+15 -55
View File
@@ -1,19 +1,12 @@
doctrine:
dbal:
default_connection: default
connections:
# ORM uses `default`; AuditLogWriter uses `audit` (same DSN, separate
# service) to write outside the ORM transaction so audit rows survive
# an application-side rollback and avoid transactional entanglement.
default:
url: '%env(resolve:DATABASE_URL)%'
profiling_collect_backtrace: '%kernel.debug%'
# audit_log has no ORM entity (written via raw DBAL). Exclude it
# from schema comparison so migrations:diff / schema:validate stay
# clean. Creation/teardown stay driven by migrations.
schema_filter: '~^(?!audit_log$).+~'
audit:
url: '%env(resolve:DATABASE_URL)%'
url: '%env(resolve:DATABASE_URL)%'
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '16'
profiling_collect_backtrace: '%kernel.debug%'
orm:
validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
@@ -22,59 +15,26 @@ doctrine:
auto_mapping: true
resolve_target_entities:
App\Shared\Domain\Contract\UserInterface: App\Module\Core\Domain\Entity\User
App\Shared\Domain\Contract\ProjectInterface: App\Module\ProjectManagement\Domain\Entity\Project
App\Shared\Domain\Contract\TaskInterface: App\Module\ProjectManagement\Domain\Entity\Task
App\Shared\Domain\Contract\TaskTagInterface: App\Module\ProjectManagement\Domain\Entity\TaskTag
App\Shared\Domain\Contract\ClientInterface: App\Module\Directory\Domain\Entity\Client
mappings:
App:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
Core:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/Core/Domain/Entity'
prefix: 'App\Module\Core\Domain\Entity'
TimeTracking:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/TimeTracking/Domain/Entity'
prefix: 'App\Module\TimeTracking\Domain\Entity'
ProjectManagement:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/ProjectManagement/Domain/Entity'
prefix: 'App\Module\ProjectManagement\Domain\Entity'
Absence:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/Absence/Domain/Entity'
prefix: 'App\Module\Absence\Domain\Entity'
Directory:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/Directory/Domain/Entity'
prefix: 'App\Module\Directory\Domain\Entity'
Mail:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/Mail/Domain/Entity'
prefix: 'App\Module\Mail\Domain\Entity'
Integration:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/Integration/Domain/Entity'
prefix: 'App\Module\Integration\Domain\Entity'
controller_resolver:
auto_mapping: false
when@test:
doctrine:
dbal:
# Propagate the _test suffix to BOTH connections: the audit
# connection must write to the test DB, not the dev DB.
connections:
default:
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
audit:
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
# "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
when@prod:
doctrine:
+1 -1
View File
@@ -23,7 +23,7 @@ framework:
# messenger:consume à maintenir. La sync de fond reste assurée par le cron OS
# (app:mail:sync, synchrone, indépendant du bus). Repasser à `async` + worker si
# la boîte grossit au point que la sync à la demande approche le timeout PHP.
'App\Module\Mail\Application\Message\MailSyncRequested': sync
'App\Message\MailSyncRequested': sync
when@test:
framework:
-2
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,7 +41,6 @@ security:
pattern: ^/api
stateless: true
provider: app_user_provider
user_checker: App\Module\Core\Infrastructure\Security\ArchivedUserChecker
jwt: ~
logout:
path: /api/logout
-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,
+9 -89
View File
@@ -31,51 +31,41 @@ services:
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
App\Module\ProjectManagement\Infrastructure\EventListener\TaskDocumentListener:
App\EventListener\TaskDocumentListener:
arguments:
$uploadDir: '%task_document_upload_dir%'
tags:
- { name: doctrine.orm.entity_listener }
App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\TaskDocumentProcessor:
App\State\TaskDocumentProcessor:
arguments:
$uploadDir: '%task_document_upload_dir%'
App\Module\ProjectManagement\Infrastructure\Controller\TaskDocumentDownloadController:
App\Controller\TaskDocumentDownloadController:
arguments:
$uploadDir: '%task_document_upload_dir%'
App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Task\AddTaskDocumentTool:
App\Mcp\Tool\Task\AddTaskDocumentTool:
arguments:
$uploadDir: '%task_document_upload_dir%'
App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Task\UpdateTaskDocumentTool:
App\Mcp\Tool\Task\UpdateTaskDocumentTool:
arguments:
$uploadDir: '%task_document_upload_dir%'
App\Module\Core\Infrastructure\Controller\UserAvatarController:
App\Controller\UserAvatarController:
arguments:
$avatarUploadDir: '%avatar_upload_dir%'
App\Module\Absence\Infrastructure\Controller\AbsenceJustificationUploadController:
App\Controller\Absence\AbsenceJustificationUploadController:
arguments:
$uploadDir: '%absence_justification_upload_dir%'
App\Module\Absence\Infrastructure\Controller\AbsenceJustificationDownloadController:
App\Controller\Absence\AbsenceJustificationDownloadController:
arguments:
$uploadDir: '%absence_justification_upload_dir%'
App\Module\Integration\Domain\Service\FileSource: '@App\Module\Integration\Infrastructure\Service\SmbFileSource'
App\Module\Integration\Domain\Repository\GiteaConfigurationRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineGiteaConfigurationRepository'
App\Module\Integration\Domain\Repository\BookStackConfigurationRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineBookStackConfigurationRepository'
App\Module\Integration\Domain\Repository\ZimbraConfigurationRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineZimbraConfigurationRepository'
App\Module\Integration\Domain\Repository\ShareConfigurationRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineShareConfigurationRepository'
App\Module\Integration\Domain\Repository\TaskBookStackLinkRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineTaskBookStackLinkRepository'
App\Service\Share\FileSource: '@App\Service\Share\SmbFileSource'
App\Module\Core\Domain\Repository\UserRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository'
@@ -83,74 +73,4 @@ services:
App\Module\Core\Domain\Repository\RoleRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository'
App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface: '@App\Module\TimeTracking\Infrastructure\Doctrine\DoctrineTimeEntryRepository'
App\Module\ProjectManagement\Domain\Repository\ProjectRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineProjectRepository'
App\Module\ProjectManagement\Domain\Repository\TaskRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskRepository'
App\Module\ProjectManagement\Domain\Repository\WorkflowRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineWorkflowRepository'
App\Module\ProjectManagement\Domain\Repository\TaskStatusRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskStatusRepository'
App\Module\ProjectManagement\Domain\Repository\TaskGroupRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskGroupRepository'
App\Module\ProjectManagement\Domain\Repository\TaskEffortRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskEffortRepository'
App\Module\ProjectManagement\Domain\Repository\TaskPriorityRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskPriorityRepository'
App\Module\ProjectManagement\Domain\Repository\TaskTagRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskTagRepository'
App\Module\ProjectManagement\Domain\Repository\TaskRecurrenceRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskRecurrenceRepository'
App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface: '@App\Module\Absence\Infrastructure\Doctrine\DoctrineAbsenceRequestRepository'
App\Module\Absence\Domain\Repository\AbsencePolicyRepositoryInterface: '@App\Module\Absence\Infrastructure\Doctrine\DoctrineAbsencePolicyRepository'
App\Module\Absence\Domain\Repository\AbsenceBalanceRepositoryInterface: '@App\Module\Absence\Infrastructure\Doctrine\DoctrineAbsenceBalanceRepository'
App\Module\Directory\Domain\Repository\ClientRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineClientRepository'
App\Module\Directory\Domain\Repository\ProspectRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineProspectRepository'
App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrinePrestataireRepository'
App\Module\Directory\Domain\Repository\ContactRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineContactRepository'
App\Module\Directory\Domain\Repository\AddressRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineAddressRepository'
App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineCommercialReportRepository'
App\Module\Directory\Infrastructure\EventListener\CommercialReportAuthorListener:
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%'
App\Module\Directory\Infrastructure\Controller\ReportDocumentDownloadController:
arguments:
$uploadDir: '%task_document_upload_dir%'
App\Module\Directory\Infrastructure\EventListener\ReportDocumentListener:
arguments:
$uploadDir: '%task_document_upload_dir%'
tags:
- { name: doctrine.orm.entity_listener }
App\Module\Mail\Domain\Repository\MailConfigurationRepositoryInterface: '@App\Module\Mail\Infrastructure\Doctrine\DoctrineMailConfigurationRepository'
App\Module\Mail\Domain\Repository\MailFolderRepositoryInterface: '@App\Module\Mail\Infrastructure\Doctrine\DoctrineMailFolderRepository'
App\Module\Mail\Domain\Repository\MailMessageRepositoryInterface: '@App\Module\Mail\Infrastructure\Doctrine\DoctrineMailMessageRepository'
App\Module\Mail\Domain\Repository\TaskMailLinkRepositoryInterface: '@App\Module\Mail\Infrastructure\Doctrine\DoctrineTaskMailLinkRepository'
App\Module\Mail\Domain\Provider\MailProviderInterface: '@App\Module\Mail\Infrastructure\Imap\ImapMailProvider'
App\Shared\Domain\Contract\NotifierInterface: '@App\Module\Core\Infrastructure\Notifier'
+5 -19
View File
@@ -10,11 +10,8 @@ declare(strict_types=1);
* - `permission` : section ou item masqué si la permission effective absente (RBAC fin —
* `User::getEffectivePermissions()` ; ROLE_ADMIN bypasse via le voter, mais la
* sidebar évalue les permissions effectives réelles — combiner avec `roles` au besoin).
* Les items contextuels (Kanban/Groupes/Archives), feature-flag (Documents) et user-flag
* Les items contextuels (Kanban/Groupes/Archives), feature-flag (Documents, Mail) et user-flag
* (Mes absences) restent rendus côté layout, hors de cet endpoint.
* Mail est déclaré ici UNIQUEMENT pour le gating module (disabledRoutes si module inactif) ;
* son rendu visuel + badge non-lus reste géré côté layout, qui filtre `/mail` de translatedSections
* pour éviter le doublon.
* Les labels sont des clés i18n (sidebar.<domaine>.<item>).
*/
return [
@@ -23,18 +20,9 @@ return [
'icon' => 'mdi:view-dashboard-outline',
'items' => [
['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'],
['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline', 'module' => 'project-management', 'permission' => 'project-management.tasks.view'],
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline', 'module' => 'project-management', 'permission' => 'project-management.projects.view'],
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline', 'module' => 'time-tracking', 'permission' => 'time-tracking.entries.view'],
],
],
[
'label' => 'sidebar.tools.section',
'icon' => 'mdi:tools',
'items' => [
// Gating module uniquement : rendu visuel + badge non-lus gérés côté layout
// (filtré de translatedSections puis ré-injecté avec suffixe (N)).
['label' => 'sidebar.general.mail', 'to' => '/mail', 'icon' => 'mdi:email-outline', 'module' => 'mail'],
['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline'],
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline'],
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline'],
],
],
[
@@ -42,9 +30,7 @@ return [
'icon' => 'mdi:cog-outline',
'roles' => ['ROLE_ADMIN'],
'items' => [
['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline', 'module' => 'absence'],
['label' => 'sidebar.admin.directory', 'to' => '/directory', 'icon' => 'mdi:card-account-details-outline', 'module' => 'directory'],
['label' => 'sidebar.admin.reporting', 'to' => '/reporting', 'icon' => 'mdi:chart-line', 'module' => 'reporting', 'permission' => 'reporting.view'],
['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline'],
['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline', 'permission' => 'core.users.view'],
],
],
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.4.45'
app.version: '0.4.30'
+1 -31
View File
@@ -128,12 +128,6 @@ sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintena
echo "==> Running migrations..."
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
echo "==> Seeding RBAC system roles (idempotent)..."
sudo docker compose exec -T -u www-data app php bin/console app:seed-rbac
echo "==> Syncing RBAC permissions catalog..."
sudo docker compose exec -T -u www-data app php bin/console app:sync-permissions
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
@@ -300,31 +294,7 @@ cd /var/www/lesstime
./deploy.sh v0.3.13 # deploie une version specifique
```
C'est tout. Le script pull l'image, redemarre le conteneur, lance les migrations, seed les roles
systeme RBAC, synchronise le catalogue des permissions et vide le cache.
---
## RBAC : roles & permissions (post-deploiement)
Le module RBAC (entites `Role` / `Permission`) repose sur des donnees qui ne sont **pas**
inserees par les migrations (celles-ci creent uniquement les tables). Deux commandes idempotentes
les peuplent, integrees au `deploy.sh` :
| Commande | Effet |
|----------|-------|
| `app:seed-rbac` | Cree les **roles systeme** `admin` (Administrateur) et `user` (Utilisateur). Idempotent : ne recree rien si deja present. |
| `app:sync-permissions` | (Re)synchronise le **catalogue des permissions** a partir des modules actifs. A relancer a chaque ajout de permission dans le code. |
Symptome si elles n'ont pas tourne : la page d'admin **Roles** affiche « Aucun role trouve ».
Correctif manuel sur une prod deja deployee (sans relancer un deploiement complet) :
```bash
cd /var/www/lesstime
sudo docker compose exec -T -u www-data app php bin/console app:seed-rbac
sudo docker compose exec -T -u www-data app php bin/console app:sync-permissions
```
C'est tout. Le script pull l'image, redemarre le conteneur, lance les migrations et vide le cache.
---
@@ -1,706 +0,0 @@
# LST-61 (1.3) · Audit log — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Porter l'infrastructure d'audit de Starseed dans Lesstime : tracer create/update/delete des entités `#[Auditable]` dans une table append-only `audit_log`, exposée en lecture seule via `GET /api/audit-logs` (paginé + filtrable), avec une page de consultation front gated RBAC.
**Architecture:** 4 couches indépendantes, additives (strangler) — (1) **marquage** déclaratif `#[Auditable]`/`#[AuditIgnore]` dans `src/Shared/Domain/Attribute/` ; (2) **capture** par un `AuditListener` Doctrine sur `onFlush`/`postFlush` (capture en mémoire puis écriture déphasée) ; (3) **écriture** via `AuditLogWriter` sur une connexion DBAL dédiée `audit` (hors transaction ORM, survit aux rollbacks) ; (4) **lecture API** via `AuditLogProvider` DBAL (pas d'entité ORM) + `DbalPaginator`. Front Nuxt : service + page consultation gated `core.audit_log.view`.
**Tech Stack:** Symfony 8, API Platform 4, Doctrine ORM/DBAL, PostgreSQL 16, PHP 8.4, PHPUnit, symfony/uid (vendoré), Nuxt 4 / Vue 3 / Pinia / @nuxtjs/i18n.
## Global Constraints
- **Aucune mention de Claude/Anthropic/IA** dans les écritures Git (commits, trailers, descriptions MR, merge). Messages factuels et techniques.
- **Additif uniquement** : aucune migration destructive (pas de DROP/ALTER sur tables existantes en `up()`).
- **PostgreSQL** : noms de colonnes toujours en minuscules snake_case dans le SQL brut.
- **Code** : `declare(strict_types=1)`, PSR-12, patterns API Platform / Doctrine existants. Variables et commentaires en anglais.
- **`config/reference.php`** auto-généré — NE JAMAIS committer.
- Toujours lire un fichier avant de le modifier ; reproduire le style existant.
- Branche : `feat/lst-61-audit-log` (empilée sur `feat/lst-57-rbac-fin`).
- Tests Docker : `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`.
---
## File Structure
**Créés :**
- `src/Shared/Domain/Attribute/Auditable.php` — marqueur classe
- `src/Shared/Domain/Attribute/AuditIgnore.php` — marqueur propriété
- `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php` — écriture DBAL `audit`
- `src/Module/Core/Infrastructure/Audit/RequestIdProvider.php` — UUID par requête
- `src/Module/Core/Infrastructure/Doctrine/AuditListener.php` — capture onFlush/postFlush
- `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php`
- `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogEntityTypesResource.php`
- `src/Module/Core/Application/DTO/AuditLogOutput.php`
- `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php`
- `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogEntityTypesProvider.php`
- `src/Module/Core/Infrastructure/ApiPlatform/Pagination/DbalPaginator.php`
- `migrations/Version20260619XXXXXX.php` — table `audit_log`
- `tests/Functional/Module/Core/AuditListenerTest.php`
- `tests/Functional/Module/Core/AuditLogApiTest.php`
- `frontend/modules/core/services/audit-logs.ts`
- `frontend/components/admin/AdminAuditTab.vue`
**Modifiés :**
- `config/packages/doctrine.yaml` — connexion `audit` + `schema_filter` audit_log
- `src/Module/Core/CoreModule.php` — permission `core.audit_log.view`
- `src/Module/Core/Domain/Entity/User.php``#[Auditable]` + `#[AuditIgnore]` password/apiToken
- `src/Module/Core/Domain/Entity/Role.php``#[Auditable]`
- `src/Module/Core/Domain/Entity/Permission.php``#[Auditable]`
- `tests/Unit/Module/Core/CoreModuleTest.php` — assert nouvelle permission
- `frontend/pages/admin.vue` — onglet Audit gated `core.audit_log.view`
- `frontend/i18n/locales/fr.json` — clés `admin.audit.*` + `audit.entity.*`
---
## Task A: Marquage + table + connexion DBAL audit
**Files:**
- Create: `src/Shared/Domain/Attribute/Auditable.php`, `src/Shared/Domain/Attribute/AuditIgnore.php`
- Create: `migrations/Version20260619XXXXXX.php`
- Modify: `config/packages/doctrine.yaml`
**Interfaces produced:** `App\Shared\Domain\Attribute\Auditable` (TARGET_CLASS), `App\Shared\Domain\Attribute\AuditIgnore` (TARGET_PROPERTY) ; service DBAL `doctrine.dbal.audit_connection` ; table `audit_log`.
- [ ] **Step A1: Attributs** — créer les deux fichiers :
```php
<?php
// src/Shared/Domain/Attribute/Auditable.php
declare(strict_types=1);
namespace App\Shared\Domain\Attribute;
use Attribute;
/**
* Marker placed on a Doctrine entity to enable audit tracking.
*
* Located in Shared (not Core) so every module can use it without a
* circular dependency on Core. Any migrated business entity that should be
* traced carries this attribute, with #[AuditIgnore] on sensitive fields.
*/
#[Attribute(Attribute::TARGET_CLASS)]
final class Auditable
{
}
```
```php
<?php
// src/Shared/Domain/Attribute/AuditIgnore.php
declare(strict_types=1);
namespace App\Shared\Domain\Attribute;
use Attribute;
/**
* Marker placed on an entity property to exclude it from audit tracking.
*
* Typical use: sensitive fields (password, apiToken). The AuditLogWriter also
* carries an exact-match blacklist on the most dangerous names as
* defense-in-depth, but the base rule is to annotate explicitly here.
*/
#[Attribute(Attribute::TARGET_PROPERTY)]
final class AuditIgnore
{
}
```
- [ ] **Step A2: Migration** — créer `migrations/Version20260619XXXXXX.php` (timestamp réel via `php bin/console make:migration` puis remplacer le contenu, OU horodatage manuel cohérent > 20260619145109) :
```php
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Audit log (LST-61) : append-only `audit_log` table.
*
* Not managed by Doctrine ORM (no entity). Written via raw DBAL by the
* AuditLogWriter on a dedicated `audit` connection to avoid re-entrant
* flushes from the Doctrine listener. Columns are lowercase snake_case.
* Additive only — no DROP/ALTER on existing tables.
*/
final class Version20260619XXXXXX extends AbstractMigration
{
public function getDescription(): string
{
return 'Audit log: create append-only audit_log table + indexes (additive)';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE audit_log (
id uuid NOT NULL,
entity_type VARCHAR(100) NOT NULL,
entity_id VARCHAR(64) NOT NULL,
action VARCHAR(10) NOT NULL,
changes JSONB NOT NULL DEFAULT '{}'::jsonb,
performed_by VARCHAR(100) NOT NULL,
performed_at TIMESTAMP(6) WITH TIME ZONE NOT NULL,
ip_address VARCHAR(45) DEFAULT NULL,
request_id VARCHAR(36) DEFAULT NULL,
PRIMARY KEY(id)
)
SQL);
$this->addSql('CREATE INDEX idx_audit_entity_time ON audit_log (entity_type, entity_id, performed_at)');
$this->addSql('CREATE INDEX idx_audit_performer ON audit_log (performed_by, performed_at)');
$this->addSql('CREATE INDEX idx_audit_time ON audit_log (performed_at)');
$this->addSql("COMMENT ON COLUMN audit_log.entity_type IS 'Audited entity type, format module.Entity (e.g. core.User)'");
$this->addSql("COMMENT ON COLUMN audit_log.entity_id IS 'Audited entity identifier (int or composite key serialized)'");
$this->addSql("COMMENT ON COLUMN audit_log.action IS 'create|update|delete'");
$this->addSql("COMMENT ON COLUMN audit_log.changes IS 'JSON diff: {field:{old,new}} for update, full snapshot for create/delete'");
$this->addSql("COMMENT ON COLUMN audit_log.performed_by IS 'User identifier or system'");
$this->addSql("COMMENT ON COLUMN audit_log.request_id IS 'UUID shared by all audit rows of a single HTTP request (null in CLI)'");
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE audit_log');
}
}
```
- [ ] **Step A3: Connexion DBAL `audit`** — restructurer `config/packages/doctrine.yaml`. Remplacer le bloc `dbal` racine (connexion unique) par des connexions nommées, et propager le `dbname_suffix` de test aux deux connexions. **Le bloc `orm` reste inchangé** (l'EM par défaut se lie à `default_connection`).
Remplacer :
```yaml
dbal:
url: '%env(resolve:DATABASE_URL)%'
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '16'
profiling_collect_backtrace: '%kernel.debug%'
```
par :
```yaml
dbal:
default_connection: default
connections:
# ORM uses `default`; AuditLogWriter uses `audit` (same DSN, separate
# service) to write outside the ORM transaction so audit rows survive
# an application-side rollback and avoid transactional entanglement.
default:
url: '%env(resolve:DATABASE_URL)%'
profiling_collect_backtrace: '%kernel.debug%'
# audit_log has no ORM entity (written via raw DBAL). Exclude it
# from schema comparison so migrations:diff / schema:validate stay
# clean. Creation/teardown stay driven by migrations.
schema_filter: '~^(?!audit_log$).+~'
audit:
url: '%env(resolve:DATABASE_URL)%'
```
Et remplacer le bloc `when@test` :
```yaml
when@test:
doctrine:
dbal:
# "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
```
par :
```yaml
when@test:
doctrine:
dbal:
# Propagate the _test suffix to BOTH connections: the audit
# connection must write to the test DB, not the dev DB.
connections:
default:
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
audit:
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
```
- [ ] **Step A4: Vérifier la non-régression** — la restructuration des connexions est le point sensible. Lancer la suite existante :
```bash
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
```
Expected: 147 tests toujours verts (aucune régression liée au changement de connexions).
- [ ] **Step A5: Appliquer la migration (dev + test)** :
```bash
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:migrate -n
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:migrate -n --env=test
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff --env=test 2>&1 | grep -i "audit_log" || echo "OK: audit_log absent du diff (schema_filter actif)"
```
Expected: table créée, `audit_log` absente de tout diff généré.
- [ ] **Step A6: Commit**
```bash
git add src/Shared/Domain/Attribute config/packages/doctrine.yaml migrations/
git commit -m "feat(core) : add audit attributes, audit_log table and dedicated dbal connection"
```
---
## Task B: AuditLogWriter + RequestIdProvider
**Files:**
- Create: `src/Module/Core/Infrastructure/Audit/AuditLogWriter.php`
- Create: `src/Module/Core/Infrastructure/Audit/RequestIdProvider.php`
**Interfaces produced:** `AuditLogWriter::log(string $entityType, string $entityId, string $action, array $changes): void` ; `RequestIdProvider::getRequestId(): ?string`.
- [ ] **Step B1: RequestIdProvider** (verbatim Starseed) :
```php
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Audit;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\Uid\Uuid;
/**
* Provides an HTTP request identifier (UUID v4) shared by every audit row
* produced during a single main request. Null in CLI (fixtures, batch).
*/
final class RequestIdProvider
{
private ?string $requestId = null;
#[AsEventListener(event: 'kernel.request')]
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$this->requestId = Uuid::v4()->toRfc4122();
}
public function getRequestId(): ?string
{
return $this->requestId;
}
}
```
- [ ] **Step B2: AuditLogWriter** (verbatim Starseed, connexion `audit`) :
```php
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure\Audit;
use DateTimeImmutable;
use DateTimeZone;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Types\Types;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Uid\Uuid;
/**
* Low-level service responsible for writing into the `audit_log` table.
*
* Uses a dedicated `audit` DBAL connection (same DSN as `default`) to write
* outside the ORM transaction: audit rows survive an application-side
* rollback and avoid transactional entanglement in batch (fixtures).
*
* Sensitive keys are stripped in defense-in-depth even when entities already
* declare those properties #[AuditIgnore]. SQL failures are swallowed by the
* caller (AuditListener wraps log() in try/catch) — audit must never crash a
* business flow.
*/
final class AuditLogWriter
{
/** @var list<string> keys always stripped from the `changes` payload */
private const array SENSITIVE_KEYS = ['password', 'plainPassword', 'apiToken', 'token', 'secret'];
public function __construct(
#[Autowire(service: 'doctrine.dbal.audit_connection')]
private readonly Connection $connection,
private readonly Security $security,
private readonly RequestStack $requestStack,
private readonly RequestIdProvider $requestIdProvider,
) {
}
/**
* @param string $entityType Format "module.Entity" (e.g. "core.User")
* @param string $entityId Entity id (int or serialized UUID)
* @param string $action create|update|delete
* @param array<string, mixed> $changes JSON payload (sensitive keys stripped)
*/
public function log(
string $entityType,
string $entityId,
string $action,
array $changes,
): void {
$filteredChanges = $this->stripSensitive($changes);
$this->connection->insert('audit_log', [
'id' => Uuid::v7()->toRfc4122(),
'entity_type' => $entityType,
'entity_id' => $entityId,
'action' => $action,
'changes' => $filteredChanges,
'performed_by' => $this->security->getUser()?->getUserIdentifier() ?? 'system',
'performed_at' => new DateTimeImmutable('now', new DateTimeZone('UTC')),
'ip_address' => $this->requestStack->getCurrentRequest()?->getClientIp(),
'request_id' => $this->requestIdProvider->getRequestId(),
], [
'id' => Types::GUID,
'changes' => Types::JSON,
'performed_at' => Types::DATETIMETZ_IMMUTABLE,
]);
}
/**
* Recursively removes sensitive keys from the payload.
*
* @param array<string, mixed> $data
*
* @return array<string, mixed>
*/
private function stripSensitive(array $data): array
{
foreach ($data as $key => $value) {
if (in_array($key, self::SENSITIVE_KEYS, true)) {
unset($data[$key]);
continue;
}
if (is_array($value)) {
$data[$key] = $this->stripSensitive($value);
}
}
return $data;
}
}
```
- [ ] **Step B3: Vérifier le câblage** (autowiring) :
```bash
docker exec -t -u www-data php-lesstime-fpm php bin/console debug:container App\\Module\\Core\\Infrastructure\\Audit\\AuditLogWriter 2>&1 | head -20
```
Expected: service trouvé, injection `doctrine.dbal.audit_connection` résolue.
- [ ] **Step B4: Commit**
```bash
git add src/Module/Core/Infrastructure/Audit/
git commit -m "feat(core) : add audit log writer and request id provider"
```
---
## Task C: AuditListener + marquage des entités Core
**Files:**
- Create: `src/Module/Core/Infrastructure/Doctrine/AuditListener.php`
- Modify: `src/Module/Core/Domain/Entity/User.php`, `Role.php`, `Permission.php`
- Test: `tests/Functional/Module/Core/AuditListenerTest.php`
**Interfaces consumed:** `AuditLogWriter`, attributs `Auditable`/`AuditIgnore`.
- [ ] **Step C1: Écrire le test fonctionnel (échec attendu)**`tests/Functional/Module/Core/AuditListenerTest.php`. Le test crée/modifie/supprime un User via l'EntityManager dans le kernel de test, puis lit `audit_log` via la connexion `audit`. (S'inspirer du style des tests fonctionnels existants — `RoleApiTest`, `UserRbacApiTest`.)
```php
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Module\Core;
use App\Module\Core\Domain\Entity\User;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
*/
final class AuditListenerTest extends KernelTestCase
{
private EntityManagerInterface $em;
private Connection $auditConnection;
protected function setUp(): void
{
self::bootKernel();
$container = self::getContainer();
$this->em = $container->get(EntityManagerInterface::class);
$this->auditConnection = $container->get('doctrine.dbal.audit_connection');
// Clean slate for deterministic assertions.
$this->auditConnection->executeStatement('DELETE FROM audit_log');
}
public function testCreateUserIsAudited(): void
{
$user = $this->makeUser('audit_create_user');
$this->em->persist($user);
$this->em->flush();
$rows = $this->fetchLogs('core.User', (string) $user->getId());
self::assertCount(1, $rows);
self::assertSame('create', $rows[0]['action']);
$changes = json_decode((string) $rows[0]['changes'], true);
self::assertArrayHasKey('username', $changes);
self::assertArrayNotHasKey('password', $changes, 'password must be excluded via #[AuditIgnore]');
self::assertArrayNotHasKey('apiToken', $changes, 'apiToken must be excluded via #[AuditIgnore]');
}
public function testUpdateUserIsAuditedWithDiff(): void
{
$user = $this->makeUser('audit_update_user');
$this->em->persist($user);
$this->em->flush();
$this->auditConnection->executeStatement('DELETE FROM audit_log');
$user->setFirstName('Changed');
$this->em->flush();
$rows = $this->fetchLogs('core.User', (string) $user->getId());
self::assertCount(1, $rows);
self::assertSame('update', $rows[0]['action']);
$changes = json_decode((string) $rows[0]['changes'], true);
self::assertArrayHasKey('firstName', $changes);
self::assertSame('Changed', $changes['firstName']['new']);
}
public function testDeleteUserIsAudited(): void
{
$user = $this->makeUser('audit_delete_user');
$this->em->persist($user);
$this->em->flush();
$id = (string) $user->getId();
$this->auditConnection->executeStatement('DELETE FROM audit_log');
$this->em->remove($user);
$this->em->flush();
$rows = $this->fetchLogs('core.User', $id);
self::assertCount(1, $rows);
self::assertSame('delete', $rows[0]['action']);
}
private function makeUser(string $username): User
{
$user = new User();
$user->setUsername($username);
$user->setPassword('hashed-secret');
$user->setRoles(['ROLE_USER']);
return $user;
}
/**
* @return list<array<string, mixed>>
*/
private function fetchLogs(string $entityType, string $entityId): array
{
return $this->auditConnection->fetchAllAssociative(
'SELECT action, changes FROM audit_log WHERE entity_type = :t AND entity_id = :id ORDER BY performed_at ASC',
['t' => $entityType, 'id' => $entityId],
);
}
protected function tearDown(): void
{
parent::tearDown();
unset($this->em, $this->auditConnection);
}
}
```
> **Note adaptation :** vérifier la signature réelle de `User` (setters disponibles : `setUsername`, `setPassword`, `setRoles`, `setFirstName`). Ajuster `makeUser()` aux champs NOT NULL réels de la table `user`. Si `User` exige d'autres champs obligatoires (ex. `createdAt` initialisé au constructeur — déjà le cas), ne rien ajouter.
- [ ] **Step C2: Run le test → échec** (listener absent, entités non marquées) :
```bash
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Functional/Module/Core/AuditListenerTest.php
```
Expected: FAIL.
- [ ] **Step C3: Créer `AuditListener`** (verbatim Starseed, namespace `App\Module\Core\Infrastructure\Doctrine`). Copier intégralement le listener fourni dans le rapport Starseed (onFlush capture + postFlush écriture, swap-and-clear, gestion collections, snapshot create/delete, buildUpdateChanges, formatEntityType regex `App\Module\<module>\...\<Entity>`, caches Auditable/AuditIgnore). **Ne rien simplifier.**
- [ ] **Step C4: Marquer les entités Core.**
`src/Module/Core/Domain/Entity/User.php` — ajouter import + attribut classe + `#[AuditIgnore]` sur `password` et `apiToken` :
```php
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Attribute\AuditIgnore;
```
```php
#[Auditable]
#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]
#[ORM\Table(name: '`user`')]
class User implements ...
```
Sur la propriété `password` (ligne ~89-90) et `apiToken` (ligne ~99-100), ajouter `#[AuditIgnore]` au-dessus de la ligne `private ?string $password = null;` / `private ?string $apiToken = null;`.
`src/Module/Core/Domain/Entity/Role.php` — ajouter `use App\Shared\Domain\Attribute\Auditable;` et `#[Auditable]` au-dessus de `#[ORM\Entity...]`.
`src/Module/Core/Domain/Entity/Permission.php` — idem `#[Auditable]`.
- [ ] **Step C5: Run le test → succès** :
```bash
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Functional/Module/Core/AuditListenerTest.php
```
Expected: PASS (3 tests).
- [ ] **Step C6: Suite complète + cs-fixer** :
```bash
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
make php-cs-fixer-allow-risky
```
Expected: tout vert.
- [ ] **Step C7: Commit**
```bash
git add src/Module/Core/Infrastructure/Doctrine/AuditListener.php src/Module/Core/Domain/Entity/ tests/Functional/Module/Core/AuditListenerTest.php
git commit -m "feat(core) : add doctrine audit listener and mark core entities auditable"
```
---
## Task D: API de lecture `/api/audit-logs` + permission
**Files:**
- Create: `AuditLogOutput.php`, `DbalPaginator.php`, `AuditLogProvider.php`, `AuditLogResource.php`, `AuditLogEntityTypesResource.php`, `AuditLogEntityTypesProvider.php`
- Modify: `src/Module/Core/CoreModule.php` (permission), `tests/Unit/Module/Core/CoreModuleTest.php`
- Test: `tests/Functional/Module/Core/AuditLogApiTest.php`
**Interfaces consumed:** table `audit_log`, connexion `doctrine.dbal.default_connection`, permission `core.audit_log.view`.
- [ ] **Step D1: Permission** — ajouter dans `CoreModule::permissions()` :
```php
['code' => 'core.audit_log.view', 'label' => 'Consulter le journal d\'audit'],
```
Mettre à jour `tests/Unit/Module/Core/CoreModuleTest.php` pour asserter la présence de ce code (la liste passe à 6 permissions).
- [ ] **Step D2: DTO + Paginator + Providers + Resources** — créer les 6 fichiers verbatim depuis le rapport Starseed :
- `src/Module/Core/Application/DTO/AuditLogOutput.php`
- `src/Module/Core/Infrastructure/ApiPlatform/Pagination/DbalPaginator.php`
- `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php`
- `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogEntityTypesProvider.php`
- `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogResource.php`
- `src/Module/Core/Infrastructure/ApiPlatform/Resource/AuditLogEntityTypesResource.php`
**Adaptation pagination :** Lesstime n'a pas de `itemsPerPage`/`maximum_items_per_page` explicite dans `api_platform.yaml`. Le provider utilise `Pagination::getPage()`/`getLimit()` (défauts API Platform : 30/page). C'est acceptable. Conserver le clamp `max(1, page)`.
- [ ] **Step D3: Écrire le test API (échec attendu)**`tests/Functional/Module/Core/AuditLogApiTest.php`. S'aligner sur le helper d'auth des tests existants (login admin/admin via cookie JWT, cf. `RoleApiTest`). Tests :
- admin authentifié : `GET /api/audit-logs` → 200, structure hydra paginée.
- filtre `?action=update` → ne renvoie que des updates.
- filtre `?entity_type=core.User`.
- `?action=bogus` → 400.
- utilisateur sans permission (alice) : 403.
- non authentifié : 401.
Préparer des données : créer/modifier un User via l'EM avant les assertions (le listener écrit), OU insérer directement des lignes via la connexion `audit`.
- [ ] **Step D4: Run → échec, puis vérifier la route** :
```bash
docker exec -t -u www-data php-lesstime-fpm php bin/console debug:router 2>&1 | grep -i audit
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Functional/Module/Core/AuditLogApiTest.php
```
Expected: routes `/api/audit-logs`, `/api/audit-logs/{id}`, `/api/audit-log-entity-types` présentes ; test passe une fois les providers branchés.
- [ ] **Step D5: sync-permissions** (enregistre `core.audit_log.view` en base dev + test) :
```bash
docker exec -t -u www-data php-lesstime-fpm php bin/console app:sync-permissions
docker exec -t -u www-data php-lesstime-fpm php bin/console app:sync-permissions --env=test
```
- [ ] **Step D6: Suite complète + cs-fixer**
```bash
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
make php-cs-fixer-allow-risky
```
- [ ] **Step D7: Commit**
```bash
git add src/Module/Core/ tests/
git commit -m "feat(core) : expose read-only audit-logs api with dbal provider and pagination"
```
---
## Task E: Front — page consultation gated RBAC
**Files:**
- Create: `frontend/modules/core/services/audit-logs.ts`, `frontend/components/admin/AdminAuditTab.vue`
- Modify: `frontend/pages/admin.vue`, `frontend/i18n/locales/fr.json`
**Interfaces consumed:** `GET /api/audit-logs`, composable `usePermissions` (livré en 1.2), pattern onglet admin (cf. `AdminRoleTab.vue` créé en 1.2).
- [ ] **Step E1: Service**`frontend/modules/core/services/audit-logs.ts` : fonction `fetchAuditLogs(params)` via `useApi()` (suivre `roles.ts`/`permissions.ts` créés en 1.2). Types : `AuditLogItem { id, entityType, entityId, action, changes, performedBy, performedAt, ipAddress, requestId }`.
- [ ] **Step E2: Composant onglet**`frontend/components/admin/AdminAuditTab.vue` : tableau paginé (colonnes date, utilisateur, type d'entité, action, id), filtre par `entityType` et `action`. Labels via i18n `audit.entity.*` et `audit.action.*`. Reproduire le style de `AdminRoleTab.vue`.
- [ ] **Step E3: Onglet dans admin.vue** — ajouter un onglet « Audit » gated `can('core.audit_log.view')` (suivre le gating de l'onglet rôles ajouté en 1.2).
- [ ] **Step E4: i18n**`frontend/i18n/locales/fr.json` : ajouter `admin.audit.*` (titre, colonnes, filtres) et `audit.entity.core.User` = « Utilisateur », `audit.entity.core.Role` = « Rôle », `audit.entity.core.Permission` = « Permission » ; `audit.action.create/update/delete`.
- [ ] **Step E5: Vérifier la route déterministe (SPA)** :
```bash
cd frontend && npx nuxt build 2>&1 | tail -5
grep -o 'name:"admin"' .output/server/chunks/build/client.precomputed.mjs | head -1
```
Expected: build OK (la page admin reste enregistrée).
- [ ] **Step E6: Commit**
```bash
git add frontend/
git commit -m "feat(core) : add audit log consultation tab in admin gated by permission"
```
---
## Task F: Validation finale + statut
- [ ] **Step F1: Suite complète verte + login fumée**
```bash
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
```
Vérifier login admin → 204 + `GET /api/me` 200 + `GET /api/audit-logs` 200 (cURL ou via test).
- [ ] **Step F2: migrations:diff propre** (audit_log absente du diff) :
```bash
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff --env=test 2>&1 | grep -ci audit_log
```
Expected: 0.
- [ ] **Step F3: Learnings** — append session #61 à `.claude/skills/ticket-executor/LEARNINGS.md`, commit `docs : log LST-61 audit log session learnings`.
- [ ] **Step F4: Push branche + MR empilée sur #57** (Gitea, base `feat/lst-57-rbac-fin`), draft puis un-draft via API si voulu.
- [ ] **Step F5: Ticket #61 (id 647) → « En attente de validation » (statut 4)**, stopper le timer, informer l'utilisateur.
---
## Self-Review (couverture spec)
| Critère d'acceptation | Tâche |
|---|---|
| CRUD des entités `#[Auditable]` tracé | C (listener + test create/update/delete) |
| Endpoint `/api/audit-logs` paginé/filtrable | D (provider DBAL + DbalPaginator + filtres) |
| `make test` vert, aucune migration destructive | A (migration additive), C/D/F (suite) |
| `#[Auditable]`/`#[AuditIgnore]` dans Shared | A1 |
| Table `audit_log` (qui/quoi/quand/diff/requestId) + COMMENT | A2 |
| `#[AuditIgnore]` champs sensibles (password, apiToken) | C4 + B2 blacklist |
| Front consultation + i18n `audit.entity.*` gated RBAC | E |
**Décision de scope :** `#[Auditable]` posé sur les **entités migrées** (User, Role, Permission) conformément au libellé du ticket. Les entités métier legacy (`src/Entity/*`) ne sont pas marquées ici — elles le seront lors de leur migration en modules (phases 2.x+). L'infra est prête à les auditer sans modification dès qu'elles portent l'attribut.
@@ -1,66 +0,0 @@
# LST-58 (2.4) — Module Directory : Prospect + front répertoire (plan)
> Suite de la migration Directory. Client (back) déjà livré (`c5738d2`).
> Reste : **entité Prospect** (nouvelle) + **front répertoire** (Clients + Prospects).
> Spec produit non fournie → design défini ici de façon raisonnable, à valider au test.
> Additif, sans régression. Branche `integration/modular-monolith-0.1-1.3`.
## Design Prospect (décidé, à valider)
Aligné sur `Client` (même module Directory), enrichi des concepts de prospection commerciale.
**Entité `App\Module\Directory\Domain\Entity\Prospect`** (table `prospect`) :
- `id` int PK
- `name` string(255) NOT NULL — contact ou société
- `company` string(255) nullable
- `email` string(255) nullable
- `phone` string(50) nullable
- `street` string(255) nullable / `city` string(255) nullable / `postalCode` string(20) nullable (alignés Client)
- `status` enum `ProspectStatus` NOT NULL (default `New`)
- `source` string(255) nullable — origine (recommandation, salon, site web…)
- `notes` text nullable
- `convertedClient` ManyToOne `ClientInterface` nullable, JoinColumn ON DELETE SET NULL — rempli à la conversion
- Timestampable/Blamable (trait) + `#[Auditable]`
- Groupes : `prospect:read` / `prospect:write`
**Enum `App\Module\Directory\Domain\Enum\ProspectStatus`** : `New` (nouveau), `Contacted` (contacté), `Qualified` (qualifié), `Won` (gagné/converti), `Lost` (perdu). Méthode `label(): string` (FR), comme les autres enums.
**API Platform** (aligné Client) :
- `GetCollection` paginationEnabled:false, `is_granted('ROLE_USER')`
- `Get` ROLE_USER ; `Post`/`Patch`/`Delete` ROLE_ADMIN
- Opération custom **`Post /prospects/{id}/convert`** (processor `ConvertProspectProcessor`) : crée un `Client` à partir du Prospect (name/company→name, email, phone, adresse), lie `convertedClient`, passe `status=Won`. Sécurité ROLE_ADMIN. Renvoie le Prospect mis à jour. Idempotent si déjà converti (renvoie l'existant).
- `#[ApiFilter]` SearchFilter sur `status` (filtre répertoire).
**Repo** : `ProspectRepositoryInterface` (Domain) + `DoctrineProspectRepository` (Infra) + binding.
**MCP** (cohérent avec clients, sous `Infrastructure/Mcp/Tool/`) : `list-prospects`, `get-prospect`, `create-prospect`, `update-prospect`, `delete-prospect`, `convert-prospect`. Serializer : ajouter `prospect()` dans `src/Mcp/Tool/Serializer.php`.
**DirectoryModule.permissions()** : ajouter `directory.prospects.view`, `directory.prospects.manage` (additif).
**Migration additive** : CREATE TABLE prospect (colonnes + FK converted_client→client ON DELETE SET NULL + created_by/updated_by FK user + index + COMMENT). Down = DROP TABLE.
**Fixtures** : 2-3 prospects de démo (statuts variés), dont un converti.
## Front répertoire (`frontend/modules/directory/`)
Aujourd'hui : pas de page client dédiée (AdminClientTab + picker ProjectDrawer). On crée un vrai répertoire.
- `nuxt.config.ts` vide.
- `services/` : `clients.ts` (move depuis racine), `prospects.ts` (nouveau) + `dto/{client,prospect}.ts`.
- `pages/directory.vue` : page à 2 onglets (Clients / Prospects), tableaux paginés côté client (paginationEnabled:false back), recherche/filtre statut pour prospects.
- `components/` : `ClientDrawer.vue` (move depuis `components/client/`), `ProspectDrawer.vue` (nouveau, create/edit + bouton « Convertir en client »).
- Sidebar : ajouter item `sidebar.general.directory``/directory`, `'module' => 'directory'`, gate ROLE_ADMIN (gestion référentiel).
- Réécrire imports consommateurs de `~/services/clients` / `~/services/dto/client` (AdminClientTab, ProjectDrawer, pages projects) → `~/modules/directory/services/...`. AdminClientTab : soit le retirer de /admin au profit de /directory, soit le laisser pointer le nouveau service. Décision : garder AdminClientTab fonctionnel (repoint service) ET ajouter la page /directory (les deux coexistent ; /directory = vue dédiée).
- i18n global : ajouter clés `directory.*`, `prospects.*`, `sidebar.general.directory`.
## Vagues d'exécution
1. **Back Prospect** : enum + entité + repo + API (CRUD + convert) + MCP (6 tools) + Serializer + permissions module + fixtures + migration. Vérif cache:clear/migrate/phpunit/cs-fixer → commit.
2. **Front Directory** : layer (move client front + page répertoire + ProspectDrawer + prospects service/dto) + sidebar + imports + i18n. Vérif nuxt build → commit.
## Critères d'acceptation (ticket #58)
- [x] Clients en module (fait, c5738d2)
- [ ] Prospects en module + front répertoire fonctionnel
- [x] resolve_target_entities → Directory\Client
- [ ] make test vert, aucune migration destructive
- [ ] toggle module directory (sidebar + route /directory)
## Suite phase 2 (après 2.4)
- 2.5 (#67) Module Mail — WIP `docs/mail-integration.md`, à traiter avec précaution.
- 2.6 (#68) Module Integration (Gitea/BookStack/Zimbra/Share).
@@ -1,82 +0,0 @@
# LST-65 (2.2) — Module ProjectManagement : plan de migration
> Migration strangler du cœur métier Projets/Tâches vers `src/Module/ProjectManagement/`.
> Additive, sans régression API. Exécution en 4 tranches **incrémentalement vertes**
> (chaque tranche compile + `phpunit` vert + commit ; aucun état cassé committé).
**Branche** : `integration/modular-monolith-0.1-1.3` (empilement phase 2).
**Vérif container** : `docker exec -u www-data php-lesstime-fpm php bin/console cache:clear`
**Tests** : `docker exec -u www-data php-lesstime-fpm php vendor/bin/phpunit` (baseline = 159 verts).
**Style** : `make php-cs-fixer-allow-risky`. PHP `declare(strict_types=1)`. SQL colonnes minuscules.
## Périmètre (10 entités + écosystème)
Entités : Project, Task, Workflow, TaskStatus, TaskGroup, TaskEffort, TaskPriority, TaskTag, TaskRecurrence, TaskDocument.
Enums : StatusCategory, RecurrenceType.
Repos (9), State (7), MCP (38), Controller (1), Services (2 : CalDavService, RecurrenceCalculator), Listeners (3), ApiResource (SwitchWorkflowOutput), fixtures, tests.
## Décisions d'architecture (figées)
1. **Contrats inter-modules uniquement** (`src/Shared/Domain/Contract/`), surface minimale :
- `ProjectInterface` : `getId(): ?int`, `getCode(): ?string`, `getName(): ?string`
- `TaskInterface` : `getId(): ?int`, `getNumber(): ?int`, `getTitle(): ?string`
- `TaskTagInterface` : `getId(): ?int`, `getLabel(): ?string`, `getColor(): ?string`
- `ClientInterface` : `getId(): ?int`, `getName(): ?string`
- PAS de WorkflowInterface (Workflow est intra-module PM).
2. **Consommateur contractuel** : seul le module **TimeTracking** (`TimeEntry`) bascule Project/Task/TaskTag → interfaces. **Project** (PM) bascule client → `ClientInterface`.
3. **Legacy non modularisé** (Gitea/BookStack/Mail : `src/Controller/Mail/*`, `src/State/Gitea*`, `src/State/BookStack*`, `src/Service/GiteaApiService.php`, `src/ApiResource/BookStack*`, `src/Entity/TaskMailLink.php`, `src/Entity/TaskBookStackLink.php`), **Serializer MCP partagé** (`src/Mcp/Tool/Serializer.php`), fixtures, tests : bascule du **FQCN concret** `App\Entity\X``App\Module\ProjectManagement\Domain\Entity\X`. Couplage transitoire legacy→module, nettoyé en 2.4/2.5/2.6.
4. **Repos** : pattern Core/TimeTracking — interface `Domain/Repository/XxxRepositoryInterface` + `Infrastructure/Doctrine/DoctrineXxxRepository extends ServiceEntityRepository implements …` + binding `services.yaml`. Conserver les méthodes métier (`findMaxNumberByProjectForUpdate`, `findFirstNonFinal`, `findDefault`).
5. **Services CalDavService + RecurrenceCalculator**`Infrastructure/` du module (dépendance résiduelle ZimbraConfiguration legacy tolérée jusqu'à 2.6).
6. **Serializer.php** reste à `src/Mcp/Tool/` (helper multi-domaines), import concret PM.
7. **Timestampable additif** : sur **Task** et **Project** uniquement (agrégats), pas les référentiels. Migration additive (4 colonnes nullable + FK SET NULL + COMMENT).
8. **Table inchangée** (naming strategy → mêmes tables). Aucune migration destructive.
9. **resolve_target_entities** final :
```
UserInterface -> App\Module\Core\Domain\Entity\User (existant)
ProjectInterface -> App\Module\ProjectManagement\Domain\Entity\Project
TaskInterface -> App\Module\ProjectManagement\Domain\Entity\Task
TaskTagInterface -> App\Module\ProjectManagement\Domain\Entity\TaskTag
ClientInterface -> App\Entity\Client (Client legacy jusqu'à 2.4)
```
---
## Tranche 1 — Découplage EN PLACE (entités non déplacées)
But : créer les contrats et basculer les consommateurs inter-modules, **sans déplacer** les entités → diff minimal, isole le risque architectural.
1. Créer les 4 interfaces dans `src/Shared/Domain/Contract/` (signatures ci-dessus).
2. `src/Entity/Project.php` `implements ProjectInterface` ; `Task.php` `implements TaskInterface` ; `TaskTag.php` `implements TaskTagInterface` ; `Client.php` `implements ClientInterface`. (Méthodes déjà présentes — juste `implements` + `use`.)
3. `Project.php` : `client` → type `?ClientInterface` (`targetEntity: ClientInterface::class`, import, getter/setter).
4. `src/Module/TimeTracking/Domain/Entity/TimeEntry.php` : `project`→`?ProjectInterface`, `task`→`?TaskInterface`, `tags`→`Collection<TaskTagInterface>` (`targetEntity` = interfaces, imports, getters/setters/addTag/removeTag). MAJ `TimeEntryRepositoryInterface`/`DoctrineTimeEntryRepository`/`ActiveTimeEntryProvider`/`TimeEntryExportController` si typage Project/Task.
5. `config/packages/doctrine.yaml` : ajouter les 4 lignes `resolve_target_entities` (cibles = `App\Entity\Project/Task/TaskTag` + `App\Entity\Client` — encore legacy à ce stade).
6. Vérif : `cache:clear` OK + `phpunit` vert. Commit `refactor(project-management) : introduce Project/Task/TaskTag/Client contracts, decouple TimeTracking`.
## Tranche 2 — Move mécanique vers le module
But : déplacer entités + écosystème, bascule namespaces, sans changement de comportement.
1. `git mv` entités → `src/Module/ProjectManagement/Domain/Entity/` (namespace `App\Module\ProjectManagement\Domain\Entity`). Relations intra-module = concret ; client=`ClientInterface` ; assignee/collaborators/uploadedBy=`UserInterface` (inchangé). `repositoryClass` → `DoctrineXxxRepository::class`.
2. `git mv` enums → `src/Module/ProjectManagement/Domain/Enum/` (namespace adapté).
3. Repos → `Infrastructure/Doctrine/DoctrineXxxRepository.php` + interfaces `Domain/Repository/XxxRepositoryInterface.php` (méthodes métier dans l'interface). Bindings `services.yaml` (9).
4. State (7), MCP (38), Controller (1), Services (2), Listeners (3), ApiResource SwitchWorkflowOutput → sous-dossiers `Infrastructure/…` du module, namespaces adaptés, **injecter les interfaces de repo**. `services.yaml` : repointer `App\State\TaskDocumentProcessor`, `App\Controller\TaskDocumentDownloadController`, `App\Mcp\Tool\Task\AddTaskDocumentTool`, `App\Mcp\Tool\Task\UpdateTaskDocumentTool`, `App\EventListener\TaskDocumentListener` vers les nouveaux FQCN (garder `$uploadDir` + tag `doctrine.orm.entity_listener`).
5. `resolve_target_entities` : repointer ProjectInterface/TaskInterface/TaskTagInterface vers les FQCN module. (ClientInterface reste `App\Entity\Client`.)
6. **Swap FQCN concret legacy** : remplacer `App\Entity\{Task,Project,Workflow,TaskStatus,TaskGroup,TaskEffort,TaskPriority,TaskTag,TaskRecurrence,TaskDocument}` → `App\Module\ProjectManagement\Domain\Entity\…` et `App\Enum\{StatusCategory,RecurrenceType}` → `App\Module\ProjectManagement\Domain\Enum\…` et `App\Repository\Xxx` → interfaces/Doctrine, dans : Serializer.php, Controller/Mail/*, State/Gitea*, State/BookStack*, ApiResource/BookStack*, Service/GiteaApiService.php, Entity/TaskMailLink.php, Entity/TaskBookStackLink.php, DataFixtures/AppFixtures.php, tests/*. (NE PAS toucher `App\Entity\Client`.)
7. `config/modules.php` : ajouter `ProjectManagementModule` (id `project-management`, label `Projets & Tâches`, isRequired false, permissions `project-management.projects.view/manage`, `project-management.tasks.view/manage` — non recâblées, additif).
8. `config/packages/doctrine.yaml` : mapping `ProjectManagement` (dir `src/Module/ProjectManagement/Domain/Entity`).
9. `config/sidebar.php` : `'module' => 'project-management'` sur items `my-tasks` et `projects`.
10. Vérif : `cache:clear` OK + `doctrine:schema:validate` mapping OK + `phpunit` vert + cs-fixer. Commit `feat(project-management) : migrate core Projects/Tasks domain into module (back)`.
## Tranche 3 — Timestampable additif (Task + Project)
1. Ajouter `TimestampableBlamableTrait` + interfaces à `Task` et `Project`.
2. Migration **additive** manuscrite : `created_at/updated_at` (TIMESTAMP(0) null), `created_by/updated_by` (INT null, FK `"user"` ON DELETE SET NULL) + index + COMMENT, sur `task` et `project`. `down()` = DROP des ajouts.
3. Champs hors groupes API existants (le trait porte ses propres groupes).
4. Vérif : `migrations:migrate -n` (dev+test) + `phpunit` vert. Commit `feat(project-management) : add timestampable/blamable to Task and Project (additive)`.
## Tranche 4 — Front layer project-management
1. `git mv` vers `frontend/modules/project-management/` : pages (my-tasks, projects/index, projects/[id]/{index,groups,archives}), components/{project,task}/*, services (projects, tasks, workflows, task-statuses, task-priorities, task-efforts, task-tags, task-groups, task-documents, task-recurrences) + services/dto/* correspondants. `nuxt.config.ts` = `export default defineNuxtConfig({})`.
2. Réécrire imports explicites `~/services/<x>` + `~/services/dto/<x>` → `~/modules/project-management/...` dans : les fichiers déplacés, `components/admin/{AdminEffortTab,AdminPriorityTab,AdminTagTab,AdminWorkflowTab,WorkflowDrawer}.vue`, `components/mail/{MailCreateTaskModal,MailLinkTaskModal}.vue`, `pages/index.vue`, `pages/mail.vue`, `app/layouts/default.vue`, **et `frontend/modules/time-tracking/`** (dto/time-entry, stores/timer, pages/time-tracking, components/TimeEntryDrawer importent project/task/task-tag dto). `clients.ts` reste racine.
3. Préserver routes `/my-tasks`, `/projects`, `/projects/:id`, `/projects/:id/groups`, `/projects/:id/archives`. i18n global inchangé.
4. Vérif : `cd frontend && npx nuxt build` OK + routes présentes. Commit `feat(project-management) : extract Projects/Tasks front into Nuxt module layer`.
## Critères d'acceptation (ticket)
- [ ] Cœur Projets/Tâches en module sans régression API (opérations/securities/uriTemplates conservés).
- [ ] Aucun import direct inter-modules **établis** (contrats) — legacy en transit toléré.
- [ ] `make test` vert, aucune migration destructive.
- [ ] Toggle module project-management (sidebar + routes) prouvé.
File diff suppressed because it is too large Load Diff
@@ -1,484 +0,0 @@
# Migration sidebar vers MalioSidebar — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Remplacer la sidebar maison de Lesstime par le composant `MalioSidebar` de `@malio/layer-ui`, en 3 groupes (Général / Outils / Administration), avec timer + version dans le footer et le logo Malio de Starseed.
**Architecture:** Modèle backend-driven conservé — `config/sidebar.php` filtré par `SidebarProvider` (permissions/rôles/modules côté serveur), exposé via `/api/sidebar`, consommé par `useSidebar()`. Le layout `default.vue` mappe ces sections vers le format `MalioSidebar` et fusionne les items contextuels rendus côté client (Kanban/Groupes/Archives, Documents, Mail+badge, Mes absences).
**Tech Stack:** Nuxt 4 (SPA), Vue 3 `<script setup>` TS, Pinia, `@malio/layer-ui` ^1.7.16, i18n (@nuxtjs/i18n), Symfony 8 / API Platform 4 (backend config PHP).
## Global Constraints
- **Ne jamais modifier `@malio/layer-ui`** (lib externe). Source de référence en lecture seule : `frontend/node_modules/@malio/layer-ui/app/components/malio/sidebar/Sidebar.vue`.
- `MalioSidebar` : props `sections` (requis), `modelValue` (v-model collapse bool), `sidebarClass`, `toggleClass`. Item = `{ label: string; to: string; exact?: boolean }` (pas d'icône ni de badge par item). Section = `{ label?: string; icon?: string; items: SidebarItem[] }`. Slots : `#logo`, `#logo-collapsed`, `#footer`, `#footer-collapsed`.
- **TypeScript strict** ; indentation **4 espaces** (frontend).
- Backend : `declare(strict_types=1)` en tête des fichiers PHP.
- Commits format projet : `type(scope) : message` (espaces autour du `:`), types autorisés minuscules (`feat`, `fix`, `refactor`, `chore`, …). **Ne committer que sur demande de l'utilisateur** (règle CLAUDE.md). Travailler sur une branche dédiée (pas directement sur `develop`).
- **Pas de runner de test frontend** dans ce projet → vérification par `npm run build` (Nuxt, échoue sur erreur TS/template) + **QA manuelle navigateur** (`make dev-nuxt`, port 3002). Ne PAS introduire de framework de test (hors scope).
- Décisions validées : 3 groupes ; badge mail = **suffixe `(N)`** sur le label.
## File Structure
- `config/sidebar.php`**Modify** : re-catégorisation en 3 sections.
- `frontend/i18n/locales/fr.json`**Modify** : clés de sections/items.
- `frontend/i18n/locales/*.json` (autres langues présentes) — **Modify si existantes** : mêmes clés.
- `frontend/public/LOGO_MALIO.png`**Create** (copie Starseed).
- `frontend/public/LOGO_MALIO_COLLAPSED.png`**Create** (copie Starseed).
- `frontend/app/layouts/default.vue`**Modify** : réécriture du template sidebar + logique `mergedSections`.
- `frontend/components/ui/SidebarLink.vue`**Possible delete** (si plus aucun usage après migration).
---
## Task 0 : Branche de travail
**Files:** aucun (git).
- [ ] **Step 1 : Créer la branche depuis `develop`**
```bash
cd /home/m-tristan/workspace/Lesstime
git checkout develop && git pull --ff-only
git checkout -b feat/malio-sidebar
```
Expected : sur la branche `feat/malio-sidebar`.
---
## Task 1 : Backend — re-catégorisation `config/sidebar.php` + i18n
**Files:**
- Modify: `config/sidebar.php`
- Modify: `frontend/i18n/locales/fr.json`
- Modify: autres `frontend/i18n/locales/*.json` si présentes (mêmes clés)
**Interfaces:**
- Produces : `/api/sidebar` renvoie des sections dont les `label` sont les clés `sidebar.general.section`, `sidebar.tools.section`, `sidebar.admin.section`. Items inchangés en `to` ; gates (`module`/`roles`/`permission`) inchangés, juste réorganisés.
- [ ] **Step 1 : Réécrire `config/sidebar.php` en 3 sections**
Remplacer le `return [...]` (lignes 20-44) par :
```php
return [
[
'label' => 'sidebar.general.section',
'icon' => 'mdi:view-dashboard-outline',
'items' => [
['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'],
['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline', 'module' => 'project-management', 'permission' => 'project-management.tasks.view'],
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline', 'module' => 'project-management', 'permission' => 'project-management.projects.view'],
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline', 'module' => 'time-tracking', 'permission' => 'time-tracking.entries.view'],
],
],
[
'label' => 'sidebar.tools.section',
'icon' => 'mdi:tools',
'items' => [
// Gating module uniquement : rendu visuel + badge non-lus gérés côté layout
// (filtré de translatedSections puis ré-injecté avec suffixe (N)).
['label' => 'sidebar.general.mail', 'to' => '/mail', 'icon' => 'mdi:email-outline', 'module' => 'mail'],
],
],
[
'label' => 'sidebar.admin.section',
'icon' => 'mdi:cog-outline',
'roles' => ['ROLE_ADMIN'],
'items' => [
['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline', 'module' => 'absence'],
['label' => 'sidebar.admin.directory', 'to' => '/directory', 'icon' => 'mdi:card-account-details-outline', 'module' => 'directory'],
['label' => 'sidebar.admin.reporting', 'to' => '/reporting', 'icon' => 'mdi:chart-line', 'module' => 'reporting', 'permission' => 'reporting.view'],
['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline', 'permission' => 'core.users.view'],
],
],
];
```
> Mettre aussi à jour le commentaire d'en-tête si nécessaire (le bloc décrivant Mail/contextuels reste valable).
- [ ] **Step 2 : Mettre à jour les clés i18n FR**
Dans `frontend/i18n/locales/fr.json`, bloc `sidebar` :
- `sidebar.general.section` : remplacer la valeur par `"Général"`.
- Ajouter `sidebar.tools.section` : `"Outils"`.
- Conserver `sidebar.general.dashboard|myTasks|projects|timeTracking|mail` et `sidebar.admin.*`.
- Ajouter les clés pour items client (utilisées en Task 3) :
- `sidebar.general.myAbsences` : `"Mes absences"`
- `sidebar.project.kanban` : `"Kanban"`
- `sidebar.project.groups` : `"Groupes"`
- `sidebar.project.archives` : `"Archives"`
Résultat attendu du bloc (extrait) :
```json
"sidebar": {
"general": {
"section": "Général",
"dashboard": "Tableau de bord",
"myTasks": "Mes tâches",
"projects": "Projets",
"timeTracking": "Suivi de temps",
"mail": "Messagerie",
"myAbsences": "Mes absences"
},
"tools": {
"section": "Outils"
},
"project": {
"kanban": "Kanban",
"groups": "Groupes",
"archives": "Archives"
},
"admin": {
"section": "Administration",
"teamAbsences": "Absences équipe",
"directory": "Répertoire",
"administration": "Administration",
"reporting": "Rapports"
}
}
```
- [ ] **Step 3 : Répliquer les clés dans les autres locales si présentes**
```bash
ls /home/m-tristan/workspace/Lesstime/frontend/i18n/locales/
```
Pour chaque fichier autre que `fr.json`, ajouter `tools.section`, `general.myAbsences`, `project.kanban|groups|archives` et ajuster `general.section`. S'il n'existe que `fr.json`, ne rien faire de plus.
- [ ] **Step 4 : Vérifier `/api/sidebar` (admin)**
```bash
docker exec -i php-lesstime-fpm php -r 'var_dump(require "/var/www/config/sidebar.php");' | head -5
```
Expected : le fichier PHP se parse sans erreur (3 entrées de premier niveau). (Le chemin exact dans le container peut différer — sinon, vérifier via `make cache-clear` qui échouerait sur une erreur de syntaxe PHP.)
```bash
make cache-clear
```
Expected : succès, pas d'erreur de parse.
- [ ] **Step 5 : Commit (sur demande utilisateur)**
```bash
git add config/sidebar.php frontend/i18n/locales/
git commit -m "refactor(sidebar) : re-catégorisation en 3 groupes (Général / Outils / Administration)"
```
---
## Task 2 : Frontend — assets logo
**Files:**
- Create: `frontend/public/LOGO_MALIO.png`
- Create: `frontend/public/LOGO_MALIO_COLLAPSED.png`
**Interfaces:**
- Produces : assets statiques servis à `/LOGO_MALIO.png` et `/LOGO_MALIO_COLLAPSED.png`.
- [ ] **Step 1 : Copier les logos depuis Starseed**
```bash
cp /home/m-tristan/workspace/Starseed/frontend/public/LOGO_MALIO.png \
/home/m-tristan/workspace/Lesstime/frontend/public/LOGO_MALIO.png
cp /home/m-tristan/workspace/Starseed/frontend/public/LOGO_MALIO_COLLAPSED.png \
/home/m-tristan/workspace/Lesstime/frontend/public/LOGO_MALIO_COLLAPSED.png
```
- [ ] **Step 2 : Vérifier**
```bash
ls -la /home/m-tristan/workspace/Lesstime/frontend/public/LOGO_MALIO*.png
```
Expected : deux fichiers présents (~5.8K et ~2.2K).
- [ ] **Step 3 : Commit (sur demande utilisateur)**
```bash
git add frontend/public/LOGO_MALIO.png frontend/public/LOGO_MALIO_COLLAPSED.png
git commit -m "chore(sidebar) : ajout des logos Malio (déplié / replié)"
```
---
## Task 3 : Frontend — migration du layout vers `MalioSidebar`
**Files:**
- Modify: `frontend/app/layouts/default.vue`
**Interfaces:**
- Consumes : `useSidebar().sections` (clés i18n des Task 1), `useUiStore().sidebarCollapsed`, `SidebarTimer` (`:collapsed`), `useAppVersion().version`, `useMailStore().globalUnreadCount`, `useShareStatus()`, `auth.user.isEmployee`, `auth.user.roles`, `useI18n().t`.
- Produces : layout rendant `<MalioSidebar>`.
> Ce task est une réécriture cohérente d'un seul fichier : la sidebar doit rester fonctionnelle (toutes features préservées) à la fin du task. On ne committe pas d'état intermédiaire cassé.
- [ ] **Step 1 : Remplacer le bloc `<aside>…</aside>` (lignes 13-104) par `<MalioSidebar>`**
Nouveau template de la zone sidebar (remplace l'overlay mobile lignes 5-11 **et** l'`<aside>`) :
```vue
<MalioSidebar
v-model="ui.sidebarCollapsed"
:sections="mergedSections"
:sidebar-class="ui.sidebarCollapsed ? '' : 'w-[232px]'"
>
<template #logo>
<img src="/LOGO_MALIO.png" alt="Malio"/>
</template>
<template #logo-collapsed>
<img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio"/>
</template>
<template #footer>
<div class="flex flex-col gap-2">
<SidebarTimer :collapsed="false" />
<p v-if="version" class="text-center text-sm font-bold">v {{ version }}</p>
</div>
</template>
<template #footer-collapsed>
<SidebarTimer :collapsed="true" />
</template>
</MalioSidebar>
```
Le bloc `<div class="h-full flex-1 …">` (AppTopNav + `<main>` + `<slot/>`) et le `<TimeEntryDrawer>` restent **inchangés**.
- [ ] **Step 2 : Remplacer la logique `translatedSections` par `mergedSections` dans le `<script setup>`**
Supprimer le computed `translatedSections` (lignes 144-156) et le remplacer par :
```ts
type MalioItem = { label: string; to: string; exact?: boolean }
type MalioSection = { label: string; icon: string; items: MalioItem[] }
// Ordre d'affichage canonique des sections.
const SECTION_ORDER = [
'sidebar.general.section',
'sidebar.tools.section',
'sidebar.admin.section',
] as const
// Icônes de secours pour les sections créées côté client (absentes du backend,
// ex. module mail off mais partage actif → section Outils à recréer).
const SECTION_ICON: Record<string, string> = {
'sidebar.general.section': 'mdi:view-dashboard-outline',
'sidebar.tools.section': 'mdi:tools',
'sidebar.admin.section': 'mdi:cog-outline',
}
// Items rendus côté client (dépendent d'un état runtime ignoré du backend).
function clientItemsFor(key: string): MalioItem[] {
if (key === 'sidebar.general.section') {
const items: MalioItem[] = []
if (currentProjectId.value) {
const id = currentProjectId.value
items.push({ label: t('sidebar.project.kanban'), to: `/projects/${id}`, exact: true })
items.push({ label: t('sidebar.project.groups'), to: `/projects/${id}/groups` })
items.push({ label: t('sidebar.project.archives'), to: `/projects/${id}/archives` })
}
if (isEmployee.value) {
items.push({ label: t('sidebar.general.myAbsences'), to: '/absences' })
}
return items
}
if (key === 'sidebar.tools.section') {
const items: MalioItem[] = []
if (isMailVisible.value) {
const n = mailStore.globalUnreadCount
const suffix = n > 0 ? ` (${n > 99 ? '99+' : n})` : ''
items.push({ label: `${t('mail.sidebar.title')}${suffix}`, to: '/mail' })
}
if (isDocumentsVisible.value) {
items.push({ label: t('sharedFiles.sidebar.title'), to: '/documents' })
}
return items
}
return []
}
const mergedSections = computed<MalioSection[]>(() => {
// 1. Sections backend (déjà filtrées par permissions), mail retiré (ré-injecté côté client).
const backend = new Map<string, MalioSection>()
for (const section of sections.value) {
backend.set(section.label, {
label: t(section.label),
icon: section.icon,
items: section.items
.filter((item) => item.to !== '/mail')
.map((item) => ({ label: t(item.label), to: item.to })),
})
}
// 2. Fusion dans l'ordre canonique.
const result: MalioSection[] = []
for (const key of SECTION_ORDER) {
const base = backend.get(key)
const extra = clientItemsFor(key)
if (base) {
base.items.push(...extra)
if (base.items.length > 0) {
result.push(base)
}
} else if (extra.length > 0) {
result.push({ label: t(key), icon: SECTION_ICON[key], items: extra })
}
}
// 3. Garde-fou : toute section backend hors ordre canonique n'est pas perdue.
for (const [key, section] of backend) {
if (!(SECTION_ORDER as readonly string[]).includes(key) && section.items.length > 0) {
result.push(section)
}
}
return result
})
```
> `isDocumentsVisible` existe déjà (ligne 166). `isMailVisible`, `isEmployee`, `currentProjectId`, `sections`, `mailStore`, `t`, `version`, `ui` sont déjà déclarés — ne pas les redéclarer.
- [ ] **Step 3 : Nettoyer le `<script>` et les imports devenus inutiles**
- Supprimer `sidebarIsCollapsed` (computed lignes 169-172) **si** plus utilisé après suppression de l'`<aside>` (l'était pour le rendu manuel). Vérifier qu'aucune autre référence ne subsiste :
```bash
grep -n "sidebarIsCollapsed" frontend/app/layouts/default.vue
```
S'il ne reste aucune occurrence hors déclaration, supprimer le computed.
- Conserver `watch(() => route.path, () => { ui.closeMobileSidebar() })` (fermeture mobile sur navigation).
- Vérifier que `SidebarLink` n'est plus référencé dans ce fichier (le composant Malio le remplace) :
```bash
grep -n "SidebarLink" frontend/app/layouts/default.vue
```
Expected : aucune occurrence.
- [ ] **Step 4 : Build de vérification**
```bash
cd /home/m-tristan/workspace/Lesstime/frontend && npm run build
```
Expected : build Nuxt réussi, **aucune erreur TypeScript** ni de template. (Si `mergedSections`/types invalides, le build échoue ici.)
- [ ] **Step 5 : QA manuelle (dev server)**
```bash
make dev-nuxt # port 3002
```
Vérifier en **admin** (`admin`/`admin`) :
- 3 groupes : Général, Outils, Administration.
- Général : Tableau de bord, Mes tâches, Projets, Suivi de temps.
- En ouvrant un projet (`/projects/<id>`) : Kanban/Groupes/Archives apparaissent dans Général ; Kanban actif uniquement sur la page kanban (exact).
- Outils : Messagerie (+ `(N)` si non-lus), Documents (si partage activé).
- Administration : Absences équipe, Répertoire, Rapports, Administration.
- Footer : timer cliquable (start/stop) + `v <version>` ; en replié, le timer reste (icône) et la version disparaît.
- Logo Malio déplié + replié (collapsed via toggle du composant).
- Route active surlignée ; pas de doublon `/mail`.
Vérifier en **utilisateur non-admin** (`alice`/`alice`) :
- **Pas** de groupe Administration.
- Items gated par permission absents si l'utilisateur n'a pas la permission.
- Mes absences visible uniquement si `isEmployee`.
- [ ] **Step 6 : Vérifier le comportement mobile (largeur < lg)**
Réduire la fenêtre / activer le responsive devtools.
- Vérifier l'ouverture/fermeture de la sidebar sur mobile.
- Vérifier le bouton hamburger éventuel de `AppTopNav` :
```bash
grep -rn "openMobileSidebar\|sidebarOpen\|closeMobileSidebar" frontend/app/components/ frontend/components/ frontend/app/layouts/default.vue
```
- Si `MalioSidebar` gère le responsive et que l'overlay supprimé n'est plus nécessaire : OK.
- Si l'ouverture mobile ne fonctionne plus (ex. AppTopNav appelait `openMobileSidebar` pour l'ancien overlay) : adapter **sans modifier la lib** — a minima conserver le repli/déploiement via `ui.sidebarCollapsed`, ou conserver un déclencheur. Documenter le choix retenu dans le commit.
- [ ] **Step 7 : Commit (sur demande utilisateur)**
```bash
git add frontend/app/layouts/default.vue
git commit -m "feat(sidebar) : migration du layout vers MalioSidebar (footer timer + version, logo Malio)"
```
---
## Task 4 : Nettoyage des éléments obsolètes
**Files:**
- Possible delete: `frontend/components/ui/SidebarLink.vue`
- Possible delete: anciens logos `frontend/public/malio.png`, `frontend/public/LOGO_CARRE.png`
**Interfaces:** aucun (suppression sûre uniquement si zéro référence).
- [ ] **Step 1 : Vérifier les usages restants de `SidebarLink`**
```bash
grep -rn "SidebarLink" /home/m-tristan/workspace/Lesstime/frontend --include="*.vue" --include="*.ts" | grep -v node_modules
```
- Si **aucune** occurrence : supprimer le fichier.
```bash
git rm frontend/components/ui/SidebarLink.vue
```
- Si encore référencé ailleurs : **ne pas supprimer**, laisser tel quel.
- [ ] **Step 2 : Vérifier les usages des anciens logos**
```bash
grep -rn "malio.png\|LOGO_CARRE.png" /home/m-tristan/workspace/Lesstime/frontend --include="*.vue" --include="*.ts" --include="*.css" | grep -v node_modules
```
- Si **aucune** occurrence : supprimer les deux PNG.
```bash
git rm frontend/public/malio.png frontend/public/LOGO_CARRE.png
```
- Sinon : conserver.
- [ ] **Step 3 : Build final**
```bash
cd /home/m-tristan/workspace/Lesstime/frontend && npm run build
```
Expected : build réussi.
- [ ] **Step 4 : Commit (sur demande utilisateur)**
```bash
git add -A
git commit -m "chore(sidebar) : suppression des composants/assets obsolètes de l'ancienne sidebar"
```
---
## Self-Review (auteur du plan)
**Spec coverage :**
- Remplacement par MalioSidebar → Task 3 ✓
- Permissions serveur préservées → Task 1 (gates inchangés) + Task 3 (mail filtré/ré-injecté, garde-fou sections) ✓
- 3 groupes Général/Outils/Administration → Task 1 + Task 3 (ordre canonique) ✓
- Footer timer + version → Task 3 Step 1 ✓
- Logo Malio Starseed → Task 2 + Task 3 ✓
- Items contextuels (Kanban/Groupes/Archives, Documents, Mes absences) → Task 3 `clientItemsFor`
- Badge mail = suffixe `(N)` → Task 3 `clientItemsFor`
- Mobile → Task 3 Step 6 ✓
- Nettoyage → Task 4 ✓
**Placeholder scan :** pas de TBD ; les branches conditionnelles de suppression (Task 4) et d'adaptation mobile (Task 3 Step 6) sont des décisions binaires basées sur un `grep`, pas des placeholders.
**Type consistency :** `MalioItem`/`MalioSection` définis une fois (Task 3) et utilisés de façon cohérente ; `clientItemsFor`/`mergedSections`/`SECTION_ORDER`/`SECTION_ICON` cohérents. Items produits conformes au type attendu par `MalioSidebar` (`{label, to, exact?}`).
**Réserve connue :** absence de runner de test FE → vérification par build + QA manuelle (assumé, conforme à l'état du repo).
@@ -1,203 +0,0 @@
# Répertoire — Contacts, Adresses & Rapports commerciaux
**Date :** 2026-06-22
**Module :** `Directory` (Lesstime)
**Statut :** Conception validée — prêt pour plan d'implémentation
## Contexte & objectif
Le module `Directory` gère aujourd'hui `Client` et `Prospect` de façon volontairement
minimaliste : champs à plat (`name`, `email`, `phone`, `street`, `city`, `postalCode`),
adresse *inline*, aucun contact individuel, aucun suivi commercial. Le CRUD se fait via
des drawers sur une page unique `/directory` à deux onglets, sans fiche détail.
On veut transformer chaque fiche client/prospect en une **vraie fiche détail à onglets**,
inspirée du répertoire de Starseed (blocs répétables, sauvegarde indépendante par onglet,
validation 422 inline), avec trois onglets : **Contact**, **Adresse**, **Rapport**.
Le « rapport commercial » est un **journal de comptes-rendus** (objet + texte + date +
type d'échange + auteur) auquel on peut **joindre des documents**.
Décisions cadrées avec l'utilisateur :
- Contacts et adresses : **plusieurs** par fiche (blocs répétables, façon Starseed).
- UX : **fiche détail à route dédiée** (le clic sur une ligne ouvre la fiche, plus le drawer).
- Rapport = **comptes-rendus** (objet + texte + date + type) **avec documents joints**.
- Conversion prospect → client : **tout est repris** (contacts, adresses, rapports).
- Cible : **Lesstime** (Starseed sert uniquement de référence de design).
## Approche retenue
**Entités partagées via double-FK** : `Contact`, `Address`, `CommercialReport` sont
chacune rattachées à **un `Client` OU un `Prospect`** via deux FK nullables
(`client_id?`, `prospect_id?`) + une contrainte CHECK « exactly-one ».
C'est le pattern **déjà employé par `task_document`** (`task_id` / `client_ticket_id` +
CHECK `task_id IS NOT NULL OR client_ticket_id IS NOT NULL`) — on reste donc cohérent
avec le code existant. La conversion prospect→client se réduit à une **réaffectation de
FK** (pas de copie), ce qui préserve l'historique.
Alternative écartée : entités dupliquées par propriétaire (`ClientContact` +
`ProspectContact`, etc.) → 2× plus de tables/code et conversion par recopie.
## Modèle de données (backend — `src/Module/Directory`)
Toutes les nouvelles entités vivent dans le module `Directory`
(`Domain/Entity`, `Domain/Repository`, `Domain/Enum`, `Infrastructure/Doctrine`,
`Infrastructure/ApiPlatform`), suivent les traits `TimestampableBlamableTrait` et
sont `#[Auditable]` comme `Client`/`Prospect`.
### `Contact` (répétable)
| Champ | Type | Notes |
|-------|------|-------|
| id | int PK | |
| firstName | string? | |
| lastName | string? | |
| jobTitle | string? | fonction |
| email | string? | lowercase |
| phonePrimary | string? | |
| phoneSecondary | string? | |
| client | ManyToOne Client? | FK `client_id`, ON DELETE CASCADE |
| prospect | ManyToOne Prospect? | FK `prospect_id`, ON DELETE CASCADE |
Contrainte CHECK : `client_id IS NOT NULL OR prospect_id IS NOT NULL` (et au plus un des
deux, garanti par la logique applicative + index). « Sans contrainte » fonctionnelle : un
contact est valide dès qu'il a au moins un nom **ou** prénom (validation souple, façon
`isContactNamed` de Starseed).
### `Address` (répétable)
| Champ | Type | Notes |
|-------|------|-------|
| id | int PK | |
| label | string? | libellé libre (« Siège », « Facturation »…) |
| street | string? | |
| streetComplement | string? | |
| postalCode | string? | |
| city | string? | |
| country | string | défaut `FR` |
| client / prospect | ManyToOne ?, FK CASCADE | double-FK + CHECK |
### `CommercialReport` (compte-rendu, répétable)
| Champ | Type | Notes |
|-------|------|-------|
| id | int PK | |
| subject | string | objet du compte-rendu |
| body | text | le compte-rendu lui-même |
| occurredAt | date | date de l'échange |
| type | enum `ReportType` | `call` / `meeting` / `email` / `note` |
| author | ManyToOne User? | rempli via Blamable (utilisateur connecté) |
| documents | OneToMany ReportDocument | pièces jointes (voir section dédiée) |
| client / prospect | ManyToOne ?, FK CASCADE | double-FK + CHECK |
`ReportType` (enum, libellés FR) : Appel, Rendez-vous, Email, Note.
### Migration de l'adresse *inline*
Les colonnes `street`, `city`, `postal_code` de `client` et `prospect` sont **migrées**
vers une première ligne `Address` (data migration : pour chaque client/prospect ayant une
adresse non vide, créer une `Address` rattachée), puis **supprimées** des tables
`client`/`prospect` pour ne pas dédoubler la donnée. Les champs `name`, `email`, `phone`
restent sur `Client`/`Prospect` (identité principale).
### Documents des comptes-rendus
> **Correction post-exploration :** contrairement à une première hypothèse, `task_document`
> n'a **aucune** colonne propriétaire générique. La migration `Version20260522110000`
> (suppression du portail client) a **retiré** `client_ticket_id` de `task_document` et
> restauré `task_id` en `NOT NULL`. Le `TaskDocumentProcessor` **exige** une tâche.
> « Réutiliser TaskDocument » impose donc de le **généraliser** (FK + processor), ce qui
> recouple `ProjectManagement``Directory`.
**Décision d'architecture (`ReportDocument` dédié — recommandé) :** créer une entité
`ReportDocument` **propre au module `Directory`**, qui réutilise le **même mécanisme de
stockage** (même paramètre `task_document_upload_dir`, mêmes validations MIME/taille, même
stratégie de download `BinaryFileResponse`), mais **sans** la mécanique SMB (inutile pour
des pièces jointes de compte-rendu). Cela préserve la frontière modulaire (pas de FK
croisée `ProjectManagement``Directory`) au prix d'une duplication maîtrisée du processor
et du controller de download (≈ 150 lignes, sans la partie SMB). Côté front, les composants
de preview/list de `ProjectManagement` sont **génériques** et réutilisés tels quels (ils ne
dépendent que du DTO document + de l'URL de download).
Entité `ReportDocument` (module `Directory`) : `id`, `commercialReport` (ManyToOne, FK
`commercial_report_id`, nullable:false, ON DELETE CASCADE), `originalName`, `fileName`,
`mimeType`, `size`, `createdAt`, `uploadedBy` (ManyToOne User, SET NULL). Endpoint
`POST /api/report_documents` (multipart, `deserialize:false`, `ReportDocumentProcessor`),
`GET /api/report_documents/{id}/download` (controller dédié, `priority: 1`),
`DELETE /api/report_documents/{id}` (listener `preRemove` qui `unlink` le fichier disque),
`GetCollection` filtrable par `commercialReport`.
## API Platform
Trois ressources (`Contact`, `Address`, `CommercialReport`) exposées avec :
- Opérations : `GetCollection`, `Get`, `Post`, `Patch`, `Delete`.
- Filtres : `SearchFilter` sur `client` et `prospect` (exact) pour charger la collection
d'une fiche donnée. Collections non paginées (aligné sur `Client`/`Prospect`).
- Sécurité : lecture `ROLE_USER`, écriture `ROLE_ADMIN` (pattern existant du module).
- Groupes de sérialisation : `contact:read`/`contact:write`, `address:read`/`address:write`,
`commercial_report:read`/`commercial_report:write`. `CommercialReport:read` embarque
`author` (id + username) et `documents`.
Permissions RBAC ajoutées au `Module::permissions()` :
`directory.reports.view`, `directory.reports.manage`. (Contacts/adresses couverts par
`directory.clients.*` / `directory.prospects.*` existants.)
## Conversion prospect → client
`ConvertProspectProcessor`
(`src/Module/Directory/Infrastructure/ApiPlatform/State/ConvertProspectProcessor.php`)
est étendu : après création/liaison du `Client`, pour chaque `Contact`, `Address` et
`CommercialReport` du prospect → set `client = <nouveau client>` et `prospect = null`.
Reste **idempotent** (si déjà converti, retourne inchangé). Les documents suivent
automatiquement (rattachés au `CommercialReport`, pas au prospect).
## Frontend (Nuxt — `frontend/modules/directory`)
### Liste & navigation
- `pages/directory.vue` (2 onglets Clients/Prospects, `MalioDataTable`) **reste**.
- Le clic sur une ligne ouvre désormais la **fiche détail** (`navigateTo`), au lieu du drawer.
- Le drawer (`ClientDrawer`/`ProspectDrawer`) est **conservé pour la création rapide**
(champs principaux : name/email/phone, + company/status/source/notes pour le prospect).
### Fiches détail
`pages/clients/[id].vue` et `pages/prospects/[id].vue` :
- En-tête : retour + titre + actions (archiver/supprimer selon droits).
- Bloc principal (identité : name/email/phone…), éditable en place.
- `MalioTabList` avec onglets **Contact**, **Adresse**, **Rapport** :
- **Contact** : `DirectoryContactBlock` répétable (ajout/suppression, sauvegarde par bloc
POST/PATCH, suppression = DELETE immédiat), validation 422 inline via `useFormErrors`.
- **Adresse** : `DirectoryAddressBlock` répétable, même mécanique.
- **Rapport** : liste des comptes-rendus (date, type badge, objet, auteur) + formulaire
d'ajout/édition (objet, type, date, corps) + zone documents (`ReportDocumentUpload` /
`ReportDocumentList`, calqués sur les composants `TaskDocument*` génériques).
Les blocs Contact/Adresse sont des composants **génériques** (mêmes pour client et prospect),
paramétrés par l'IRI du propriétaire (`client` ou `prospect`).
### Services & DTO
Nouveaux services `services/contacts.ts`, `services/addresses.ts`,
`services/commercial-reports.ts` (CRUD + filtre par owner) et DTO associés
(`dto/contact.ts`, `dto/address.ts`, `dto/commercial-report.ts`). Réutilisation du service
existant `task-documents.ts` via `uploadWithRelation('commercialReport', iri, file)`.
## i18n
Traductions FR ajoutées sous `directory.*` : libellés des onglets (Contact, Adresse,
Rapport), champs des trois entités, types de compte-rendu (Appel/Rendez-vous/Email/Note),
toasts de succès (créé/mis à jour/supprimé) et messages de validation.
## Tests (PHPUnit)
- Entités + contrainte CHECK double-FK (un contact/adresse/rapport ne peut être orphelin).
- Conversion : après convert, contacts/adresses/rapports du prospect pointent vers le
client (`prospect = null`), idempotence.
- Sécurité : lecture `ROLE_USER`, écriture refusée hors `ROLE_ADMIN`.
- Upload : un document peut être rattaché à un `CommercialReport` ; CHECK respecté.
- Data migration adresse inline → `Address` (au moins une adresse créée par client/prospect
ayant une adresse non vide).
> ⚠️ Base de test non isolée (les POST s'accumulent) : tester des **invariants**
> (relations, statuts, présence), pas des **counts absolus**.
## Hors périmètre (YAGNI)
- Pas de pipeline d'opportunités/affaires avec montants (le `status` du prospect suffit).
- Pas de dashboard/statistiques commerciales chiffrées.
- Pas de relance/prochaine action datée sur le compte-rendu (non retenu au cadrage).
- Pas de gestion de types d'adresse structurés (facturation/livraison) : `label` libre.
@@ -1,200 +0,0 @@
# Migration de la sidebar vers `MalioSidebar` (@malio/layer-ui)
**Date** : 2026-06-25
**Statut** : Design validé
**Scope** : Frontend (layout) + backend (config sidebar) + assets
## Contexte
La sidebar actuelle de Lesstime est un `<aside>` fait main dans
`frontend/app/layouts/default.vue`, qui itère sur les sections renvoyées par
`/api/sidebar` et rend chaque item via le composant maison `SidebarLink`. Le
timer et la version sont empilés en bas du `<aside>`, le toggle collapse et
l'overlay mobile sont gérés manuellement.
La librairie `@malio/layer-ui` (mise à jour) fournit désormais un composant
`MalioSidebar`. Le projet **Starseed** a déjà effectué cette migration sur une
architecture identique (`config/sidebar.php``SidebarProvider` → composable
`useSidebar` → layout). Cette spec applique la même migration à Lesstime, avec
trois spécificités Lesstime : footer (timer + version), re-catégorisation des
onglets, et plusieurs items contextuels rendus côté client.
On **ne modifie pas** la lib `@malio/layer-ui` (règle CLAUDE.md).
## Objectifs
1. Remplacer le `<aside>` maison par `<MalioSidebar>`.
2. Préserver le filtrage des permissions/rôles/modules **côté serveur**.
3. Re-catégoriser la navigation en 3 groupes : **Général / Outils / Administration**.
4. Mettre le timer et la version dans le **footer** du composant.
5. Reprendre le **logo Malio** de Starseed.
## Décisions validées
- **Catégorisation** : 3 groupes (option B).
- **Badge mail** : le compteur de non-lus devient un **suffixe sur le label**
(`Messagerie (3)`), faute de slot badge/icône par item dans `MalioSidebar`.
## Contraintes du composant `MalioSidebar`
Source : `frontend/node_modules/@malio/layer-ui/app/components/malio/sidebar/Sidebar.vue`.
- **Props** : `sections` (requis), `modelValue` (v-model collapse, bool),
`id`, `sidebarClass`, `toggleClass`.
- **Types** :
- `SidebarItem = { label: string; to: string; exact?: boolean }`
- `SidebarSection = { label?: string; icon?: string; items: SidebarItem[] }`
- **Slots** : `#logo`, `#logo-collapsed`, `#footer`, `#footer-collapsed`.
- **Events** : `update:modelValue(boolean)`.
- **Item** : pas d'icône par item ni de badge — uniquement l'icône de section.
Route active = match exact ou par préfixe (`exact: true` pour exact strict).
- Largeurs fixes : 232px (déplié) / 72px (replié). Toggle interne.
### Conséquences (compromis assumés)
- Perte de l'**icône par item** (design malioUI = texte + icône de section).
Starseed fonctionne ainsi.
- Le **badge mail** ne peut pas être une pastille → suffixe `(N)` dans le label.
## Architecture cible
Modèle **backend-driven** conservé (sécurité serveur intacte). Le frontend
mappe les sections renvoyées par `/api/sidebar` vers le format `MalioSidebar`
et **fusionne** les items contextuels (qui dépendent d'un état runtime non
connu du backend).
### 1. Backend — `config/sidebar.php`
Re-catégorisation en 3 sections (gates inchangés, juste réorganisés) :
```
GÉNÉRAL (sidebar.general.section, icon mdi:view-dashboard-outline)
Tableau de bord / —
Mes tâches /my-tasks module project-management, perm tasks.view
Projets /projects module project-management, perm projects.view
Suivi de temps /time-tracking module time-tracking, perm entries.view
OUTILS (sidebar.tools.section, icon mdi:tools)
Messagerie /mail module mail
(filtré du rendu backend côté front, ré-injecté avec badge)
ADMINISTRATION (sidebar.admin.section, icon mdi:cog-outline, roles [ROLE_ADMIN])
Absences équipe /team-absences module absence
Répertoire /directory module directory
Rapports /reporting module reporting, perm reporting.view
Administration /admin perm core.users.view
```
> `/mail` reste déclaré pour le gating module (`disabledRoutes`), mais est
> filtré des sections rendues et ré-injecté côté client avec son badge, comme
> aujourd'hui.
### 2. i18n — `frontend/i18n/locales/fr.json`
- Renommer `sidebar.general.section` : « Gestion de projet » → « Général ».
- Ajouter `sidebar.tools.section` : « Outils ».
- Conserver les clés d'items existantes. Items client : réutiliser les clés
existantes quand elles existent (`sharedFiles.sidebar.title` pour Documents,
`mail.sidebar.title`/`sidebar.general.mail` pour Messagerie) ; ajouter une
clé pour « Mes absences » (aujourd'hui en dur) et pour les contextuels
(Kanban/Groupes/Archives, aujourd'hui en dur) si on souhaite les traduire,
sinon conserver les libellés en dur actuels.
### 3. Frontend — `frontend/app/layouts/default.vue`
Réécriture du template autour de `<MalioSidebar>` :
```vue
<MalioSidebar v-model="ui.sidebarCollapsed" :sections="mergedSections"
:sidebar-class="ui.sidebarCollapsed ? '' : 'w-[232px]'">
<template #logo> <img src="/LOGO_MALIO.png" alt="Malio"/></template>
<template #logo-collapsed> <img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio"/></template>
<template #footer>
<SidebarTimer :collapsed="false" />
<p class="font-bold">v {{ version }}</p>
</template>
<template #footer-collapsed>
<SidebarTimer :collapsed="true" />
</template>
</MalioSidebar>
```
**Computed `mergedSections`** : construit les sections finales dans l'ordre
canonique `[général, outils, administration]`.
Logique de fusion :
1. Partir des sections backend (déjà filtrées), mappées en
`{ label: t(label), icon, items: items.filter(to !== '/mail').map({label: t, to}) }`.
2. Définir une table `clientItems` indexée par clé de section :
- `sidebar.general.section` → (si `currentProjectId`) Kanban (`exact`),
Groupes, Archives ; puis (si `isEmployee`) Mes absences.
- `sidebar.tools.section` → (si `isMailVisible`) Messagerie avec label
`Messagerie` + suffixe `(N)` quand `mailStore.globalUnreadCount > 0`
(`99+` au-delà) ; puis (si `shareEnabled`) Documents.
3. Pour chaque section backend, **append** ses items client.
4. Si une clé de `clientItems` produit des items mais que la section
correspondante n'est **pas** présente dans la réponse backend (ex. module
mail off mais partage on → pas de section « Outils » côté backend), **créer**
la section côté front (label + icône depuis une table locale).
5. **Supprimer** les sections finales sans items.
6. Trier selon l'ordre canonique des clés.
Le reste du `<script>` (timer title watchers, `refData`/`TimeEntryDrawer`,
polling mail, `ensureShareStatus`, `currentProjectId`, `isEmployee`,
`isMailVisible`, `shareEnabled`) est **conservé tel quel**.
### 4. Mobile
Starseed a **supprimé l'overlay mobile custom** et ne garde que
`watch(route) → ui.closeMobileSidebar()`. On s'aligne : suppression du markup
overlay (`ui.sidebarOpen`, `.sidebar-overlay`) si `MalioSidebar` gère le
responsive. **À vérifier à l'implémentation** : comportement mobile réel du
composant ; si l'ouverture mobile n'est pas couverte, adapter a minima sans
modifier la lib.
### 5. Assets — logo
Copier depuis Starseed vers `frontend/public/` :
- `LOGO_MALIO.png` (128×44)
- `LOGO_MALIO_COLLAPSED.png` (34×40)
Les anciens `/malio.png` et `/LOGO_CARRE.png` ne sont plus référencés par le
layout (les laisser ou les retirer si plus aucun usage — à vérifier).
## Composants / éléments réutilisés
- `SidebarTimer` (`components/ui/SidebarTimer.vue`) : inchangé, déjà piloté par
`:collapsed`.
- `useAppVersion()` : inchangé.
- `useSidebar()` : inchangé.
- `usePermissions()` : inchangé (le filtrage permission reste backend ; les
flags client `isEmployee`/`isMailVisible`/`shareEnabled` restent locaux).
## Éléments supprimés
- Le `<aside>` manuel et son markup (logo, nav, toggle, overlay) dans
`default.vue`.
- L'usage de `SidebarLink` dans le layout (le composant peut rester s'il est
utilisé ailleurs — à vérifier ; sinon suppression possible).
## Critères d'acceptation
1. La sidebar est rendue par `<MalioSidebar>`.
2. 3 groupes : Général, Outils, Administration (Administration visible
uniquement pour `ROLE_ADMIN` / permissions, comme avant).
3. Toutes les permissions/rôles/modules sont respectés à l'identique (aucune
régression de visibilité pour user/admin).
4. Items contextuels présents : Kanban/Groupes/Archives (dans un projet),
Documents (partage activé), Mes absences (employé).
5. Messagerie affiche `(N)` quand il y a des non-lus.
6. Footer : timer fonctionnel + version (version masquée en replié).
7. Logo Malio de Starseed affiché (déplié + replié).
8. Collapse/expand et route active fonctionnent.
9. Pas de doublon `/mail`. Pas de section vide affichée.
10. Build Nuxt OK, pas d'erreur TS.
## Hors scope
- Refonte du `SiteSelector` (n'existe pas dans Lesstime).
- Modification de la lib `@malio/layer-ui`.
- Changement du modèle de permissions backend.
+138 -128
View File
@@ -1,31 +1,112 @@
<template>
<div class="h-screen overflow-hidden">
<div class="flex h-full">
<MalioSidebar
v-model="ui.sidebarCollapsed"
:sections="mergedSections"
:sidebar-class="ui.sidebarCollapsed ? '' : 'w-[232px]'"
<!-- Mobile sidebar overlay -->
<Transition name="sidebar-overlay">
<div
v-if="ui.sidebarOpen"
class="fixed inset-0 z-40 bg-black/50 lg:hidden"
@click="ui.closeMobileSidebar()"
/>
</Transition>
<aside
class="fixed inset-y-0 left-0 z-50 flex h-full flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500 transition-transform duration-300 lg:static lg:z-auto lg:translate-x-0"
:class="[
ui.sidebarCollapsed ? 'lg:w-16' : 'lg:w-64',
ui.sidebarOpen ? 'w-64 translate-x-0' : '-translate-x-full',
]"
>
<template #logo>
<img src="/LOGO_MALIO.png" alt="Malio"/>
</template>
<template #logo-collapsed>
<img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio"/>
</template>
<template #footer>
<div class="flex flex-col gap-2">
<SidebarTimer :collapsed="false" />
<p v-if="version" class="text-center text-sm font-bold">v {{ version }}</p>
</div>
</template>
<template #footer-collapsed>
<SidebarTimer :collapsed="true" />
</template>
</MalioSidebar>
<div class="flex items-center overflow-hidden" :class="sidebarIsCollapsed ? 'justify-center p-3' : 'justify-between'">
<img
v-if="!sidebarIsCollapsed"
src="/malio.png"
alt="Logo"
class="w-auto"
/>
<img
v-else
src="/LOGO_CARRE.png"
alt="Logo"
class="w-[46px] h-[55px]"
/>
<button
class="mr-2 rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:hidden"
@click="ui.closeMobileSidebar()"
>
<Icon name="mdi:close" size="20" />
</button>
</div>
<nav class="flex-1 overflow-hidden" :class="sidebarIsCollapsed ? 'px-1 pb-6' : 'px-4 pb-6'">
<!-- Sections dynamiques (/api/sidebar) : navigation globale + sections gated par rôle -->
<template v-for="(section, sIndex) in translatedSections" :key="section.label">
<p v-if="!sidebarIsCollapsed" class="px-4 pt-5 pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
{{ section.label }}
</p>
<div v-else class="mx-2 my-3 border-t border-secondary-500" />
<SidebarLink
v-for="item in section.items"
:key="item.to"
:to="item.to"
:icon="item.icon"
:label="item.label"
:collapsed="sidebarIsCollapsed"
@click="ui.closeMobileSidebar()"
/>
<!-- Items conservés côté client, insérés après la 1re section (cf. décision 3) -->
<template v-if="sIndex === 0">
<!-- Contextuel projet -->
<template v-if="currentProjectId">
<SidebarLink :to="`/projects/${currentProjectId}`" icon="mdi:view-column-outline" label="Kanban" :collapsed="sidebarIsCollapsed" sub exact @click="ui.closeMobileSidebar()" />
<SidebarLink :to="`/projects/${currentProjectId}/groups`" icon="mdi:tag-multiple-outline" label="Groupes" :collapsed="sidebarIsCollapsed" sub @click="ui.closeMobileSidebar()" />
<SidebarLink :to="`/projects/${currentProjectId}/archives`" icon="mdi:archive-outline" label="Archives" :collapsed="sidebarIsCollapsed" sub @click="ui.closeMobileSidebar()" />
</template>
<!-- Feature-flag : Documents -->
<SidebarLink v-if="isDocumentsVisible" to="/documents" icon="mdi:folder-network-outline" :label="$t('sharedFiles.sidebar.title')" :collapsed="sidebarIsCollapsed" @click="ui.closeMobileSidebar()" />
<!-- Feature-flag : Mail + badge -->
<div v-if="isMailVisible" class="relative">
<SidebarLink to="/mail" icon="mdi:email-outline" :label="$t('mail.sidebar.title')" :collapsed="sidebarIsCollapsed" @click="ui.closeMobileSidebar()" />
<span
v-if="mailStore.globalUnreadCount > 0"
class="pointer-events-none absolute right-3 top-1/2 flex h-5 min-w-5 -translate-y-1/2 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
:class="{ 'right-1 top-1 translate-y-0': sidebarIsCollapsed }"
:aria-label="`${mailStore.globalUnreadCount} messages non lus`"
>
{{ mailStore.globalUnreadCount > 99 ? '99+' : mailStore.globalUnreadCount }}
</span>
</div>
<!-- User-flag : Mes absences (isEmployee non couvert par le gate rôle) -->
<SidebarLink v-if="isEmployee" to="/absences" icon="mdi:umbrella-beach-outline" label="Mes absences" :collapsed="sidebarIsCollapsed" @click="ui.closeMobileSidebar()" />
</template>
</template>
</nav>
<div class="px-4 py-3">
<SidebarTimer :collapsed="sidebarIsCollapsed" />
</div>
<div class="flex items-center justify-center p-4">
<p v-if="!sidebarIsCollapsed" class="font-bold">v {{ version }}</p>
</div>
<!-- Collapse toggle button centered vertically on the sidebar edge -->
<button
class="absolute top-1/2 -right-4 z-10 hidden h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full border border-neutral-200 bg-white text-neutral-400 shadow-sm hover:text-neutral-700 transition-colors lg:flex"
:title="ui.sidebarCollapsed ? 'Ouvrir le menu' : 'Réduire le menu'"
@click="ui.toggleSidebar()"
>
<Icon
:name="ui.sidebarCollapsed ? 'mdi:chevron-right' : 'mdi:chevron-left'"
size="18"
/>
</button>
</aside>
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
<AppTopNav :user="auth.user" />
<main class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-24 sm:px-6 lg:px-12 xl:px-11">
<main class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-24 sm:px-8 lg:px-16">
<div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12" />
<slot/>
</main>
</div>
@@ -44,8 +125,8 @@
<script setup lang="ts">
import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/modules/project-management/services/dto/project'
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
import type { Project } from '~/services/dto/project'
import type { TaskTag } from '~/services/dto/task-tag'
import { useAppVersion } from '~/composables/useAppVersion'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
@@ -58,6 +139,18 @@ const route = useRoute()
const { t } = useI18n()
const { sections } = useSidebar()
const translatedSections = computed(() =>
sections.value.map((section) => ({
label: t(section.label),
icon: section.icon,
items: section.items.map((item) => ({
label: t(item.label),
to: item.to,
icon: item.icon,
})),
})),
)
const isEmployee = computed(() => Boolean(auth.user?.isEmployee))
const isMailVisible = computed(() => {
@@ -68,116 +161,22 @@ const isMailVisible = computed(() => {
const { enabled: shareEnabled, ensureLoaded: ensureShareStatus } = useShareStatus()
const isDocumentsVisible = computed(() => shareEnabled.value === true)
// On mobile, sidebar is always expanded (not collapsed icon mode)
const sidebarIsCollapsed = computed(() => {
if (ui.sidebarOpen) return false
return ui.sidebarCollapsed
})
// Close mobile sidebar on route change
watch(() => route.path, () => {
ui.closeMobileSidebar()
})
const currentProjectId = computed(() => {
const match = route.path.match(/^\/projects\/(\d+)/)
return match ? match[1] : null
})
type MalioItem = { label: string; to: string; exact?: boolean }
type MalioSection = { label: string; icon: string; items: MalioItem[] }
// Ordre d'affichage canonique des sections.
const SECTION_ORDER = [
'sidebar.general.section',
'sidebar.tools.section',
'sidebar.admin.section',
] as const
// Icônes de secours pour les sections créées côté client (absentes du backend,
// ex. module mail off mais partage actif section Outils à recréer).
const SECTION_ICON: Record<string, string> = {
'sidebar.general.section': 'mdi:view-dashboard-outline',
'sidebar.tools.section': 'mdi:tools',
'sidebar.admin.section': 'mdi:cog-outline',
}
// Item client avec ancre optionnelle : `after` = `to` de l'item après lequel l'insérer
// (sinon ajouté en fin de section).
type ClientItem = MalioItem & { after?: string }
// Items rendus côté client (dépendent d'un état runtime ignoré du backend).
function clientItemsFor(key: string): ClientItem[] {
if (key === 'sidebar.general.section') {
const items: ClientItem[] = []
if (currentProjectId.value) {
const id = currentProjectId.value
// Insérés juste sous « Projets », dans l'ordre via ancres chaînées.
items.push({ label: t('sidebar.project.kanban'), to: `/projects/${id}`, exact: true, after: '/projects' })
items.push({ label: t('sidebar.project.groups'), to: `/projects/${id}/groups`, after: `/projects/${id}` })
items.push({ label: t('sidebar.project.archives'), to: `/projects/${id}/archives`, after: `/projects/${id}/groups` })
}
if (isEmployee.value) {
items.push({ label: t('sidebar.general.myAbsences'), to: '/absences' })
}
return items
}
if (key === 'sidebar.tools.section') {
const items: ClientItem[] = []
if (isMailVisible.value) {
const n = mailStore.globalUnreadCount
const suffix = n > 0 ? ` (${n > 99 ? '99+' : n})` : ''
items.push({ label: `${t('mail.sidebar.title')}${suffix}`, to: '/mail' })
}
if (isDocumentsVisible.value) {
items.push({ label: t('sharedFiles.sidebar.title'), to: '/documents' })
}
return items
}
return []
}
// Insère les items client après leur ancre (`after`), sinon en fin de liste.
function mergeClientItems(base: MalioItem[], extra: ClientItem[]): MalioItem[] {
const result = [...base]
for (const { after, ...item } of extra) {
const idx = after ? result.findIndex((i) => i.to === after) : -1
if (idx !== -1) {
result.splice(idx + 1, 0, item)
} else {
result.push(item)
}
}
return result
}
const mergedSections = computed<MalioSection[]>(() => {
// 1. Sections backend (déjà filtrées par permissions), mail retiré (ré-injecté côté client).
const backend = new Map<string, MalioSection>()
for (const section of sections.value) {
backend.set(section.label, {
label: t(section.label),
icon: section.icon,
items: section.items
.filter((item) => item.to !== '/mail')
.map((item) => ({ label: t(item.label), to: item.to })),
})
}
// 2. Fusion dans l'ordre canonique.
const result: MalioSection[] = []
for (const key of SECTION_ORDER) {
const base = backend.get(key)
const extra = clientItemsFor(key)
if (base) {
base.items = mergeClientItems(base.items, extra)
if (base.items.length > 0) {
result.push(base)
}
} else if (extra.length > 0) {
result.push({ label: t(key), icon: SECTION_ICON[key] ?? '', items: mergeClientItems([], extra) })
}
}
// 3. Garde-fou : toute section backend hors ordre canonique n'est pas perdue.
for (const [key, section] of backend) {
if (!(SECTION_ORDER as readonly string[]).includes(key) && section.items.length > 0) {
result.push(section)
}
}
return result
})
const timerStore = useTimerStore()
const baseTitle = ref('Lesstime')
@@ -265,3 +264,14 @@ function onCompleteSaved() {
})
}
</script>
<style scoped>
.sidebar-overlay-enter-active,
.sidebar-overlay-leave-active {
transition: opacity 0.3s ease;
}
.sidebar-overlay-enter-from,
.sidebar-overlay-leave-to {
opacity: 0;
}
</style>
@@ -19,8 +19,8 @@
</template>
<script setup lang="ts">
import type { AbsenceBalance } from '~/modules/absence/services/dto/absence'
import { useAbsenceService } from '~/modules/absence/services/absences'
import type { AbsenceBalance } from '~/services/dto/absence'
import { useAbsenceService } from '~/services/absences'
const props = defineProps<{
modelValue: boolean
@@ -73,7 +73,8 @@
</template>
<script setup lang="ts">
import type { AbsenceBalance } from '~/modules/absence/services/dto/absence'
import type { AbsenceBalance } from '~/services/dto/absence'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
const props = defineProps<{
balances: AbsenceBalance[]
@@ -52,8 +52,9 @@
</template>
<script setup lang="ts">
import type { AbsenceRequest } from '~/modules/absence/services/dto/absence'
import { useAbsenceService } from '~/modules/absence/services/absences'
import type { AbsenceRequest } from '~/services/dto/absence'
import { useAbsenceService } from '~/services/absences'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
const props = defineProps<{
absences: AbsenceRequest[]
@@ -29,7 +29,7 @@
</template>
<script setup lang="ts">
import type { HalfDay } from '~/modules/absence/services/dto/absence'
import type { HalfDay } from '~/services/dto/absence'
const props = withDefaults(defineProps<{
/** ISO date string "YYYY-MM-DD" or null. */
@@ -135,8 +135,9 @@
</template>
<script setup lang="ts">
import type { AbsenceRequest } from '~/modules/absence/services/dto/absence'
import { useAbsenceService } from '~/modules/absence/services/absences'
import type { AbsenceRequest } from '~/services/dto/absence'
import { useAbsenceService } from '~/services/absences'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
const props = defineProps<{
modelValue: boolean
@@ -26,8 +26,9 @@
</template>
<script setup lang="ts">
import type { AbsenceRequest } from '~/modules/absence/services/dto/absence'
import { useAbsenceService } from '~/modules/absence/services/absences'
import type { AbsenceRequest } from '~/services/dto/absence'
import { useAbsenceService } from '~/services/absences'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
const props = defineProps<{
modelValue: boolean
@@ -105,8 +105,9 @@
</template>
<script setup lang="ts">
import type { AbsencePolicy, AbsencePreviewResult, AbsenceType, HalfDay } from '~/modules/absence/services/dto/absence'
import { useAbsenceService } from '~/modules/absence/services/absences'
import type { AbsencePolicy, AbsencePreviewResult, AbsenceType, HalfDay } from '~/services/dto/absence'
import { useAbsenceService } from '~/services/absences'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
const props = defineProps<{
modelValue: boolean
@@ -51,8 +51,8 @@
</template>
<script setup lang="ts">
import type { AbsencePolicy } from '~/modules/absence/services/dto/absence'
import { useAbsenceService } from '~/modules/absence/services/absences'
import type { AbsencePolicy } from '~/services/dto/absence'
import { useAbsenceService } from '~/services/absences'
const service = useAbsenceService()
const rows = ref<AbsencePolicy[]>([])
-160
View File
@@ -1,160 +0,0 @@
<template>
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">{{ $t('admin.audit.title') }}</h2>
</div>
<div class="mt-4 flex flex-wrap gap-4">
<MalioSelect
v-model="entityTypeFilter"
:options="entityTypeOptions"
:label="$t('admin.audit.filterEntityType')"
:empty-option-label="$t('admin.audit.filterEntityTypeAll')"
group-class="w-64"
/>
<MalioSelect
v-model="actionFilter"
:options="actionOptions"
:label="$t('admin.audit.filterAction')"
:empty-option-label="$t('admin.audit.filterActionAll')"
group-class="w-64"
/>
</div>
<DataTable
:columns="columns"
:items="rows"
:loading="isLoading"
:empty-message="$t('admin.audit.empty')"
>
<template #cell-performedAt="{ item }">
{{ formatDate(item.performedAt) }}
</template>
<template #cell-entityType="{ item }">
{{ entityTypeLabel(item.entityType) }}
</template>
<template #cell-action="{ item }">
{{ actionLabel(item.action) }}
</template>
</DataTable>
<div class="mt-4 flex items-center justify-between">
<span class="text-sm text-neutral-500">{{ $t('admin.audit.page', { page }) }}</span>
<div class="flex gap-2">
<MalioButton
variant="secondary"
button-class="w-auto px-4"
:label="$t('admin.audit.previous')"
:disabled="page <= 1 || isLoading"
@click="goToPage(page - 1)"
/>
<MalioButton
variant="secondary"
button-class="w-auto px-4"
:label="$t('admin.audit.next')"
:disabled="!hasNextPage || isLoading"
@click="goToPage(page + 1)"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { AuditLogAction, AuditLogItem } from '~/modules/core/services/audit-logs'
import { useAuditLogService } from '~/modules/core/services/audit-logs'
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
const { t, te } = useI18n()
const PAGE_SIZE = 30
const columns = computed<DataTableColumn[]>(() => [
{ key: 'performedAt', label: t('admin.audit.date'), primary: true },
{ key: 'performedBy', label: t('admin.audit.performedBy') },
{ key: 'entityType', label: t('admin.audit.entityType') },
{ key: 'action', label: t('admin.audit.action') },
{ key: 'entityId', label: t('admin.audit.entityId') },
])
const actionOptions = computed<{ value: AuditLogAction, label: string }[]>(() => [
{ value: 'create', label: t('audit.action.create') },
{ value: 'update', label: t('audit.action.update') },
{ value: 'delete', label: t('audit.action.delete') },
])
const auditLogService = useAuditLogService()
const rows = ref<AuditLogItem[]>([])
const entityTypes = ref<string[]>([])
const totalItems = ref(0)
const page = ref(1)
const isLoading = ref(true)
const entityTypeFilter = ref<string | null>(null)
const actionFilter = ref<AuditLogAction | null>(null)
const entityTypeOptions = computed<{ value: string, label: string }[]>(() =>
entityTypes.value.map((value) => ({ value, label: entityTypeLabel(value) })),
)
// PAGE_SIZE must match the API default page size. The full-page guard keeps the
// "next" button accurate even on the last (partial) page.
const hasNextPage = computed(() => rows.value.length >= PAGE_SIZE && page.value * PAGE_SIZE < totalItems.value)
function entityTypeLabel(value: string): string {
const key = `audit.entity.${value}`
return te(key) ? t(key) : value
}
function actionLabel(action: AuditLogAction): string {
return t(`audit.action.${action}`)
}
function formatDate(value: string): string {
return new Date(value).toLocaleString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
async function loadItems() {
isLoading.value = true
try {
const result = await auditLogService.list({
page: page.value,
entityType: entityTypeFilter.value ?? undefined,
action: actionFilter.value ?? undefined,
})
rows.value = result.items
totalItems.value = result.totalItems
} finally {
isLoading.value = false
}
}
async function loadEntityTypes() {
entityTypes.value = await auditLogService.entityTypes()
}
function goToPage(target: number) {
if (target < 1) {
return
}
page.value = target
loadItems()
}
watch([entityTypeFilter, actionFilter], () => {
page.value = 1
loadItems()
})
onMounted(() => {
loadItems()
loadEntityTypes()
})
</script>
@@ -51,7 +51,7 @@
</template>
<script setup lang="ts">
import { useBookStackService } from '~/modules/integration/services/bookstack'
import { useBookStackService } from '~/services/bookstack'
const { getSettings, saveSettings, testConnection } = useBookStackService()
+2 -2
View File
@@ -40,8 +40,8 @@
</template>
<script setup lang="ts">
import type { Client } from '~/modules/directory/services/dto/client'
import { useClientService } from '~/modules/directory/services/clients'
import type { Client } from '~/services/dto/client'
import { useClientService } from '~/services/clients'
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
+2 -2
View File
@@ -30,8 +30,8 @@
</template>
<script setup lang="ts">
import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort'
import { useTaskEffortService } from '~/modules/project-management/services/task-efforts'
import type { TaskEffort } from '~/services/dto/task-effort'
import { useTaskEffortService } from '~/services/task-efforts'
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
+1 -1
View File
@@ -45,7 +45,7 @@
</template>
<script setup lang="ts">
import { useGiteaService } from '~/modules/integration/services/gitea'
import { useGiteaService } from '~/services/gitea'
const { getSettings, saveSettings, testConnection } = useGiteaService()
+1 -1
View File
@@ -140,7 +140,7 @@
</template>
<script setup lang="ts">
import { useMailService } from '~/modules/mail/services/mail'
import { useMailService } from '~/services/mail'
const { getConfiguration, updateConfiguration, testConfiguration } = useMailService()
@@ -37,8 +37,8 @@
</template>
<script setup lang="ts">
import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority'
import { useTaskPriorityService } from '~/modules/project-management/services/task-priorities'
import type { TaskPriority } from '~/services/dto/task-priority'
import { useTaskPriorityService } from '~/services/task-priorities'
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
+1 -1
View File
@@ -70,7 +70,7 @@
</template>
<script setup lang="ts">
import { useShareSettingsService } from '~/modules/integration/services/share-settings'
import { useShareSettingsService } from '~/services/share-settings'
const { getSettings, saveSettings, testConnection } = useShareSettingsService()
+2 -2
View File
@@ -37,8 +37,8 @@
</template>
<script setup lang="ts">
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
import { useTaskTagService } from '~/modules/project-management/services/task-tags'
import type { TaskTag } from '~/services/dto/task-tag'
import { useTaskTagService } from '~/services/task-tags'
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
@@ -42,8 +42,8 @@
</template>
<script setup lang="ts">
import type { Workflow } from '~/modules/project-management/services/dto/workflow'
import { useWorkflowService } from '~/modules/project-management/services/workflows'
import type { Workflow } from '~/services/dto/workflow'
import { useWorkflowService } from '~/services/workflows'
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
const { t } = useI18n()
+1 -1
View File
@@ -58,7 +58,7 @@
</template>
<script setup lang="ts">
import { useZimbraService } from '~/modules/integration/services/zimbra'
import { useZimbraService } from '~/services/zimbra'
const { getSettings, saveSettings, testConnection } = useZimbraService()
+5 -5
View File
@@ -96,11 +96,11 @@
</template>
<script setup lang="ts">
import type { Workflow, StatusCategory } from '~/modules/project-management/services/dto/workflow'
import { STATUS_CATEGORY_COLOR } from '~/modules/project-management/services/dto/workflow'
import type { TaskStatusWrite } from '~/modules/project-management/services/dto/task-status'
import { useWorkflowService } from '~/modules/project-management/services/workflows'
import { useTaskStatusService } from '~/modules/project-management/services/task-statuses'
import type { Workflow, StatusCategory } from '~/services/dto/workflow'
import { STATUS_CATEGORY_COLOR } from '~/services/dto/workflow'
import type { TaskStatusWrite } from '~/services/dto/task-status'
import { useWorkflowService } from '~/services/workflows'
import { useTaskStatusService } from '~/services/task-statuses'
const { t } = useI18n()
@@ -6,11 +6,36 @@
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.name"
label="Nom société"
label="Nom"
input-class="w-full"
:error="touched.name && !form.name.trim() ? 'Le nom est requis' : ''"
@blur="touched.name = true"
/>
<MalioInputText
v-model="form.email"
label="Email"
input-class="w-full"
/>
<MalioInputText
v-model="form.phone"
label="Téléphone"
input-class="w-full"
/>
<MalioInputText
v-model="form.street"
label="Rue"
input-class="w-full"
/>
<MalioInputText
v-model="form.city"
label="Ville"
input-class="w-full"
/>
<MalioInputText
v-model="form.postalCode"
label="Code Postal"
input-class="w-full"
/>
<div class="mt-6 flex justify-end">
<MalioButton
@@ -25,8 +50,8 @@
</template>
<script setup lang="ts">
import type { Client, ClientWrite } from '~/modules/directory/services/dto/client'
import { useClientService } from '~/modules/directory/services/clients'
import type { Client, ClientWrite } from '~/services/dto/client'
import { useClientService } from '~/services/clients'
const props = defineProps<{
modelValue: boolean
@@ -48,16 +73,37 @@ const isSubmitting = ref(false)
const form = reactive({
name: '',
email: '',
phone: '',
street: '',
city: '',
postalCode: '',
})
const touched = reactive({
name: false,
email: false,
})
watch(() => props.modelValue, (open) => {
if (open) {
form.name = props.client?.name ?? ''
if (props.client) {
form.name = props.client.name ?? ''
form.email = props.client.email ?? ''
form.phone = props.client.phone ?? ''
form.street = props.client.street ?? ''
form.city = props.client.city ?? ''
form.postalCode = props.client.postalCode ?? ''
} else {
form.name = ''
form.email = ''
form.phone = ''
form.street = ''
form.city = ''
form.postalCode = ''
}
touched.name = false
touched.email = false
}
})
@@ -71,6 +117,11 @@ async function handleSubmit() {
try {
const payload: ClientWrite = {
name: form.name.trim(),
email: form.email.trim() || null,
phone: form.phone.trim() || null,
street: form.street.trim() || null,
city: form.city.trim() || null,
postalCode: form.postalCode.trim() || null,
}
if (isEditing.value && props.client) {
@@ -1,12 +1,12 @@
<script setup lang="ts">
import type { MailMessageDetailDto } from '~/modules/mail/services/dto/mail'
import type { Task } from '~/modules/project-management/services/dto/task'
import type { Project } from '~/modules/project-management/services/dto/project'
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
import type { MailMessageDetailDto } from '~/services/dto/mail'
import type { Task } from '~/services/dto/task'
import type { Project } from '~/services/dto/project'
import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data'
import { useMailService } from '~/modules/mail/services/mail'
import { useProjectService } from '~/modules/project-management/services/projects'
import { useTaskGroupService } from '~/modules/project-management/services/task-groups'
import { useMailService } from '~/services/mail'
import { useProjectService } from '~/services/projects'
import { useTaskGroupService } from '~/services/task-groups'
import { useUserService } from '~/services/users'
import { useAuthStore } from '~/shared/stores/auth'
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { MailFolderDto } from '~/modules/mail/services/dto/mail'
import type { MailFolderDto } from '~/services/dto/mail'
const props = defineProps<{
/** Arbre de dossiers (getter folderTree du store) */
@@ -1,9 +1,9 @@
<script setup lang="ts">
import type { Task } from '~/modules/project-management/services/dto/task'
import type { Project } from '~/modules/project-management/services/dto/project'
import { useMailService } from '~/modules/mail/services/mail'
import { useTaskService } from '~/modules/project-management/services/tasks'
import { useProjectService } from '~/modules/project-management/services/projects'
import type { Task } from '~/services/dto/task'
import type { Project } from '~/services/dto/project'
import { useMailService } from '~/services/mail'
import { useTaskService } from '~/services/tasks'
import { useProjectService } from '~/services/projects'
const props = defineProps<{
modelValue: boolean
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { MailMessageHeaderDto } from '~/modules/mail/services/dto/mail'
import type { MailMessageHeaderDto } from '~/services/dto/mail'
const props = defineProps<{
messages: readonly MailMessageHeaderDto[]
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { MailMessageDetailDto, MailAddressDto, MailAttachmentDto } from '~/modules/mail/services/dto/mail'
import type { MailMessageDetailDto, MailAddressDto, MailAttachmentDto } from '~/services/dto/mail'
import { sanitizeMailHtml } from '~/utils/sanitizeMailHtml'
import { useMailService } from '~/modules/mail/services/mail'
import { useMailService } from '~/services/mail'
const props = defineProps<{
/** Détail complet du message. null = aucun message sélectionné. */
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { useMailStore } from '~/modules/mail/stores/mail'
import { useMailStore } from '~/stores/mail'
const store = useMailStore()
const { syncing } = storeToRefs(store)
@@ -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>
@@ -130,15 +123,13 @@
</template>
<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'
import type { Project, ProjectWrite } from '~/services/dto/project'
import type { Client } from '~/services/dto/client'
import type { GiteaRepository } from '~/services/dto/gitea'
import type { BookStackShelf } from '~/services/dto/bookstack'
import { useProjectService } from '~/services/projects'
import { useGiteaService } from '~/services/gitea'
import { useBookStackService } from '~/services/bookstack'
const props = defineProps<{
modelValue: boolean
@@ -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 {
@@ -67,10 +67,10 @@
</template>
<script setup lang="ts">
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
import type { Task } from '~/modules/project-management/services/dto/task'
import { useTaskGroupService } from '~/modules/project-management/services/task-groups'
import { useTaskService } from '~/modules/project-management/services/tasks'
import type { TaskGroup } from '~/services/dto/task-group'
import type { Task } from '~/services/dto/task'
import { useTaskGroupService } from '~/services/task-groups'
import { useTaskService } from '~/services/tasks'
import { stripRichText } from '~/utils/format'
const props = defineProps<{
@@ -82,12 +82,12 @@
</template>
<script setup lang="ts">
import type { Project } from '~/modules/project-management/services/dto/project'
import type { Task } from '~/modules/project-management/services/dto/task'
import type { Workflow } from '~/modules/project-management/services/dto/workflow'
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
import { useWorkflowService } from '~/modules/project-management/services/workflows'
import { useTaskService } from '~/modules/project-management/services/tasks'
import type { Project } from '~/services/dto/project'
import type { Task } from '~/services/dto/task'
import type { Workflow } from '~/services/dto/workflow'
import type { TaskStatus } from '~/services/dto/task-status'
import { useWorkflowService } from '~/services/workflows'
import { useTaskService } from '~/services/tasks'
const props = defineProps<{
modelValue: boolean
@@ -167,8 +167,8 @@
</template>
<script setup lang="ts">
import type { FileEntry } from '~/modules/integration/services/dto/share'
import { useShareService } from '~/modules/integration/services/share'
import type { FileEntry } from '~/services/dto/share'
import { useShareService } from '~/services/share'
import { formatFileSize } from '~/utils/format'
const props = defineProps<{
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
import type { TaskStatus } from '~/services/dto/task-status'
defineProps<{
statuses: TaskStatus[]
@@ -75,8 +75,8 @@
</template>
<script setup lang="ts">
import type { BookStackLink, BookStackSearchResult } from '~/modules/integration/services/dto/bookstack'
import { useBookStackService } from '~/modules/integration/services/bookstack'
import type { BookStackLink, BookStackSearchResult } from '~/services/dto/bookstack'
import { useBookStackService } from '~/services/bookstack'
const props = defineProps<{
taskId: number
@@ -104,13 +104,13 @@
</template>
<script setup lang="ts">
import type { Task } from '~/modules/project-management/services/dto/task'
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort'
import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority'
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
import type { Task } from '~/services/dto/task'
import type { TaskStatus } from '~/services/dto/task-status'
import type { TaskEffort } from '~/services/dto/task-effort'
import type { TaskPriority } from '~/services/dto/task-priority'
import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/modules/project-management/services/dto/project'
import type { Project } from '~/services/dto/project'
const props = withDefaults(defineProps<{
selectedCount: number
@@ -102,7 +102,7 @@
</template>
<script setup lang="ts">
import type { Task } from '~/modules/project-management/services/dto/task'
import type { Task } from '~/services/dto/task'
const props = withDefaults(defineProps<{
task: Task
@@ -60,8 +60,8 @@
</template>
<script setup lang="ts">
import type { TaskDocument } from '~/modules/project-management/services/dto/task-document'
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
import type { TaskDocument } from '~/services/dto/task-document'
import { useTaskDocumentService } from '~/services/task-documents'
import { formatFileSize } from '~/utils/format'
defineProps<{
@@ -121,8 +121,8 @@
</template>
<script setup lang="ts">
import type { TaskDocument } from '~/modules/project-management/services/dto/task-document'
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
import type { TaskDocument } from '~/services/dto/task-document'
import { useTaskDocumentService } from '~/services/task-documents'
import { formatFileSize } from '~/utils/format'
import { copyToClipboard } from '~/utils/clipboard'
@@ -56,9 +56,9 @@
</template>
<script setup lang="ts">
import type { Breadcrumb, FileEntry } from '~/modules/integration/services/dto/share'
import { useShareService } from '~/modules/integration/services/share'
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
import type { Breadcrumb, FileEntry } from '~/services/dto/share'
import { useShareService } from '~/services/share'
import { useTaskDocumentService } from '~/services/task-documents'
import { formatFileSize } from '~/utils/format'
const props = defineProps<{
@@ -46,7 +46,7 @@
</template>
<script setup lang="ts">
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
import { useTaskDocumentService } from '~/services/task-documents'
const props = defineProps<{
taskId?: number
@@ -25,8 +25,8 @@
</template>
<script setup lang="ts">
import type { TaskEffort, TaskEffortWrite } from '~/modules/project-management/services/dto/task-effort'
import { useTaskEffortService } from '~/modules/project-management/services/task-efforts'
import type { TaskEffort, TaskEffortWrite } from '~/services/dto/task-effort'
import { useTaskEffortService } from '~/services/task-efforts'
const props = defineProps<{
modelValue: boolean
@@ -226,9 +226,9 @@
</template>
<script setup lang="ts">
import type { Task } from '~/modules/project-management/services/dto/task'
import type { GiteaBranch, GiteaPullRequest } from '~/modules/integration/services/dto/gitea'
import { useGiteaService } from '~/modules/integration/services/gitea'
import type { Task } from '~/services/dto/task'
import type { GiteaBranch, GiteaPullRequest } from '~/services/dto/gitea'
import { useGiteaService } from '~/services/gitea'
import { copyToClipboard } from '~/utils/clipboard'
const { t } = useI18n()
@@ -56,10 +56,10 @@
</template>
<script setup lang="ts">
import type { TaskGroup, TaskGroupWrite } from '~/modules/project-management/services/dto/task-group'
import type { Task } from '~/modules/project-management/services/dto/task'
import { useTaskGroupService } from '~/modules/project-management/services/task-groups'
import { useTaskService } from '~/modules/project-management/services/tasks'
import type { TaskGroup, TaskGroupWrite } from '~/services/dto/task-group'
import type { Task } from '~/services/dto/task'
import { useTaskGroupService } from '~/services/task-groups'
import { useTaskService } from '~/services/tasks'
const props = defineProps<{
modelValue: boolean
@@ -110,7 +110,7 @@
</template>
<script setup lang="ts">
import type { Task } from '~/modules/project-management/services/dto/task'
import type { Task } from '~/services/dto/task'
const props = withDefaults(defineProps<{
task: Task
@@ -536,23 +536,23 @@
</template>
<script setup lang="ts">
import type { Task, TaskWrite } from '~/modules/project-management/services/dto/task'
import type { TaskDocument } from '~/modules/project-management/services/dto/task-document'
import { useGiteaService } from '~/modules/integration/services/gitea'
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
import type { Task, TaskWrite } from '~/services/dto/task'
import type { TaskDocument } from '~/services/dto/task-document'
import { useGiteaService } from '~/services/gitea'
import { useTaskDocumentService } from '~/services/task-documents'
import ConfirmDeleteDocumentModal from '~/components/ui/ConfirmDeleteDocumentModal.vue'
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort'
import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority'
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
import type { TaskStatus } from '~/services/dto/task-status'
import type { TaskEffort } from '~/services/dto/task-effort'
import type { TaskPriority } from '~/services/dto/task-priority'
import type { TaskTag } from '~/services/dto/task-tag'
import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data'
import { useTaskService } from '~/modules/project-management/services/tasks'
import { useTaskRecurrenceService } from '~/modules/project-management/services/task-recurrences'
import { useTaskService } from '~/services/tasks'
import { useTaskRecurrenceService } from '~/services/task-recurrences'
import type { Project } from '~/modules/project-management/services/dto/project'
import { useMailService } from '~/modules/mail/services/mail'
import type { MailMessageHeaderDto } from '~/modules/mail/services/dto/mail'
import type { Project } from '~/services/dto/project'
import { useMailService } from '~/services/mail'
import type { MailMessageHeaderDto } from '~/services/dto/mail'
const props = defineProps<{
modelValue: boolean
@@ -569,7 +569,7 @@ const props = defineProps<{
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'saved', task?: Task): void
(e: 'saved'): void
}>()
const isOpen = computed({
@@ -1042,7 +1042,7 @@ async function handleSubmit() {
await removeRecurrence(props.task.recurrence.id)
}
emit('saved', savedTask)
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
@@ -28,8 +28,8 @@
</template>
<script setup lang="ts">
import type { TaskPriority, TaskPriorityWrite } from '~/modules/project-management/services/dto/task-priority'
import { useTaskPriorityService } from '~/modules/project-management/services/task-priorities'
import type { TaskPriority, TaskPriorityWrite } from '~/services/dto/task-priority'
import { useTaskPriorityService } from '~/services/task-priorities'
const props = defineProps<{
modelValue: boolean
@@ -28,8 +28,8 @@
</template>
<script setup lang="ts">
import type { TaskTag, TaskTagWrite } from '~/modules/project-management/services/dto/task-tag'
import { useTaskTagService } from '~/modules/project-management/services/task-tags'
import type { TaskTag, TaskTagWrite } from '~/services/dto/task-tag'
import { useTaskTagService } from '~/services/task-tags'
const props = defineProps<{
modelValue: boolean
@@ -64,7 +64,7 @@
</template>
<script setup lang="ts">
import type { TimeEntry } from '~/modules/time-tracking/services/dto/time-entry'
import type { TimeEntry } from '~/services/dto/time-entry'
const props = defineProps<{
entry: TimeEntry
@@ -35,7 +35,7 @@
</template>
<script setup lang="ts">
import type { TimeEntry } from '~/modules/time-tracking/services/dto/time-entry'
import type { TimeEntry } from '~/services/dto/time-entry'
const props = defineProps<{
visible: boolean
@@ -124,11 +124,11 @@
</template>
<script setup lang="ts">
import type { TimeEntry, TimeEntryWrite } from '~/modules/time-tracking/services/dto/time-entry'
import type { TimeEntry, TimeEntryWrite } from '~/services/dto/time-entry'
import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/modules/project-management/services/dto/project'
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
import { useTimeEntryService } from '~/modules/time-tracking/services/time-entries'
import type { Project } from '~/services/dto/project'
import type { TaskTag } from '~/services/dto/task-tag'
import { useTimeEntryService } from '~/services/time-entries'
const props = defineProps<{
modelValue: boolean
@@ -67,7 +67,7 @@
</template>
<script setup lang="ts">
import type { TimeEntry } from '~/modules/time-tracking/services/dto/time-entry'
import type { TimeEntry } from '~/services/dto/time-entry'
import { stripRichText } from '~/utils/format'
const props = defineProps<{
@@ -150,8 +150,8 @@
</template>
<script setup lang="ts">
import type { TimeEntry } from '~/modules/time-tracking/services/dto/time-entry'
import { useAbsenceService } from '~/modules/absence/services/absences'
import type { TimeEntry } from '~/services/dto/time-entry'
import { useAbsenceService } from '~/services/absences'
const { t } = useI18n()
const absenceService = useAbsenceService()
@@ -108,9 +108,9 @@
<script setup lang="ts">
import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/modules/project-management/services/dto/project'
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
import type { Client } from '~/modules/directory/services/dto/client'
import type { Project } from '~/services/dto/project'
import type { TaskTag } from '~/services/dto/task-tag'
import type { Client } from '~/services/dto/client'
const props = defineProps<{
users: UserData[]
+2 -2
View File
@@ -3,11 +3,11 @@
<div class="flex h-full items-center justify-between">
<MalioButtonIcon
icon="mdi:menu"
aria-label="Replier ou déplier le menu"
aria-label="Menu"
variant="ghost"
icon-size="24"
button-class="lg:hidden text-white hover:bg-primary-600"
@click="ui.toggleSidebar()"
@click="ui.openMobileSidebar()"
/>
<div class="hidden items-center gap-2 lg:flex">
<h1 class="text-lg font-bold tracking-tight">Lesstime</h1>
@@ -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>
-25
View File
@@ -1,25 +0,0 @@
<template>
<!-- Entête de page standard : source unique du style des titres.
Toujours sticky en haut du <main> scrollable : reste visible au scroll.
Fond blanc + pt-[38px]/pb-[30px] (au lieu de marges) pour que le contenu
défilant soit masqué sous l'entête (espaces haut ET bas compris) et que
l'entête soit collée sous l'AppTopNav sans trou.
Slots :
- défaut : texte du titre
- #actions : boutons à droite du titre
- #subheader : barre de filtres / onglets rendue SOUS le titre, dans le
même bloc sticky (reste donc collée avec le titre). La
marge titre -> sous-entête est portée par le contenu passé
(ex. mt-4) pour laisser chaque page régler son cas. -->
<div class="sticky top-0 z-20 bg-white pt-[38px] pb-[30px]">
<div class="flex items-center justify-between gap-4">
<h1 class="text-[30px] font-semibold text-primary-500">
<slot/>
</h1>
<div v-if="$slots.actions" class="shrink-0">
<slot name="actions"/>
</div>
</div>
<slot name="subheader"/>
</div>
</template>
+52
View File
@@ -0,0 +1,52 @@
<template>
<NuxtLink
:to="to"
class="group/link relative flex items-center transition-colors hover:text-primary-500"
:class="linkClasses"
:active-class="exact ? '' : activeClass"
:exact-active-class="exact ? activeClass : ''"
>
<Icon :name="icon" :size="sub ? '20' : '24'" class="flex-shrink-0" />
<span
v-if="!collapsed"
class="self-baseline whitespace-nowrap overflow-hidden transition-opacity duration-300"
:class="sub ? 'text-sm' : 'text-md'"
>
{{ label }}
</span>
<div
v-if="collapsed"
class="pointer-events-none absolute left-full z-50 ml-2 rounded-md bg-neutral-800 px-2 py-1 text-xs text-white opacity-0 shadow-lg transition-opacity group-hover/link:pointer-events-auto group-hover/link:opacity-100 whitespace-nowrap"
>
{{ label }}
</div>
</NuxtLink>
</template>
<script setup lang="ts">
const props = defineProps<{
to: string
icon: string
label: string
collapsed: boolean
sub?: boolean
exact?: boolean
}>()
const activeClass = computed(() => {
if (props.collapsed) {
return '!text-primary-500 bg-primary-500/10'
}
return '!text-primary-500 bg-tertiary-500'
})
const linkClasses = computed(() => {
if (props.collapsed) {
return 'justify-center w-10 h-10 mx-auto my-1 p-2 rounded-lg text-neutral-600 hover:text-primary-500 hover:bg-primary-500/10'
}
if (props.sub) {
return 'gap-3 px-4 py-2 pl-12 text-sm font-semibold text-neutral-700'
}
return 'gap-3 px-4 py-3 text-md font-semibold text-neutral-700'
})
</script>
+20 -33
View File
@@ -106,43 +106,30 @@ const touched = reactive({
password: false,
})
const { create, update, getById } = useUserService()
function applyUser(user: UserData) {
form.username = user.username ?? ''
form.firstName = user.firstName ?? ''
form.lastName = user.lastName ?? ''
form.password = ''
form.roles = [...user.roles]
form.isEmployee = user.isEmployee ?? false
}
watch(() => props.modelValue, async (open) => {
if (!open) {
return
}
touched.username = false
touched.password = false
if (props.item) {
applyUser(props.item)
try {
const full = await getById(props.item.id)
applyUser(full)
} catch {
// Keep the list data if the detailed fetch fails.
watch(() => props.modelValue, (open) => {
if (open) {
if (props.item) {
form.username = props.item.username ?? ''
form.firstName = props.item.firstName ?? ''
form.lastName = props.item.lastName ?? ''
form.password = ''
form.roles = [...props.item.roles]
form.isEmployee = props.item.isEmployee ?? false
} else {
form.username = ''
form.firstName = ''
form.lastName = ''
form.password = ''
form.roles = ['ROLE_USER']
form.isEmployee = false
}
} else {
form.username = ''
form.firstName = ''
form.lastName = ''
form.password = ''
form.roles = ['ROLE_USER']
form.isEmployee = false
touched.username = false
touched.password = false
}
})
const { create, update } = useUserService()
async function handleSubmit() {
touched.username = true
touched.password = true
@@ -1,4 +1,4 @@
import type { AbsenceRequest, AbsenceStatus, AbsenceType, HalfDay } from '~/modules/absence/services/dto/absence'
import type { AbsenceRequest, AbsenceStatus, AbsenceType, HalfDay } from '~/services/dto/absence'
export type BadgeVariant = 'neutral' | 'info' | 'success' | 'warning' | 'danger'
@@ -1,4 +1,4 @@
import { useShareService } from '~/modules/integration/services/share'
import { useShareService } from '~/services/share'
export function useShareStatus() {
const enabled = useState<boolean | null>('share-enabled', () => null)
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1 +0,0 @@
export default defineNuxtConfig({})
+1 -1
View File
@@ -1,7 +1,7 @@
<template>
<NuxtLayout name="default">
<div class="mx-auto max-w-lg px-4 py-10">
<PageHeader>{{ $t('profile.title') }}</PageHeader>
<h1 class="mb-8 text-2xl font-bold text-neutral-900">{{ $t('profile.title') }}</h1>
<div class="flex flex-col items-center gap-6 rounded-xl border border-neutral-200 bg-white p-8 shadow-sm">
<!-- Current avatar -->
@@ -1,65 +0,0 @@
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export type AuditLogAction = 'create' | 'update' | 'delete'
export type AuditLogItem = {
id: string
'@id'?: string
entityType: string
entityId: string
action: AuditLogAction
changes: Record<string, unknown>
performedBy: string
performedAt: string
ipAddress: string | null
requestId: string | null
}
export type AuditLogQuery = {
page?: number
entityType?: string
action?: AuditLogAction
}
export type AuditLogPage = {
items: AuditLogItem[]
totalItems: number
}
export type AuditLogEntityTypes = {
'@id'?: string
entityTypes: string[]
}
export function useAuditLogService() {
const api = useApi()
async function list(params: AuditLogQuery = {}): Promise<AuditLogPage> {
const query: Record<string, unknown> = {}
if (params.page !== undefined) {
query.page = params.page
}
if (params.entityType) {
query.entity_type = params.entityType
}
if (params.action) {
query.action = params.action
}
const data = await api.get<HydraCollection<AuditLogItem>>('/audit-logs', query)
return {
items: extractHydraMembers(data),
totalItems: data['hydra:totalItems'] ?? data['totalItems'] ?? 0,
}
}
async function entityTypes(): Promise<string[]> {
// `/audit-log-entity-types` is a single API Platform item resource
// (not a hydra collection): it returns `{ entityTypes: string[] }`.
const data = await api.get<AuditLogEntityTypes>('/audit-log-entity-types')
return data.entityTypes ?? []
}
return { list, entityTypes }
}
@@ -1,144 +0,0 @@
<template>
<MalioDrawer v-model="isOpen">
<template #header>
<h2 class="text-xl font-bold">
{{ isEditing ? $t('directory.reports.editTitle') : $t('directory.reports.addTitle') }}
</h2>
</template>
<form @submit.prevent="handleSubmit" class="flex flex-col gap-4">
<MalioInputText
v-model="form.subject"
:label="$t('directory.reports.fields.subject')"
input-class="w-full"
:error="touched.subject && !form.subject.trim() ? $t('directory.validation.subjectRequired') : ''"
@blur="touched.subject = true"
/>
<MalioSelect
v-model="form.type"
:label="$t('directory.reports.fields.type')"
:options="typeOptions"
group-class="w-full"
/>
<MalioDate
v-model="form.occurredAt"
:label="$t('directory.reports.fields.occurredAt')"
/>
<MalioInputRichText
v-model="form.body"
:label="$t('directory.reports.fields.body')"
min-height="180px"
/>
<div class="mt-4 flex justify-end gap-3">
<MalioButton
variant="tertiary"
button-class="w-auto px-4"
:label="$t('common.cancel')"
@click="isOpen = false"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="isSubmitting"
@click="handleSubmit"
/>
</div>
</form>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { CommercialReport, CommercialReportWrite, ReportType } from '~/modules/directory/services/dto/commercial-report'
import { useCommercialReportService } from '~/modules/directory/services/commercial-reports'
const props = defineProps<{
modelValue: boolean
report: CommercialReport | null
owner: { client?: string, prospect?: string, prestataire?: string }
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'saved'): void
}>()
const { t } = useI18n()
const { create, update } = useCommercialReportService()
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const isEditing = computed(() => !!props.report)
const isSubmitting = ref(false)
const typeOptions: { label: string, value: ReportType }[] = [
{ label: t('directory.reports.types.call'), value: 'call' },
{ label: t('directory.reports.types.meeting'), value: 'meeting' },
{ label: t('directory.reports.types.email'), value: 'email' },
{ label: t('directory.reports.types.note'), value: 'note' },
]
function today(): string {
return new Date().toISOString().slice(0, 10)
}
// L'éditeur riche émet du HTML : un contenu « vide » vaut `<p></p>`. On le
// normalise en null pour ne pas persister une coquille vide.
function normalizeBody(html: string): string | null {
const stripped = html.replace(/<[^>]*>/g, '').replace(/&nbsp;/g, ' ').trim()
return stripped ? html : null
}
const form = reactive<{ subject: string, type: ReportType, occurredAt: string, body: string }>({
subject: '',
type: 'note',
occurredAt: today(),
body: '',
})
const touched = reactive({ subject: false })
watch(() => props.modelValue, (open) => {
if (!open) return
if (props.report) {
form.subject = props.report.subject
form.type = props.report.type
form.occurredAt = props.report.occurredAt.slice(0, 10)
form.body = props.report.body ?? ''
} else {
form.subject = ''
form.type = 'note'
form.occurredAt = today()
form.body = ''
}
touched.subject = false
})
async function handleSubmit(): Promise<void> {
touched.subject = true
if (!form.subject.trim() || isSubmitting.value) return
isSubmitting.value = true
try {
const payload: CommercialReportWrite = {
subject: form.subject.trim(),
type: form.type,
occurredAt: form.occurredAt,
body: normalizeBody(form.body),
...props.owner,
}
if (isEditing.value && props.report) {
await update(props.report.id, payload)
} else {
await create(payload)
}
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
</script>
@@ -1,237 +0,0 @@
<template>
<div class="flex flex-col gap-5 pt-6">
<!-- Barre d'action -->
<div class="flex items-center justify-between gap-3">
<p class="text-sm text-neutral-500">
<span v-if="reports.length">{{ $t('directory.reports.count', { n: reports.length }, reports.length) }}</span>
</p>
<MalioButton
v-if="canManage"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.reports.add')"
@click="openCreate"
/>
</div>
<!-- État vide -->
<div
v-if="!loading && !reports.length"
class="flex flex-col items-center gap-2 rounded-lg border border-dashed border-neutral-200 bg-neutral-50 px-6 py-12 text-center"
>
<Icon name="mdi:message-text-outline" class="text-4xl text-neutral-300" />
<p class="font-medium text-neutral-600">{{ $t('directory.reports.empty') }}</p>
<p class="max-w-sm text-sm text-neutral-400">{{ $t('directory.reports.emptyHint') }}</p>
<MalioButton
v-if="canManage"
variant="tertiary"
icon-name="mdi:plus"
icon-position="left"
button-class="mt-2 w-auto px-4"
:label="$t('directory.reports.add')"
@click="openCreate"
/>
</div>
<!-- Timeline antéchronologique -->
<ol v-else class="flex flex-col">
<li
v-for="report in sortedReports"
:key="report.id"
class="relative flex gap-4 pb-6 last:pb-0"
>
<!-- Rail + pastille de type -->
<div class="flex flex-col items-center">
<span
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full"
:class="typeStyle(report.type).badge"
>
<Icon :name="typeStyle(report.type).icon" class="text-lg" />
</span>
<span class="mt-1 w-px grow bg-neutral-200" aria-hidden="true" />
</div>
<!-- Carte -->
<div class="flex-1 rounded-lg border border-neutral-200 bg-white p-4 shadow-[0_1px_2px_0_rgba(0,0,0,0.05)]">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span
class="rounded-full px-2 py-0.5 text-xs font-medium"
:class="typeStyle(report.type).chip"
>
{{ $t(`directory.reports.types.${report.type}`) }}
</span>
<p class="truncate font-semibold text-neutral-800">{{ report.subject }}</p>
</div>
<p class="mt-1 text-xs text-neutral-500">
<span :title="absoluteDate(report.occurredAt)">{{ relativeDate(report.occurredAt) }}</span>
<span v-if="report.author"> · {{ report.author.username }}</span>
</p>
</div>
<div v-if="canManage" class="flex shrink-0 gap-1">
<MalioButtonIcon
icon="mdi:pencil-outline"
variant="ghost"
:aria-label="$t('common.edit')"
@click="openEdit(report)"
/>
<MalioButtonIcon
icon="mdi:delete-outline"
variant="ghost"
:aria-label="$t('common.delete')"
@click="askDelete(report)"
/>
</div>
</div>
<MalioInputRichText
v-if="report.body"
:model-value="report.body"
:editable="false"
:reserve-message-space="false"
editor-class="!border-0 !rounded-none !bg-transparent !p-0 text-sm text-neutral-700"
class="mt-2"
/>
<!-- Documents joints -->
<div
v-if="(report.documents?.length ?? 0) || canManage"
class="mt-3 border-t border-neutral-100 pt-3"
>
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-neutral-400">
{{ $t('directory.reports.documentsLabel') }}
</p>
<div class="flex flex-col gap-2">
<ReportDocumentList
v-if="report.documents?.length"
:documents="report.documents"
:can-manage="canManage"
@delete="(docId) => removeDocument(docId)"
/>
<ReportDocumentUpload
v-if="canManage"
:report-id="report.id"
@uploaded="reload"
/>
</div>
</div>
</div>
</li>
</ol>
<CommercialReportDrawer
v-model="drawerOpen"
:report="editing"
:owner="owner"
@saved="reload"
/>
<ConfirmDeleteReportModal
v-model="confirmOpen"
:busy="deleting"
@confirm="confirmDelete"
/>
</div>
</template>
<script setup lang="ts">
import type { CommercialReport, ReportType } from '~/modules/directory/services/dto/commercial-report'
import { useCommercialReportService } from '~/modules/directory/services/commercial-reports'
import { useReportDocumentService } from '~/modules/directory/services/report-documents'
const props = defineProps<{
owner: { client?: string, prospect?: string, prestataire?: string }
canManage: boolean
}>()
const reportService = useCommercialReportService()
const documentService = useReportDocumentService()
const reports = ref<CommercialReport[]>([])
const loading = ref(true)
const drawerOpen = ref(false)
const editing = ref<CommercialReport | null>(null)
const confirmOpen = ref(false)
const pendingDelete = ref<CommercialReport | null>(null)
const deleting = 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)),
)
const typeStyles: Record<ReportType, { icon: string, badge: string, chip: string }> = {
call: { icon: 'mdi:phone-outline', badge: 'bg-emerald-100 text-emerald-700', chip: 'bg-emerald-50 text-emerald-700' },
meeting: { icon: 'mdi:account-group-outline', badge: 'bg-violet-100 text-violet-700', chip: 'bg-violet-50 text-violet-700' },
email: { icon: 'mdi:email-outline', badge: 'bg-sky-100 text-sky-700', chip: 'bg-sky-50 text-sky-700' },
note: { icon: 'mdi:note-text-outline', badge: 'bg-amber-100 text-amber-700', chip: 'bg-amber-50 text-amber-700' },
}
function typeStyle(type: ReportType) {
return typeStyles[type]
}
function startOfDay(d: Date): number {
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
}
function absoluteDate(iso: string): string {
return new Date(iso).toLocaleDateString('fr-FR')
}
// Date relative lisible (« aujourd'hui », « il y a 3 jours ») avec repli sur la
// date absolue au-delà d'un an. La date exacte reste disponible en infobulle.
function relativeDate(iso: string): string {
const diffDays = Math.round((startOfDay(new Date(iso)) - startOfDay(new Date())) / 86400000)
const rtf = new Intl.RelativeTimeFormat('fr-FR', { numeric: 'auto' })
const abs = Math.abs(diffDays)
if (abs < 1) return rtf.format(0, 'day')
if (abs < 7) return rtf.format(diffDays, 'day')
if (abs < 31) return rtf.format(Math.round(diffDays / 7), 'week')
if (abs < 365) return rtf.format(Math.round(diffDays / 30), 'month')
return absoluteDate(iso)
}
function openCreate(): void {
editing.value = null
drawerOpen.value = true
}
function openEdit(report: CommercialReport): void {
editing.value = report
drawerOpen.value = true
}
function askDelete(report: CommercialReport): void {
pendingDelete.value = report
confirmOpen.value = true
}
async function confirmDelete(): Promise<void> {
if (!pendingDelete.value || deleting.value) return
deleting.value = true
try {
await reportService.remove(pendingDelete.value.id)
confirmOpen.value = false
pendingDelete.value = null
await reload()
} finally {
deleting.value = false
}
}
async function removeDocument(id: number): Promise<void> {
await documentService.remove(id)
await reload()
}
async function reload(): Promise<void> {
reports.value = await reportService.getByOwner(props.owner)
loading.value = false
}
onMounted(reload)
watch(() => props.owner, reload, { deep: true })
</script>
@@ -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>
@@ -1,198 +0,0 @@
<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)"
/>
</div>
<MalioInputText
class="col-span-2"
:label="$t('directory.addresses.fields.streetComplement')"
:model-value="modelValue.streetComplement ?? ''"
:readonly="readonly"
@update:model-value="update('streetComplement', $event)"
/>
<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)"
/>
</div>
</template>
<script setup lang="ts">
import type { Address } from '~/modules/directory/services/dto/address'
import {
useAddressAutocomplete,
type AddressSuggestion,
} from '~/modules/directory/composables/useAddressAutocomplete'
const props = defineProps<{
modelValue: Address
title: string
removable?: boolean
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: Address]
'remove': []
}>()
const { t } = useI18n()
const toast = useToast()
const autocomplete = useAddressAutocomplete()
type Option = { label: string, value: string | number }
const addressOptions = ref<Option[]>([])
// Villes renvoyées par la BAN pour le code postal courant.
const fetchedCityOptions = ref<Option[]>([])
const addressLoading = ref(false)
// 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,
// même avant toute recherche par code postal sinon elle s'afficherait vide.
const cityOptions = computed<Option[]>(() => {
const current = (props.modelValue.city ?? '').trim()
const options = [...fetchedCityOptions.value]
if (current && !options.some(o => o.value === current)) {
options.unshift({ value: current, label: current })
}
return options
})
// Mode dégradé : BAN indisponible la ville passe en saisie libre.
const degraded = ref(false)
let lastAddressSuggestions: AddressSuggestion[] = []
let notified = false
function update(field: keyof Address, value: string): void {
emit('update:modelValue', { ...props.modelValue, [field]: value === '' ? null : value })
}
// Avertit une seule fois que l'autocomplétion est indisponible (saisie libre).
function notifyUnavailable(): void {
if (notified) return
notified = true
toast.info({ title: '', message: t('directory.addresses.autocompleteUnavailable') })
}
/** Recherche d'adresse assistée (event de MalioInputAutocomplete). */
async function onAddressSearch(query: string): Promise<void> {
if (query.trim().length < 3) {
addressOptions.value = []
return
}
addressLoading.value = true
try {
const postalCode = (props.modelValue.postalCode ?? '').replace(/\D/g, '') || undefined
const suggestions = await autocomplete.searchAddress(query, postalCode)
lastAddressSuggestions = suggestions
addressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
}
catch {
addressOptions.value = []
notifyUnavailable()
}
finally {
addressLoading.value = false
}
}
/** Sélection d'une suggestion → remplit rue + ville + code postal. */
function onAddressSelect(option: Option | null): void {
if (option === null) return
// Matching par `label` (adresse complète, unique côté BAN) plutôt que par
// rue : deux communes peuvent partager le même libellé de voie.
const suggestion = lastAddressSuggestions.find(s => s.label === option.label)
if (!suggestion) {
update('street', String(option.value))
return
}
emit('update:modelValue', {
...props.modelValue,
street: suggestion.street,
city: suggestion.city || props.modelValue.city,
postalCode: suggestion.postalCode || props.modelValue.postalCode,
})
}
/** Saisie du code postal → met à jour le champ + interroge la BAN pour la ville. */
async function onPostalCodeInput(value: string): Promise<void> {
update('postalCode', value)
const digits = (value ?? '').replace(/\D/g, '')
if (digits.length < 5) return
try {
const suggestions = await autocomplete.searchCity(digits)
fetchedCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
degraded.value = false
}
catch {
degraded.value = true
notifyUnavailable()
}
}
</script>
@@ -1,89 +0,0 @@
<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
: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>
</template>
<script setup lang="ts">
import type { Contact } from '~/modules/directory/services/dto/contact'
import { isValidEmail, isValidFrPhone } from '~/modules/directory/utils/validation'
const props = defineProps<{
modelValue: Contact
title: string
removable?: boolean
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: Contact]
'remove': []
}>()
const { t } = useI18n()
const emailError = computed(() =>
isValidEmail(props.modelValue.email) ? '' : t('directory.validation.emailInvalid'),
)
const phonePrimaryError = computed(() =>
isValidFrPhone(props.modelValue.phonePrimary) ? '' : t('directory.validation.phoneInvalid'),
)
const phoneSecondaryError = computed(() =>
isValidFrPhone(props.modelValue.phoneSecondary) ? '' : t('directory.validation.phoneInvalid'),
)
function update(field: keyof Contact, value: string): void {
emit('update:modelValue', { ...props.modelValue, [field]: value === '' ? null : value })
}
</script>
@@ -1,88 +0,0 @@
<template>
<MalioDrawer v-model="isOpen">
<template #header>
<h2 class="text-xl font-bold">{{ isEditing ? $t('prestataires.editPrestataire') : $t('prestataires.addPrestataire') }}</h2>
</template>
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.name"
:label="$t('prestataires.fields.name')"
input-class="w-full"
:error="touched.name && !form.name.trim() ? $t('directory.validation.nameRequired') : ''"
@blur="touched.name = true"
/>
<div class="mt-6 flex justify-end">
<MalioButton
:label="$t('common.save')"
button-class="w-auto px-6"
:disabled="isSubmitting"
@click="handleSubmit"
/>
</div>
</form>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { Prestataire, PrestataireWrite } from '~/modules/directory/services/dto/prestataire'
import { usePrestataireService } from '~/modules/directory/services/prestataires'
const props = defineProps<{
modelValue: boolean
prestataire: Prestataire | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'saved'): void
}>()
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const isEditing = computed(() => !!props.prestataire)
const isSubmitting = ref(false)
const form = reactive({
name: '',
})
const touched = reactive({
name: false,
})
watch(() => props.modelValue, (open) => {
if (open) {
form.name = props.prestataire?.name ?? ''
touched.name = false
}
})
const { create, update } = usePrestataireService()
async function handleSubmit() {
touched.name = true
if (!form.name.trim()) return
isSubmitting.value = true
try {
const payload: PrestataireWrite = {
name: form.name.trim(),
}
if (isEditing.value && props.prestataire) {
await update(props.prestataire.id, payload)
} else {
await create(payload)
}
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
</script>

Some files were not shown because too many files have changed in this diff Show More