Compare commits

..

26 Commits

Author SHA1 Message Date
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
643 changed files with 3371 additions and 27400 deletions
@@ -112,32 +112,6 @@
- **Aligner le contrat sur la réalité de l'entité, pas l'inverse** : `User::getUsername()` est `?string` (pas `string`) et la méthode réelle est `getIsEmployee(): bool` (pas `isEmployee()`). Le plan écrivait `isEmployee()` — le contrat existant était déjà correct, aucun changement. Toujours lire l'entité avant de figer une signature de contrat. - **Aligner le contrat sur la réalité de l'entité, pas l'inverse** : `User::getUsername()` est `?string` (pas `string`) et la méthode réelle est `getIsEmployee(): bool` (pas `isEmployee()`). Le plan écrivait `isEmployee()` — le contrat existant était déjà correct, aucun changement. Toujours lire l'entité avant de figer une signature de contrat.
- **Tests fonctionnels qui persistent réellement** (pas de rollback transactionnel ici) : un `NotifierTest` qui crée une notif échoue au 2e run (`2 != 1`) → rendre les données uniques (`uniqid()` sur le titre) pour l'idempotence. - **Tests fonctionnels qui persistent réellement** (pas de rollback transactionnel ici) : un `NotifierTest` qui crée une notif échoue au 2e run (`2 != 1`) → rendre les données uniques (`uniqid()` sur le titre) pour l'idempotence.
## Session 2026-06-19 (LST-57 / 1.2 — RBAC fin : portage Starseed)
### Contexte
- Plan TDD dédié (`docs/superpowers/plans/2026-06-19-lst-57-rbac-fin.md`, 7 phases A→G). Source de vérité = **implémentation RBAC de Starseed** (le brief attaché au ticket était inaccessible en local — fichier non synchronisé sur le stockage ; cartographié via un agent Explore sur `/home/matthieu/dev_malio/Starseed`). 1 sous-agent par phase, pilotage chrono/MCP/vérif/push sur la session principale.
- 7 commits impl (A `ffed224`, B `ac662e7`, C `5060fb6`, D `48c67a5`, E `1a9eba9`, F `544d4cf`, G `511353c`) + plan `fdc7257`. Tests 131→**147 verts**. Timer impl 1014.
### Décision d'architecture majeure (actée, à valider PO)
- **RBAC additif, `ROLE_ADMIN` = bypass, PAS de colonne `is_admin`** — divergence assumée vs Starseed (qui a supprimé la colonne JSON `roles` au profit de `is_admin`). Lesstime garde `roles` JSON + `getRoles()` (login/JWT/MCP/sidebar #62 reposent dessus) ; le `PermissionVoter` bypass si `in_array('ROLE_ADMIN', $user->getRoles())`. Réécrire l'auth aurait été une régression à haut risque pour zéro bénéfice AC. Migration future vers `is_admin` possible.
### Patterns
- **RBAC = Role + Permission (M2M) + relations User** : `Role`(code snake_case immuable, label, description, isSystem, ManyToMany permissions EAGER), `Permission`(code `module.resource.action` unique, label, module, orphan), `User` reçoit `rbacRoles` (table `user_role`) + `directPermissions` (table `user_permission`), `getEffectivePermissions()` = union triée dédupliquée. Migration **100% additive** (5 CREATE TABLE, zéro DROP/ALTER sur `user`).
- **Permissions déclaratives par module** : `ModuleInterface::permissions(): list<array{code,label}>`, agrégées par `ModuleRegistry::permissions($activeClasses)` (injecte `module=id()`, valide le préfixe). `app:sync-permissions` upsert (revive orphan / updateMetadata / create) + markOrphan des absentes. `app:seed-rbac` seede les rôles système (`admin`/`user`, isSystem) — **sans matrice métier** tant qu'aucune permission métier n'existe (les modules 2.x ajouteront leurs permissions + rôles).
- **Voter pur + bypass applicatif** : `PermissionVoter` (regex `/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/` pour `supports`, donc abstient sur `ROLE_*`/`IS_AUTHENTICATED_*`). Le bypass admin de la **sidebar** est dans `SidebarProvider` (si ROLE_ADMIN → injecte le catalogue complet `ModuleRegistry::permissions()`), pas dans `SidebarFilter` qui reste un filtre pur (`permissionSatisfied()`). Le seed n'attachant aucune permission, sans ce bypass l'admin ne verrait rien.
- **Front** : `usePermissions()` (`can/canAny/canAll/isAdmin`) dans `modules/core/composables/` (auto-importé) ; type `UserData` enrichi de `effectivePermissions` ; onglet `AdminRoleTab`+`RoleDrawer` dans `frontend/components/admin/` (le scan `components` Nuxt ne couvre que `~/components`, PAS les layers `modules/*` → les composants vont dans `components/`, le composable/services dans `modules/core/`).
### Gotchas
- **`Symfony\Component\Serializer\Annotation\Groups` N'EXISTE PLUS en Symfony 8** — seul `Attribute\Groups` existe. Un import `Annotation\Groups` rend tous les `#[Groups]` **no-op silencieux** (sérialisation cassée, POST en 400 car le constructeur n'est pas alimenté). Bug latent introduit en Phase A, révélé seulement par les tests fonctionnels de Phase D (TDD). Toujours utiliser `Attribute\Groups`. Vérifier la cohérence sur TOUTES les entités.
- **`isSystem` exposé sous la clé `system`** : PropertyInfo strippe le préfixe `is`. Mettre `#[Groups]` + `#[SerializedName('isSystem')]` sur le getter pour conserver `isSystem` côté API.
- **`options: ['comment' => ...]` sur les colonnes des entités** : sans le mapping `options.comment`, les `COMMENT ON COLUMN` de la migration créent une dérive `migrations:diff` perpétuelle (Doctrine veut les remettre à `''`). Aligner le mapping entité sur le COMMENT de la migration.
- **`make db-reset` détruit `lesstime_test`** (`docker compose down -v` supprime le volume) — les tests tournent sur la base suffixée `_test`. Après un db-reset, recréer la base de test : `doctrine:database:create --env=test --if-not-exists` + `migrations:migrate -n --env=test` + `fixtures:load -n --env=test`. Ne jamais lancer `make db-reset` depuis un sous-agent de phase.
- **Signature `Voter::voteOnAttribute`** : la version Symfony installée impose `voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool` (4e param). Sans lui : « Declaration must be compatible » fatal.
### MR / Git
- **MR empilées sur Gitea** (`tea pr create --base <branche-précédente>`) reflètent la chaîne de dépendances (#56→develop, #62#56, #63#62, #57#63) avec des diffs propres ; Gitea re-cible la base à chaque merge. `tea pr` n'a pas d'`edit` → pour sortir une MR du brouillon (retrait `WIP:`), PATCH API Gitea `/repos/{o}/{r}/pulls/{n}` avec le token de `~/.config/tea/config.yml`.
- **WIP en cours** : pousser la branche d'un ticket en cours + ouvrir la MR en brouillon (titre `WIP:`) sauvegarde le travail sans signaler « prêt à merger » ; re-pousser à chaque phase. Le push ne lock pas l'index → aucune contention avec un sous-agent qui committe en parallèle.
## Meta-learnings ## Meta-learnings
- **Parallélisation**: Les tickets touchant des fichiers indépendants peuvent tourner en parallèle sans problème - **Parallélisation**: Les tickets touchant des fichiers indépendants peuvent tourner en parallèle sans problème
- **Commits concurrents**: NE PAS lancer deux sous-agents qui committent sur le même repo en parallèle (collision `.git/index.lock`) — séquencer. - **Commits concurrents**: NE PAS lancer deux sous-agents qui committent sur le même repo en parallèle (collision `.git/index.lock`) — séquencer.
@@ -145,25 +119,3 @@
- **MCP status**: Toujours mettre "En cours" AVANT de commencer, "Terminé" APRÈS validation - **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 - **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 - **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 # POSTGRES_PORT=5435
# XDEBUG_CLIENT_HOST=host.docker.internal # XDEBUG_CLIENT_HOST=host.docker.internal
# ===========================================================================
# Error tracking — GlitchTip (compatible SDK Sentry)
# ===========================================================================
# DSN du projet GlitchTip "lesstime-api" (BACKEND, runtime).
# Actif uniquement en prod (bundle prod-only). Vide/absent => Sentry inerte.
# A definir dans infra/prod/.env (pas en dev). Ex : http://<cle>@glitchtip.interne:<port>/<id>
# SENTRY_DSN=
# NB : le DSN FRONT (lesstime-front) et l'upload des source maps sont fournis
# au BUILD de l'image, pas au runtime. Voir infra/prod/Dockerfile (ARG) et la
# CI .gitea/workflows/build-docker.yml (build-args depuis les secrets Gitea) :
# NUXT_PUBLIC_SENTRY_DSN, SENTRY_URL, SENTRY_ORG, SENTRY_PROJECT, SENTRY_AUTH_TOKEN
# =========================================================================== # ===========================================================================
# Frontend (frontend/.env) # Frontend (frontend/.env)
# =========================================================================== # ===========================================================================
-5
View File
@@ -20,11 +20,6 @@ jobs:
run: | run: |
docker build \ docker build \
-f infra/prod/Dockerfile \ -f infra/prod/Dockerfile \
--build-arg NUXT_PUBLIC_SENTRY_DSN="${{ secrets.SENTRY_FRONT_DSN }}" \
--build-arg SENTRY_URL="${{ secrets.SENTRY_URL }}" \
--build-arg SENTRY_ORG="${{ secrets.SENTRY_ORG }}" \
--build-arg SENTRY_PROJECT="${{ secrets.SENTRY_FRONT_PROJECT }}" \
--build-arg SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}" \
-t gitea.malio.fr/malio-dev/lesstime:${{ gitea.ref_name }} \ -t gitea.malio.fr/malio-dev/lesstime:${{ gitea.ref_name }} \
-t gitea.malio.fr/malio-dev/lesstime:latest \ -t gitea.malio.fr/malio-dev/lesstime:latest \
. .
-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": { "mcpServers": {
"lesstime": {
"type": "http",
"url": "http://project.malio-dev.fr/_mcp",
"headers": {
"Authorization": "Bearer 7e8b410a5b79b5c0432951dcee3a3a81e0731e86d9f70d8784ec079a2b759c64"
}
},
"lesstime-local": { "lesstime-local": {
"command": "docker", "command": "docker",
"args": [ "args": [
-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`) - Config Docker : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`)
- Après modif nginx : `docker restart nginx-lesstime` - 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 ## Fixtures
- User admin : `admin` / `admin` (ROLE_ADMIN) - 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 Gitea (issues, repos)
- Intégration Mail IMAP (boîte partagée OVH, voir `docs/mail-integration.md`) - Intégration Mail IMAP (boîte partagée OVH, voir `docs/mail-integration.md`)
- Serveur MCP pour assistants IA - Serveur MCP pour assistants IA
- Error tracking centralisé back + front (GlitchTip / SDK Sentry, prod uniquement — voir « Error tracking »)
- Multi-langue (i18n) - Multi-langue (i18n)
## Prérequis ## Prérequis
@@ -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) | | `CORS_ALLOW_ORIGIN` | Origines CORS autorisées | localhost | ✅ (domaine prod) |
| **`ENCRYPTION_KEY`** | **Clé hex 32 bytes chiffrant les credentials IMAP/SMTP (feature mail)** | placeholder | ✅ — doit rester **stable**, sinon les credentials mail stockés deviennent illisibles | | **`ENCRYPTION_KEY`** | **Clé hex 32 bytes chiffrant les credentials IMAP/SMTP (feature mail)** | placeholder | ✅ — doit rester **stable**, sinon les credentials mail stockés deviennent illisibles |
| **`LOCK_DSN`** | **Store de verrous Symfony pour la sync mail (anti-chevauchement)** | `flock` | `flock` suffit | | **`LOCK_DSN`** | **Store de verrous Symfony pour la sync mail (anti-chevauchement)** | `flock` | `flock` suffit |
| `SENTRY_DSN` | Error tracking **backend** → GlitchTip (projet `lesstime-api`) | _(vide)_ | ⚪ optionnel — active le tracking (voir « Error tracking ») |
> **Messagerie** : `ENCRYPTION_KEY` et `LOCK_DSN` sont introduites par l'intégration mail. > **Messagerie** : `ENCRYPTION_KEY` et `LOCK_DSN` sont introduites par l'intégration mail.
> Détails de config et cron de synchronisation : `docs/mail-integration.md` et `docs/mail-cron-setup.md`. > Détails de config et cron de synchronisation : `docs/mail-integration.md` et `docs/mail-cron-setup.md`.
@@ -219,60 +217,28 @@ Lesstime expose un serveur MCP (Model Context Protocol) permettant aux assistant
} }
``` ```
### Configuration réseau (HTTP) — par poste, hors git ### Configuration réseau (HTTP)
Le transport HTTP nécessite un **token API** (Bearer), qui est un **secret** : il ne va **jamais**
dans le `.mcp.json` versionné (celui-ci ne contient que le serveur STDIO local, sans secret).
Chaque développeur configure le serveur HTTP dans sa **config Claude Code locale**.
**Méthode recommandée (identique sur Fedora, Windows et macOS) :**
```bash
claude mcp add --transport http --scope user lesstime \
http://project.malio-dev.fr/_mcp \
--header "Authorization: Bearer <api-token>"
```
- En prod : `http://project.malio-dev.fr/_mcp`
- En réseau local : `http://<ip-serveur>:8082/_mcp`
**Où c'est stocké** (si tu édites le fichier à la main, sous la clé `mcpServers`) :
| OS | Fichier de config Claude Code |
|----|-------------------------------|
| **Fedora / Linux** | `~/.claude.json` |
| **Windows** (collègue) | `%USERPROFILE%\.claude.json` (ex. `C:\Users\<user>\.claude.json`) |
| **macOS** | `~/.claude.json` |
```json ```json
{ {
"mcpServers": { "mcpServers": {
"lesstime": { "lesstime": {
"type": "http", "type": "url",
"url": "http://project.malio-dev.fr/_mcp", "url": "http://<ip-serveur>:8082/_mcp",
"headers": { "Authorization": "Bearer <api-token>" } "headers": {
"Authorization": "Bearer <api-token>"
}
} }
} }
} }
``` ```
Après modification, relancer la connexion avec `/mcp` dans Claude Code.
### Gestion des tokens API ### Gestion des tokens API
Générer / régénérer un token pour un utilisateur :
```bash ```bash
# En dev (container local)
docker exec -u www-data php-lesstime-fpm php bin/console app:generate-api-token <username> docker exec -u www-data php-lesstime-fpm php bin/console app:generate-api-token <username>
# En prod (sur le serveur, dans infra/prod)
sudo docker compose exec -T -u www-data app php bin/console app:generate-api-token <username>
``` ```
⚠️ Le token est **invalidé à chaque reset/reseed de la base**. Symptôme : `/mcp` renvoie
`HTTP 401 "Invalid API token"`. Il faut alors le **régénérer** (commande ci-dessus) puis remplacer
la valeur `Bearer ...` dans ta config locale (par poste).
## Déploiement ## Déploiement
La prod tourne en **Docker** : l'image est buildée par la CI Gitea sur push de tag `v*` La prod tourne en **Docker** : l'image est buildée par la CI Gitea sur push de tag `v*`
@@ -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) : et vide le cache. Guide complet (première installation, BDD, Nginx, JWT, rollback) :
**`doc/deployment-docker.md`**. **`doc/deployment-docker.md`**.
## Error tracking (GlitchTip)
Les erreurs **backend** et **frontend** sont remontées vers **GlitchTip** (instance auto-hébergée
interne, compatible SDK Sentry) qui les **groupe par projet** et compte les occurrences. Activé
**uniquement en prod** : en dev, sans DSN, le SDK est inerte (zéro impact). Ticket de référence :
INFRA #146.
### Pourquoi back et front se configurent différemment
| | Backend (Symfony) | Frontend (Nuxt SPA) |
|---|---|---|
| Nature | process PHP qui tourne en continu | fichiers JS/HTML **statiques** (`nuxt generate`) |
| Quand le DSN est lu | au **runtime** | **figé au build** (baké dans le JS) |
| Où mettre le DSN | `.env` du serveur (`/var/www/lesstime/.env`) — runtime | **secrets Gitea** → build-args de la CI |
> Les erreurs partent **toujours vers GlitchTip**, jamais vers la CI. La CI ne sert qu'à *écrire*
> le DSN front dans le bundle au moment du build (il n'y a aucun process front en prod qui
> pourrait lire une variable d'environnement).
### Variables
**Backend — fichier `.env` du serveur** (`/var/www/lesstime/.env`, chargé via `env_file` ; le repo ne fournit que le template `infra/prod/.env.example`) :
```env
SENTRY_DSN=http://<clé>@glitchtip.interne:<port>/<id-projet-api>
```
**Frontend — secrets Gitea** (repo → Settings → Actions → Secrets), consommés par
`.gitea/workflows/build-docker.yml` :
| Secret Gitea | Rôle |
|---|---|
| `SENTRY_FRONT_DSN` | DSN du projet `lesstime-front` (public, baké dans le JS) |
| `SENTRY_URL` | URL de l'instance GlitchTip |
| `SENTRY_ORG` | slug de l'organisation GlitchTip |
| `SENTRY_FRONT_PROJECT` | slug du projet front |
| `SENTRY_AUTH_TOKEN` | token d'upload des **source maps** (vrai secret) |
> Sans source maps, seul `SENTRY_FRONT_DSN` est requis (les stacktraces front seront sur du JS
> minifié). Le build n'échoue pas si les autres secrets sont absents.
### Fichiers concernés
| Fichier | Rôle |
|---|---|
| `config/packages/sentry.yaml` | conf backend (prod-only, exceptions, 4xx ignorés, release = `app.version`) |
| `config/bundles.php` | `SentryBundle` enregistré `['prod' => true]` |
| `frontend/nuxt.config.ts` | module Sentry chargé **uniquement si DSN présent** + upload source maps |
| `frontend/sentry.client.config.ts` | init du SDK client (no-op si DSN vide) |
| `infra/prod/Dockerfile` | build-args front (`NUXT_PUBLIC_SENTRY_DSN`, `SENTRY_*`) |
| `.gitea/workflows/build-docker.yml` | injection des secrets Gitea en build-args |
### Activation (résumé)
1. Dans GlitchTip : créer les projets `lesstime-api` et `lesstime-front`, récupérer les 2 DSN
(+ un auth token pour les source maps).
2. Backend : ajouter `SENTRY_DSN` dans le `.env` du serveur (`/var/www/lesstime/.env`).
3. Frontend : ajouter les secrets Gitea ci-dessus.
4. Tagger une version (`v*`) → la CI build l'image avec le DSN front baké → `deploy.sh`.
### Certificat HTTPS interne (CA auto-signée)
GlitchTip est servi en **HTTPS** sur `https://logs.malio-dev.fr` (nginx devant), avec un certificat
**auto-signé** par une **CA interne** (« MALIO-DEV Local Root CA », cert serveur `*.malio-dev.fr`).
`malio-dev.fr` est un **domaine interne uniquement** (DNS local, pas de résolution publique).
> **Pourquoi pas Let's Encrypt ?** Une CA publique doit valider le domaine via Internet (challenge
> HTTP ou DNS public). Comme `malio-dev.fr` n'existe qu'en interne, aucune validation n'est
> possible → on reste sur la CA interne, qu'il faut faire **approuver partout** où la connexion TLS
> est établie. Tant que la CA n'est pas approuvée, **rien ne remonte** : le backend logue
> « Message not sent » (SDK Sentry) et le navigateur affiche « connexion non sécurisée » (le front
> n'envoie rien).
**Qui doit faire confiance à la CA ?** La connexion à `logs.malio-dev.fr` part de deux endroits
différents, donc deux fixes distincts :
| Émetteur des erreurs | Qui établit le TLS | Où approuver la CA |
|---|---|---|
| Backend (Symfony) | le **container PHP** | CA bakée dans l'**image Docker** (ci-dessous) |
| Frontend (SPA) | le **navigateur du poste** | CA poussée sur les **postes via GPO** (ci-dessous) |
#### Fix backend — CA bakée dans l'image
Le certificat **public** de la root CA est committé dans le repo (`infra/prod/malio-dev-root-ca.crt`,
aucune clé privée) et installé dans le trust store du container au build (`infra/prod/Dockerfile`,
stage production — `ca-certificates` est déjà installé) :
```dockerfile
COPY infra/prod/malio-dev-root-ca.crt /usr/local/share/ca-certificates/malio-dev-root-ca.crt
RUN update-ca-certificates
```
Le container fait alors confiance à tout `*.malio-dev.fr` interne et le SDK Sentry backend peut
envoyer. Vérification :
```bash
curl --cacert infra/prod/malio-dev-root-ca.crt https://logs.malio-dev.fr/api/1/store/ # → HTTP 200
```
#### Fix postes — CA poussée par GPO (Active Directory)
Le front est une SPA : c'est le **navigateur de l'utilisateur** qui contacte `logs.malio-dev.fr`,
donc c'est le **poste** qui doit faire confiance à la CA (la CA de l'image ne sert qu'au backend).
Sur le domaine Active Directory, on pousse la CA **une seule fois via GPO** plutôt que poste par poste :
1. Contrôleur de domaine → **Group Policy Management** → éditer une GPO.
2. `Configuration ordinateur → Stratégies → Paramètres Windows → Paramètres de sécurité → Stratégies
de clé publique → Autorités de certification racines de confiance`.
3. Clic droit → **Importer** → sélectionner `rootCA.crt` (« MALIO-DEV Local Root CA »).
4. Sur les postes : `gpupdate /force` (ou attendre le rafraîchissement), puis **redémarrer le navigateur**.
- Chrome / Edge utilisent le magasin Windows → confiance automatique.
- ⚠️ **Firefox** a son propre magasin : activer `security.enterprise_roots.enabled = true`
(`about:config` ou via policy) pour qu'il lise le magasin Windows.
> **Validation poste** : ouvrir `https://logs.malio-dev.fr` → cadenas vert sans avertissement = CA
> approuvée = le front peut envoyer.
#### Renouvellement / changement de CA
Si la CA interne change (rotation, expiration) :
1. Remplacer `infra/prod/malio-dev-root-ca.crt` par le nouveau certificat public, commit + **rebuild
de l'image** (re-tag `v*`) pour le backend.
2. **Re-pousser** la nouvelle CA via GPO (étapes ci-dessus) pour les postes.
## Licence ## Licence
Propriétaire — Tous droits réservés. Propriétaire — Tous droits réservés.
-1
View File
@@ -20,7 +20,6 @@
"phpoffice/phpspreadsheet": "^5.5", "phpoffice/phpspreadsheet": "^5.5",
"phpstan/phpdoc-parser": "^2.3", "phpstan/phpdoc-parser": "^2.3",
"sabre/vobject": "^4.5", "sabre/vobject": "^4.5",
"sentry/sentry-symfony": "^5.10",
"symfony/asset": "8.0.*", "symfony/asset": "8.0.*",
"symfony/console": "8.0.*", "symfony/console": "8.0.*",
"symfony/doctrine-messenger": "^8.0", "symfony/doctrine-messenger": "^8.0",
Generated
+1 -419
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "106755bef51fd069316cd7f3a7e1a0b6", "content-hash": "eee87b9c0011fb88523cb5aea0de29ba",
"packages": [ "packages": [
{ {
"name": "api-platform/doctrine-common", "name": "api-platform/doctrine-common",
@@ -2508,125 +2508,6 @@
}, },
"time": "2026-02-08T16:21:46+00:00" "time": "2026-02-08T16:21:46+00:00"
}, },
{
"name": "guzzlehttp/psr7",
"version": "2.12.3",
"source": {
"type": "git",
"url": "https://github.com/guzzle/psr7.git",
"reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/psr7/zipball/7ec62dc3f44aa218487dbed81a9bf9bc647be55d",
"reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0",
"psr/http-factory": "^1.0",
"psr/http-message": "^1.1 || ^2.0",
"ralouphie/getallheaders": "^3.0",
"symfony/deprecation-contracts": "^2.5 || ^3.0",
"symfony/polyfill-php80": "^1.25"
},
"provide": {
"psr/http-factory-implementation": "1.0",
"psr/http-message-implementation": "1.0"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"http-interop/http-factory-tests": "1.1.0",
"jshttp/mime-db": "1.54.0.1",
"phpunit/phpunit": "^8.5.52 || ^9.6.34"
},
"suggest": {
"laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
},
"type": "library",
"extra": {
"bamarni-bin": {
"bin-links": true,
"forward-command": false
}
},
"autoload": {
"psr-4": {
"GuzzleHttp\\Psr7\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
},
{
"name": "George Mponos",
"email": "gmponos@gmail.com",
"homepage": "https://github.com/gmponos"
},
{
"name": "Tobias Nyholm",
"email": "tobias.nyholm@gmail.com",
"homepage": "https://github.com/Nyholm"
},
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com",
"homepage": "https://github.com/sagikazarmark"
},
{
"name": "Tobias Schultze",
"email": "webmaster@tubo-world.de",
"homepage": "https://github.com/Tobion"
},
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com",
"homepage": "https://sagikazarmark.hu"
}
],
"description": "PSR-7 message implementation that also provides common utility methods",
"keywords": [
"http",
"message",
"psr-7",
"request",
"response",
"stream",
"uri",
"url"
],
"support": {
"issues": "https://github.com/guzzle/psr7/issues",
"source": "https://github.com/guzzle/psr7/tree/2.12.3"
},
"funding": [
{
"url": "https://github.com/GrahamCampbell",
"type": "github"
},
{
"url": "https://github.com/Nyholm",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7",
"type": "tidelift"
}
],
"time": "2026-06-23T15:21:08+00:00"
},
{ {
"name": "icewind/smb", "name": "icewind/smb",
"version": "3.8.1", "version": "3.8.1",
@@ -3079,66 +2960,6 @@
}, },
"time": "2026-05-04T12:34:54+00:00" "time": "2026-05-04T12:34:54+00:00"
}, },
{
"name": "jean85/pretty-package-versions",
"version": "2.1.1",
"source": {
"type": "git",
"url": "https://github.com/Jean85/pretty-package-versions.git",
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a",
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a",
"shasum": ""
},
"require": {
"composer-runtime-api": "^2.1.0",
"php": "^7.4|^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.2",
"jean85/composer-provided-replaced-stub-package": "^1.0",
"phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^7.5|^8.5|^9.6",
"rector/rector": "^2.0",
"vimeo/psalm": "^4.3 || ^5.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Jean85\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Alessandro Lai",
"email": "alessandro.lai85@gmail.com"
}
],
"description": "A library to get pretty versions strings of installed dependencies",
"keywords": [
"composer",
"package",
"release",
"versions"
],
"support": {
"issues": "https://github.com/Jean85/pretty-package-versions/issues",
"source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1"
},
"time": "2025-03-19T14:43:43+00:00"
},
{ {
"name": "lcobucci/jwt", "name": "lcobucci/jwt",
"version": "5.6.0", "version": "5.6.0",
@@ -5118,50 +4939,6 @@
}, },
"time": "2021-10-29T13:26:27+00:00" "time": "2021-10-29T13:26:27+00:00"
}, },
{
"name": "ralouphie/getallheaders",
"version": "3.0.3",
"source": {
"type": "git",
"url": "https://github.com/ralouphie/getallheaders.git",
"reference": "120b605dfeb996808c31b6477290a714d356e822"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822",
"reference": "120b605dfeb996808c31b6477290a714d356e822",
"shasum": ""
},
"require": {
"php": ">=5.6"
},
"require-dev": {
"php-coveralls/php-coveralls": "^2.1",
"phpunit/phpunit": "^5 || ^6.5"
},
"type": "library",
"autoload": {
"files": [
"src/getallheaders.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ralph Khattar",
"email": "ralph.khattar@gmail.com"
}
],
"description": "A polyfill for getallheaders.",
"support": {
"issues": "https://github.com/ralouphie/getallheaders/issues",
"source": "https://github.com/ralouphie/getallheaders/tree/develop"
},
"time": "2019-03-08T08:55:37+00:00"
},
{ {
"name": "sabre/uri", "name": "sabre/uri",
"version": "3.0.2", "version": "3.0.2",
@@ -5395,201 +5172,6 @@
}, },
"time": "2024-09-06T08:00:55+00:00" "time": "2024-09-06T08:00:55+00:00"
}, },
{
"name": "sentry/sentry",
"version": "4.28.0",
"source": {
"type": "git",
"url": "https://github.com/getsentry/sentry-php.git",
"reference": "662cb7a01a342a7f33780fc955ff4a028d8b785a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/getsentry/sentry-php/zipball/662cb7a01a342a7f33780fc955ff4a028d8b785a",
"reference": "662cb7a01a342a7f33780fc955ff4a028d8b785a",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"guzzlehttp/psr7": "^1.8.4|^2.1.1",
"jean85/pretty-package-versions": "^1.5|^2.0.4",
"php": "^7.2|^8.0",
"psr/log": "^1.0|^2.0|^3.0",
"symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0|^8.0"
},
"conflict": {
"raven/raven": "*"
},
"require-dev": {
"carthage-software/mago": "1.30.0",
"friendsofphp/php-cs-fixer": "^3.4",
"guzzlehttp/promises": "^2.0.3",
"monolog/monolog": "^1.6|^2.0|^3.0",
"nyholm/psr7": "^1.8",
"open-telemetry/api": "^1.0",
"open-telemetry/exporter-otlp": "^1.0",
"open-telemetry/sdk": "^1.0",
"phpstan/phpstan": "^1.3",
"phpunit/phpunit": "^8.5.52|^9.6.34",
"spiral/roadrunner-http": "^3.6",
"spiral/roadrunner-worker": "^3.6"
},
"suggest": {
"ext-excimer": "Enable Sentry profiling with the Excimer PHP extension.",
"monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler."
},
"type": "library",
"autoload": {
"files": [
"src/functions.php"
],
"psr-4": {
"Sentry\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Sentry",
"email": "accounts@sentry.io"
}
],
"description": "PHP SDK for Sentry (http://sentry.io)",
"homepage": "http://sentry.io",
"keywords": [
"crash-reporting",
"crash-reports",
"error-handler",
"error-monitoring",
"log",
"logging",
"profiling",
"sentry",
"tracing"
],
"support": {
"issues": "https://github.com/getsentry/sentry-php/issues",
"source": "https://github.com/getsentry/sentry-php/tree/4.28.0"
},
"funding": [
{
"url": "https://sentry.io/",
"type": "custom"
},
{
"url": "https://sentry.io/pricing/",
"type": "custom"
}
],
"time": "2026-06-11T12:22:38+00:00"
},
{
"name": "sentry/sentry-symfony",
"version": "5.10.0",
"source": {
"type": "git",
"url": "https://github.com/getsentry/sentry-symfony.git",
"reference": "6f49255f4cdcfc43a3a283bd3a1f65d483e9192f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/getsentry/sentry-symfony/zipball/6f49255f4cdcfc43a3a283bd3a1f65d483e9192f",
"reference": "6f49255f4cdcfc43a3a283bd3a1f65d483e9192f",
"shasum": ""
},
"require": {
"guzzlehttp/psr7": "^2.1.1",
"jean85/pretty-package-versions": "^1.5||^2.0",
"php": "^7.2||^8.0",
"sentry/sentry": "^4.23.0",
"symfony/cache-contracts": "^1.1||^2.4||^3.0",
"symfony/config": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/console": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/dependency-injection": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/event-dispatcher": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/http-kernel": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/polyfill-php80": "^1.22",
"symfony/psr-http-message-bridge": "^1.2||^2.0||^6.4||^7.0||^8.0",
"symfony/yaml": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0"
},
"require-dev": {
"doctrine/dbal": "^2.13||^3.3||^4.0",
"doctrine/doctrine-bundle": "^2.6||^3.0",
"friendsofphp/php-cs-fixer": "^2.19||^3.40",
"masterminds/html5": "^2.8",
"phpstan/extension-installer": "^1.0",
"phpstan/phpstan": "1.12.5",
"phpstan/phpstan-phpunit": "1.4.0",
"phpstan/phpstan-symfony": "1.4.10",
"phpunit/phpunit": "^8.5.40||^9.6.21",
"symfony/browser-kit": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/cache": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/dom-crawler": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/framework-bundle": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/http-client": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/messenger": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/monolog-bundle": "^3.4||^4.0",
"symfony/phpunit-bridge": "^5.2.6||^6.0||^7.0||^8.0",
"symfony/process": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/security-core": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/security-http": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"symfony/twig-bundle": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
"vimeo/psalm": "^4.3||^5.16.0"
},
"suggest": {
"doctrine/doctrine-bundle": "Allow distributed tracing of database queries using Sentry.",
"monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler.",
"symfony/cache": "Allow distributed tracing of cache pools using Sentry.",
"symfony/twig-bundle": "Allow distributed tracing of Twig template rendering using Sentry."
},
"type": "symfony-bundle",
"autoload": {
"files": [
"src/aliases.php"
],
"psr-4": {
"Sentry\\SentryBundle\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Sentry",
"email": "accounts@sentry.io"
}
],
"description": "Symfony integration for Sentry (http://getsentry.com)",
"homepage": "http://getsentry.com",
"keywords": [
"errors",
"logging",
"sentry",
"symfony"
],
"support": {
"issues": "https://github.com/getsentry/sentry-symfony/issues",
"source": "https://github.com/getsentry/sentry-symfony/tree/5.10.0"
},
"funding": [
{
"url": "https://sentry.io/",
"type": "custom"
},
{
"url": "https://sentry.io/pricing/",
"type": "custom"
}
],
"time": "2026-04-01T14:50:32+00:00"
},
{ {
"name": "symfony/asset", "name": "symfony/asset",
"version": "v8.0.6", "version": "v8.0.6",
-2
View File
@@ -8,7 +8,6 @@ use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle;
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle; use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle; use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
use Nelmio\CorsBundle\NelmioCorsBundle; use Nelmio\CorsBundle\NelmioCorsBundle;
use Sentry\SentryBundle\SentryBundle;
use Symfony\AI\McpBundle\McpBundle; use Symfony\AI\McpBundle\McpBundle;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle; use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\MonologBundle\MonologBundle; use Symfony\Bundle\MonologBundle\MonologBundle;
@@ -25,5 +24,4 @@ return [
LexikJWTAuthenticationBundle::class => ['all' => true], LexikJWTAuthenticationBundle::class => ['all' => true],
McpBundle::class => ['all' => true], McpBundle::class => ['all' => true],
MonologBundle::class => ['all' => true], MonologBundle::class => ['all' => true],
SentryBundle::class => ['prod' => true],
]; ];
-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. * 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\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 [ return [
CoreModule::class, 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: api_platform:
title: Lesstime API title: Lesstime API
version: 1.0.0 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: formats:
jsonld: ['application/ld+json'] jsonld: ['application/ld+json']
json: ['application/json'] json: ['application/json']
+12 -52
View File
@@ -1,19 +1,12 @@
doctrine: doctrine:
dbal: 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)%' 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%' 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)%'
orm: orm:
validate_xml_mapping: true validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
@@ -22,58 +15,25 @@ doctrine:
auto_mapping: true auto_mapping: true
resolve_target_entities: resolve_target_entities:
App\Shared\Domain\Contract\UserInterface: App\Module\Core\Domain\Entity\User 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: mappings:
App:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
Core: Core:
type: attribute type: attribute
is_bundle: false is_bundle: false
dir: '%kernel.project_dir%/src/Module/Core/Domain/Entity' dir: '%kernel.project_dir%/src/Module/Core/Domain/Entity'
prefix: 'App\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: controller_resolver:
auto_mapping: false auto_mapping: false
when@test: when@test:
doctrine: doctrine:
dbal: dbal:
# Propagate the _test suffix to BOTH connections: the audit # "TEST_TOKEN" is typically set by ParaTest
# 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)%' dbname_suffix: '_test%env(default::TEST_TOKEN)%'
when@prod: when@prod:
+1 -1
View File
@@ -23,7 +23,7 @@ framework:
# messenger:consume à maintenir. La sync de fond reste assurée par le cron OS # 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 # (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. # 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: when@test:
framework: framework:
-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{ * @psalm-type ConfigType = array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
* parameters?: ParametersConfig, * parameters?: ParametersConfig,
@@ -1876,7 +1792,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig, * lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
* mcp?: McpConfig, * mcp?: McpConfig,
* monolog?: MonologConfig, * monolog?: MonologConfig,
* sentry?: SentryConfig,
* }, * },
* "when@test"?: array{ * "when@test"?: array{
* imports?: ImportsConfig, * imports?: ImportsConfig,
+9 -89
View File
@@ -31,122 +31,42 @@ services:
# add more service definitions when explicit configuration is needed # add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones # please note that last definitions always *replace* previous ones
App\Module\ProjectManagement\Infrastructure\EventListener\TaskDocumentListener: App\EventListener\TaskDocumentListener:
arguments: arguments:
$uploadDir: '%task_document_upload_dir%' $uploadDir: '%task_document_upload_dir%'
tags: tags:
- { name: doctrine.orm.entity_listener } - { name: doctrine.orm.entity_listener }
App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\TaskDocumentProcessor: App\State\TaskDocumentProcessor:
arguments: arguments:
$uploadDir: '%task_document_upload_dir%' $uploadDir: '%task_document_upload_dir%'
App\Module\ProjectManagement\Infrastructure\Controller\TaskDocumentDownloadController: App\Controller\TaskDocumentDownloadController:
arguments: arguments:
$uploadDir: '%task_document_upload_dir%' $uploadDir: '%task_document_upload_dir%'
App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Task\AddTaskDocumentTool: App\Mcp\Tool\Task\AddTaskDocumentTool:
arguments: arguments:
$uploadDir: '%task_document_upload_dir%' $uploadDir: '%task_document_upload_dir%'
App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Task\UpdateTaskDocumentTool: App\Mcp\Tool\Task\UpdateTaskDocumentTool:
arguments: arguments:
$uploadDir: '%task_document_upload_dir%' $uploadDir: '%task_document_upload_dir%'
App\Module\Core\Infrastructure\Controller\UserAvatarController: App\Controller\UserAvatarController:
arguments: arguments:
$avatarUploadDir: '%avatar_upload_dir%' $avatarUploadDir: '%avatar_upload_dir%'
App\Module\Absence\Infrastructure\Controller\AbsenceJustificationUploadController: App\Controller\Absence\AbsenceJustificationUploadController:
arguments: arguments:
$uploadDir: '%absence_justification_upload_dir%' $uploadDir: '%absence_justification_upload_dir%'
App\Module\Absence\Infrastructure\Controller\AbsenceJustificationDownloadController: App\Controller\Absence\AbsenceJustificationDownloadController:
arguments: arguments:
$uploadDir: '%absence_justification_upload_dir%' $uploadDir: '%absence_justification_upload_dir%'
App\Module\Integration\Domain\Service\FileSource: '@App\Module\Integration\Infrastructure\Service\SmbFileSource' App\Service\Share\FileSource: '@App\Service\Share\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\Module\Core\Domain\Repository\UserRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository' App\Module\Core\Domain\Repository\UserRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository'
App\Module\Core\Domain\Repository\PermissionRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository'
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\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' App\Shared\Domain\Contract\NotifierInterface: '@App\Module\Core\Infrastructure\Notifier'
+9 -26
View File
@@ -4,17 +4,11 @@ declare(strict_types=1);
/* /*
* Définition de la sidebar (sections + items) — navigation GLOBALE uniquement. * Définition de la sidebar (sections + items) — navigation GLOBALE uniquement.
* Filtrée par SidebarFilter : * Filtrée par SidebarFilter : `module` (route ajoutée à disabledRoutes si module inactif),
* - `module` : route ajoutée à disabledRoutes si module inactif ; * `roles` (section ou item masqué si l'utilisateur n'a aucun des rôles listés ; gate minimal,
* - `roles` : section ou item masqué si l'utilisateur n'a aucun des rôles listés (gate minimal) ; * le RBAC fin par permission arrive en #1.2).
* - `permission` : section ou item masqué si la permission effective absente (RBAC fin — * Les items contextuels (Kanban/Groupes/Archives), feature-flag (Documents, Mail) et user-flag
* `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
* (Mes absences) restent rendus côté layout, hors de cet endpoint. * (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>). * Les labels sont des clés i18n (sidebar.<domaine>.<item>).
*/ */
return [ return [
@@ -23,18 +17,9 @@ return [
'icon' => 'mdi:view-dashboard-outline', 'icon' => 'mdi:view-dashboard-outline',
'items' => [ 'items' => [
['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'], ['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.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline'],
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline', 'module' => 'project-management', 'permission' => 'project-management.projects.view'], ['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline'],
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline', 'module' => 'time-tracking', 'permission' => 'time-tracking.entries.view'], ['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline'],
],
],
[
'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'],
], ],
], ],
[ [
@@ -42,10 +27,8 @@ return [
'icon' => 'mdi:cog-outline', 'icon' => 'mdi:cog-outline',
'roles' => ['ROLE_ADMIN'], 'roles' => ['ROLE_ADMIN'],
'items' => [ 'items' => [
['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline', 'module' => 'absence'], ['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline'],
['label' => 'sidebar.admin.directory', 'to' => '/directory', 'icon' => 'mdi:card-account-details-outline', 'module' => 'directory'], ['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline'],
['label' => 'sidebar.admin.reporting', 'to' => '/reporting', 'icon' => 'mdi:chart-line', 'module' => 'reporting', 'permission' => 'reporting.view'],
['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline', 'permission' => 'core.users.view'],
], ],
], ],
]; ];
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.4.44' 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..." echo "==> Running migrations..."
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction 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..." echo "==> Clearing cache..."
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
@@ -300,31 +294,7 @@ cd /var/www/lesstime
./deploy.sh v0.3.13 # deploie une version specifique ./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 C'est tout. Le script pull l'image, redemarre le conteneur, lance les migrations et vide le cache.
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
```
--- ---
File diff suppressed because it is too large Load Diff
@@ -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.
+135 -125
View File
@@ -1,31 +1,112 @@
<template> <template>
<div class="h-screen overflow-hidden"> <div class="h-screen overflow-hidden">
<div class="flex h-full"> <div class="flex h-full">
<MalioSidebar <!-- Mobile sidebar overlay -->
v-model="ui.sidebarCollapsed" <Transition name="sidebar-overlay">
:sections="mergedSections" <div
:sidebar-class="ui.sidebarCollapsed ? '' : 'w-[232px]'" 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> <div class="flex items-center overflow-hidden" :class="sidebarIsCollapsed ? 'justify-center p-3' : 'justify-between'">
<img src="/LOGO_MALIO.png" alt="Malio"/> <img
</template> v-if="!sidebarIsCollapsed"
<template #logo-collapsed> src="/malio.png"
<img src="/LOGO_MALIO_COLLAPSED.png" alt="Malio"/> alt="Logo"
</template> class="w-auto"
<template #footer> />
<div class="flex flex-col gap-2"> <img
<SidebarTimer :collapsed="false" /> v-else
<p v-if="version" class="text-center text-sm font-bold">v {{ version }}</p> 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> </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> </template>
<template #footer-collapsed> <!-- Feature-flag : Documents -->
<SidebarTimer :collapsed="true" /> <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>
</MalioSidebar> </template>
</nav>
<div class="px-4 py-3">
<SidebarTimer :collapsed="sidebarIsCollapsed" />
</div>
<div class="flex items-center justify-center p-4">
<p v-if="!sidebarIsCollapsed" class="font-bold">v {{ version }}</p>
</div>
<!-- Collapse toggle button centered vertically on the sidebar edge -->
<button
class="absolute top-1/2 -right-4 z-10 hidden h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full border border-neutral-200 bg-white text-neutral-400 shadow-sm hover:text-neutral-700 transition-colors lg:flex"
:title="ui.sidebarCollapsed ? 'Ouvrir le menu' : 'Réduire le menu'"
@click="ui.toggleSidebar()"
>
<Icon
:name="ui.sidebarCollapsed ? 'mdi:chevron-right' : 'mdi:chevron-left'"
size="18"
/>
</button>
</aside>
<div class="h-full flex-1 flex flex-col min-h-0 min-w-0"> <div class="h-full flex-1 flex flex-col min-h-0 min-w-0">
<AppTopNav :user="auth.user" /> <AppTopNav :user="auth.user" />
<main class="flex flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white px-4 pb-24 sm:px-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/> <slot/>
</main> </main>
</div> </div>
@@ -44,8 +125,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { UserData } from '~/services/dto/user-data' import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/modules/project-management/services/dto/project' import type { Project } from '~/services/dto/project'
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag' import type { TaskTag } from '~/services/dto/task-tag'
import { useAppVersion } from '~/composables/useAppVersion' import { useAppVersion } from '~/composables/useAppVersion'
import type { HydraCollection } from '~/utils/api' import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api' import { extractHydraMembers } from '~/utils/api'
@@ -58,6 +139,18 @@ const route = useRoute()
const { t } = useI18n() const { t } = useI18n()
const { sections } = useSidebar() 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 isEmployee = computed(() => Boolean(auth.user?.isEmployee))
const isMailVisible = computed(() => { const isMailVisible = computed(() => {
@@ -68,116 +161,22 @@ const isMailVisible = computed(() => {
const { enabled: shareEnabled, ensureLoaded: ensureShareStatus } = useShareStatus() const { enabled: shareEnabled, ensureLoaded: ensureShareStatus } = useShareStatus()
const isDocumentsVisible = computed(() => shareEnabled.value === true) const isDocumentsVisible = computed(() => shareEnabled.value === true)
// On mobile, sidebar is always expanded (not collapsed icon mode)
const sidebarIsCollapsed = computed(() => {
if (ui.sidebarOpen) return false
return ui.sidebarCollapsed
})
// Close mobile sidebar on route change
watch(() => route.path, () => {
ui.closeMobileSidebar()
})
const currentProjectId = computed(() => { const currentProjectId = computed(() => {
const match = route.path.match(/^\/projects\/(\d+)/) const match = route.path.match(/^\/projects\/(\d+)/)
return match ? match[1] : null return match ? match[1] : null
}) })
type MalioItem = { label: string; to: string; exact?: boolean }
type MalioSection = { label: string; icon: string; items: MalioItem[] }
// Ordre d'affichage canonique des sections.
const SECTION_ORDER = [
'sidebar.general.section',
'sidebar.tools.section',
'sidebar.admin.section',
] as const
// Icônes de secours pour les sections créées côté client (absentes du backend,
// ex. module mail off mais partage actif section Outils à recréer).
const SECTION_ICON: Record<string, string> = {
'sidebar.general.section': 'mdi:view-dashboard-outline',
'sidebar.tools.section': 'mdi:tools',
'sidebar.admin.section': 'mdi:cog-outline',
}
// Item client avec ancre optionnelle : `after` = `to` de l'item après lequel l'insérer
// (sinon ajouté en fin de section).
type ClientItem = MalioItem & { after?: string }
// Items rendus côté client (dépendent d'un état runtime ignoré du backend).
function clientItemsFor(key: string): ClientItem[] {
if (key === 'sidebar.general.section') {
const items: ClientItem[] = []
if (currentProjectId.value) {
const id = currentProjectId.value
// Insérés juste sous « Projets », dans l'ordre via ancres chaînées.
items.push({ label: t('sidebar.project.kanban'), to: `/projects/${id}`, exact: true, after: '/projects' })
items.push({ label: t('sidebar.project.groups'), to: `/projects/${id}/groups`, after: `/projects/${id}` })
items.push({ label: t('sidebar.project.archives'), to: `/projects/${id}/archives`, after: `/projects/${id}/groups` })
}
if (isEmployee.value) {
items.push({ label: t('sidebar.general.myAbsences'), to: '/absences' })
}
return items
}
if (key === 'sidebar.tools.section') {
const items: ClientItem[] = []
if (isMailVisible.value) {
const n = mailStore.globalUnreadCount
const suffix = n > 0 ? ` (${n > 99 ? '99+' : n})` : ''
items.push({ label: `${t('mail.sidebar.title')}${suffix}`, to: '/mail' })
}
if (isDocumentsVisible.value) {
items.push({ label: t('sharedFiles.sidebar.title'), to: '/documents' })
}
return items
}
return []
}
// Insère les items client après leur ancre (`after`), sinon en fin de liste.
function mergeClientItems(base: MalioItem[], extra: ClientItem[]): MalioItem[] {
const result = [...base]
for (const { after, ...item } of extra) {
const idx = after ? result.findIndex((i) => i.to === after) : -1
if (idx !== -1) {
result.splice(idx + 1, 0, item)
} else {
result.push(item)
}
}
return result
}
const mergedSections = computed<MalioSection[]>(() => {
// 1. Sections backend (déjà filtrées par permissions), mail retiré (ré-injecté côté client).
const backend = new Map<string, MalioSection>()
for (const section of sections.value) {
backend.set(section.label, {
label: t(section.label),
icon: section.icon,
items: section.items
.filter((item) => item.to !== '/mail')
.map((item) => ({ label: t(item.label), to: item.to })),
})
}
// 2. Fusion dans l'ordre canonique.
const result: MalioSection[] = []
for (const key of SECTION_ORDER) {
const base = backend.get(key)
const extra = clientItemsFor(key)
if (base) {
base.items = mergeClientItems(base.items, extra)
if (base.items.length > 0) {
result.push(base)
}
} else if (extra.length > 0) {
result.push({ label: t(key), icon: SECTION_ICON[key] ?? '', items: mergeClientItems([], extra) })
}
}
// 3. Garde-fou : toute section backend hors ordre canonique n'est pas perdue.
for (const [key, section] of backend) {
if (!(SECTION_ORDER as readonly string[]).includes(key) && section.items.length > 0) {
result.push(section)
}
}
return result
})
const timerStore = useTimerStore() const timerStore = useTimerStore()
const baseTitle = ref('Lesstime') const baseTitle = ref('Lesstime')
@@ -265,3 +264,14 @@ function onCompleteSaved() {
}) })
} }
</script> </script>
<style scoped>
.sidebar-overlay-enter-active,
.sidebar-overlay-leave-active {
transition: opacity 0.3s ease;
}
.sidebar-overlay-enter-from,
.sidebar-overlay-leave-to {
opacity: 0;
}
</style>
@@ -19,8 +19,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { AbsenceBalance } from '~/modules/absence/services/dto/absence' import type { AbsenceBalance } from '~/services/dto/absence'
import { useAbsenceService } from '~/modules/absence/services/absences' import { useAbsenceService } from '~/services/absences'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -73,7 +73,8 @@
</template> </template>
<script setup lang="ts"> <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<{ const props = defineProps<{
balances: AbsenceBalance[] balances: AbsenceBalance[]
@@ -52,8 +52,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { AbsenceRequest } from '~/modules/absence/services/dto/absence' import type { AbsenceRequest } from '~/services/dto/absence'
import { useAbsenceService } from '~/modules/absence/services/absences' import { useAbsenceService } from '~/services/absences'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
const props = defineProps<{ const props = defineProps<{
absences: AbsenceRequest[] absences: AbsenceRequest[]
@@ -29,7 +29,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { HalfDay } from '~/modules/absence/services/dto/absence' import type { HalfDay } from '~/services/dto/absence'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
/** ISO date string "YYYY-MM-DD" or null. */ /** ISO date string "YYYY-MM-DD" or null. */
@@ -135,8 +135,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { AbsenceRequest } from '~/modules/absence/services/dto/absence' import type { AbsenceRequest } from '~/services/dto/absence'
import { useAbsenceService } from '~/modules/absence/services/absences' import { useAbsenceService } from '~/services/absences'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -26,8 +26,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { AbsenceRequest } from '~/modules/absence/services/dto/absence' import type { AbsenceRequest } from '~/services/dto/absence'
import { useAbsenceService } from '~/modules/absence/services/absences' import { useAbsenceService } from '~/services/absences'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -105,8 +105,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { AbsencePolicy, AbsencePreviewResult, AbsenceType, HalfDay } from '~/modules/absence/services/dto/absence' import type { AbsencePolicy, AbsencePreviewResult, AbsenceType, HalfDay } from '~/services/dto/absence'
import { useAbsenceService } from '~/modules/absence/services/absences' import { useAbsenceService } from '~/services/absences'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -51,8 +51,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { AbsencePolicy } from '~/modules/absence/services/dto/absence' import type { AbsencePolicy } from '~/services/dto/absence'
import { useAbsenceService } from '~/modules/absence/services/absences' import { useAbsenceService } from '~/services/absences'
const service = useAbsenceService() const service = useAbsenceService()
const rows = ref<AbsencePolicy[]>([]) 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> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useBookStackService } from '~/modules/integration/services/bookstack' import { useBookStackService } from '~/services/bookstack'
const { getSettings, saveSettings, testConnection } = useBookStackService() const { getSettings, saveSettings, testConnection } = useBookStackService()
+2 -2
View File
@@ -40,8 +40,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Client } from '~/modules/directory/services/dto/client' import type { Client } from '~/services/dto/client'
import { useClientService } from '~/modules/directory/services/clients' import { useClientService } from '~/services/clients'
import type { DataTableColumn } from '~/components/ui/DataTable.vue' import type { DataTableColumn } from '~/components/ui/DataTable.vue'
+2 -2
View File
@@ -30,8 +30,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort' import type { TaskEffort } from '~/services/dto/task-effort'
import { useTaskEffortService } from '~/modules/project-management/services/task-efforts' import { useTaskEffortService } from '~/services/task-efforts'
import type { DataTableColumn } from '~/components/ui/DataTable.vue' import type { DataTableColumn } from '~/components/ui/DataTable.vue'
+1 -1
View File
@@ -45,7 +45,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useGiteaService } from '~/modules/integration/services/gitea' import { useGiteaService } from '~/services/gitea'
const { getSettings, saveSettings, testConnection } = useGiteaService() const { getSettings, saveSettings, testConnection } = useGiteaService()
+1 -1
View File
@@ -140,7 +140,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useMailService } from '~/modules/mail/services/mail' import { useMailService } from '~/services/mail'
const { getConfiguration, updateConfiguration, testConfiguration } = useMailService() const { getConfiguration, updateConfiguration, testConfiguration } = useMailService()
@@ -37,8 +37,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority' import type { TaskPriority } from '~/services/dto/task-priority'
import { useTaskPriorityService } from '~/modules/project-management/services/task-priorities' import { useTaskPriorityService } from '~/services/task-priorities'
import type { DataTableColumn } from '~/components/ui/DataTable.vue' import type { DataTableColumn } from '~/components/ui/DataTable.vue'
-116
View File
@@ -1,116 +0,0 @@
<template>
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">{{ $t('admin.roles.title') }}</h2>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('admin.roles.addRole')"
@click="openCreate"
/>
</div>
<DataTable
:columns="columns"
:items="items"
:loading="isLoading"
:empty-message="$t('admin.roles.empty')"
@row-click="openEdit"
>
<template #cell-isSystem="{ item }">
<span
v-if="item.isSystem"
class="rounded-full bg-primary-100 px-2 py-0.5 text-xs font-semibold text-primary-600"
>
{{ $t('admin.roles.system') }}
</span>
</template>
<template #cell-permissions="{ item }">
<span class="text-neutral-600">{{ item.permissions.length }}</span>
</template>
<template #actions="{ item }">
<MalioButtonIcon
v-if="!item.isSystem"
icon="mdi:delete-outline"
:aria-label="$t('common.delete')"
variant="ghost"
icon-size="20"
button-class="text-neutral-400 hover:text-red-500"
@click.stop="handleDelete(item.id)"
/>
</template>
</DataTable>
<RoleDrawer
v-model="drawerOpen"
:item="selectedItem"
:permissions="permissions"
@saved="onSaved"
/>
</div>
</template>
<script setup lang="ts">
import type { Role } from '~/modules/core/services/roles'
import { useRoleService } from '~/modules/core/services/roles'
import type { Permission } from '~/modules/core/services/permissions'
import { usePermissionService } from '~/modules/core/services/permissions'
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
const { t } = useI18n()
const columns = computed<DataTableColumn[]>(() => [
{ key: 'label', label: t('admin.roles.label'), primary: true },
{ key: 'code', label: t('admin.roles.code') },
{ key: 'permissions', label: t('admin.roles.permissions') },
{ key: 'isSystem', label: '' },
])
const roleService = useRoleService()
const permissionService = usePermissionService()
const items = ref<Role[]>([])
const permissions = ref<Permission[]>([])
const isLoading = ref(true)
const drawerOpen = ref(false)
const selectedItem = ref<Role | null>(null)
async function loadItems() {
isLoading.value = true
try {
items.value = await roleService.list()
} finally {
isLoading.value = false
}
}
async function loadPermissions() {
permissions.value = await permissionService.list()
}
function openCreate() {
selectedItem.value = null
drawerOpen.value = true
}
function openEdit(item: Role) {
selectedItem.value = item
drawerOpen.value = true
}
async function handleDelete(id: number) {
await roleService.remove(id)
await loadItems()
}
async function onSaved() {
await loadItems()
}
onMounted(() => {
loadItems()
loadPermissions()
})
</script>
+1 -1
View File
@@ -70,7 +70,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useShareSettingsService } from '~/modules/integration/services/share-settings' import { useShareSettingsService } from '~/services/share-settings'
const { getSettings, saveSettings, testConnection } = useShareSettingsService() const { getSettings, saveSettings, testConnection } = useShareSettingsService()
+2 -2
View File
@@ -37,8 +37,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag' import type { TaskTag } from '~/services/dto/task-tag'
import { useTaskTagService } from '~/modules/project-management/services/task-tags' import { useTaskTagService } from '~/services/task-tags'
import type { DataTableColumn } from '~/components/ui/DataTable.vue' import type { DataTableColumn } from '~/components/ui/DataTable.vue'
@@ -42,8 +42,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Workflow } from '~/modules/project-management/services/dto/workflow' import type { Workflow } from '~/services/dto/workflow'
import { useWorkflowService } from '~/modules/project-management/services/workflows' import { useWorkflowService } from '~/services/workflows'
import type { DataTableColumn } from '~/components/ui/DataTable.vue' import type { DataTableColumn } from '~/components/ui/DataTable.vue'
const { t } = useI18n() const { t } = useI18n()
+1 -1
View File
@@ -58,7 +58,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useZimbraService } from '~/modules/integration/services/zimbra' import { useZimbraService } from '~/services/zimbra'
const { getSettings, saveSettings, testConnection } = useZimbraService() const { getSettings, saveSettings, testConnection } = useZimbraService()
-186
View File
@@ -1,186 +0,0 @@
<template>
<MalioDrawer v-model="isOpen">
<template #header>
<h2 class="text-xl font-bold">
{{ isEditing ? $t('admin.roles.editRole') : $t('admin.roles.addRole') }}
</h2>
</template>
<form class="flex flex-col gap-3" @submit.prevent="handleSubmit">
<MalioInputText
v-model="form.code"
:label="$t('admin.roles.code')"
input-class="w-full"
:disabled="isEditing"
:hint="isEditing ? $t('admin.roles.codeImmutable') : $t('admin.roles.codeHint')"
:error="touched.code && !codeValid ? $t('admin.roles.codeInvalid') : ''"
@blur="touched.code = true"
/>
<MalioInputText
v-model="form.label"
:label="$t('admin.roles.label')"
input-class="w-full"
:error="touched.label && !form.label.trim() ? $t('admin.roles.labelRequired') : ''"
@blur="touched.label = true"
/>
<MalioInputTextArea
v-model="form.description"
:label="$t('admin.roles.description')"
input-class="w-full"
/>
<div class="mt-2">
<label class="text-sm font-semibold text-neutral-700">
{{ $t('admin.roles.permissions') }}
</label>
<p v-if="permissions.length === 0" class="mt-2 text-xs text-neutral-400">
{{ $t('admin.roles.noPermissions') }}
</p>
<div
v-for="group in groupedPermissions"
:key="group.module"
class="mt-3 rounded-lg border border-neutral-200 p-3"
>
<p class="mb-2 text-xs font-bold uppercase tracking-wide text-neutral-500">
{{ group.module }}
</p>
<div class="flex flex-col gap-2">
<label
v-for="perm in group.permissions"
:key="perm.id"
class="flex items-start gap-2 text-sm text-neutral-700"
>
<input
v-model="form.permissions"
type="checkbox"
:value="perm['@id']"
class="mt-0.5 rounded border-neutral-300"
/>
<span>
{{ perm.label }}
<span class="block text-xs text-neutral-400">{{ perm.code }}</span>
</span>
</label>
</div>
</div>
</div>
<div class="mt-4 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 { Role, RoleWrite } from '~/modules/core/services/roles'
import { useRoleService } from '~/modules/core/services/roles'
import type { Permission } from '~/modules/core/services/permissions'
const props = defineProps<{
modelValue: boolean
item: Role | null
permissions: Permission[]
}>()
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.item)
const isSubmitting = ref(false)
const form = reactive({
code: '',
label: '',
description: '',
permissions: [] as string[],
})
const touched = reactive({
code: false,
label: false,
})
const codeValid = computed(() => /^[a-z][a-z0-9_]*$/.test(form.code))
const groupedPermissions = computed(() => {
const byModule = new Map<string, Permission[]>()
for (const perm of props.permissions) {
const list = byModule.get(perm.module) ?? []
list.push(perm)
byModule.set(perm.module, list)
}
return [...byModule.entries()]
.map(([module, permissions]) => ({ module, permissions }))
.sort((a, b) => a.module.localeCompare(b.module))
})
watch(() => props.modelValue, (open) => {
if (open) {
if (props.item) {
form.code = props.item.code
form.label = props.item.label
form.description = props.item.description ?? ''
form.permissions = props.item.permissions
.map((p) => p['@id'])
.filter((iri): iri is string => !!iri)
} else {
form.code = ''
form.label = ''
form.description = ''
form.permissions = []
}
touched.code = false
touched.label = false
}
})
const { create, update } = useRoleService()
async function handleSubmit() {
touched.code = true
touched.label = true
if (!form.label.trim()) {
return
}
if (!isEditing.value && !codeValid.value) {
return
}
isSubmitting.value = true
try {
if (isEditing.value && props.item) {
const payload: Partial<RoleWrite> = {
label: form.label.trim(),
description: form.description.trim() || null,
permissions: form.permissions,
}
await update(props.item.id, payload)
} else {
const payload: RoleWrite = {
code: form.code.trim(),
label: form.label.trim(),
description: form.description.trim() || null,
permissions: form.permissions,
}
await create(payload)
}
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
</script>
+5 -5
View File
@@ -96,11 +96,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Workflow, StatusCategory } from '~/modules/project-management/services/dto/workflow' import type { Workflow, StatusCategory } from '~/services/dto/workflow'
import { STATUS_CATEGORY_COLOR } from '~/modules/project-management/services/dto/workflow' import { STATUS_CATEGORY_COLOR } from '~/services/dto/workflow'
import type { TaskStatusWrite } from '~/modules/project-management/services/dto/task-status' import type { TaskStatusWrite } from '~/services/dto/task-status'
import { useWorkflowService } from '~/modules/project-management/services/workflows' import { useWorkflowService } from '~/services/workflows'
import { useTaskStatusService } from '~/modules/project-management/services/task-statuses' import { useTaskStatusService } from '~/services/task-statuses'
const { t } = useI18n() const { t } = useI18n()
@@ -6,11 +6,36 @@
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2"> <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText <MalioInputText
v-model="form.name" v-model="form.name"
label="Nom société" label="Nom"
input-class="w-full" input-class="w-full"
:error="touched.name && !form.name.trim() ? 'Le nom est requis' : ''" :error="touched.name && !form.name.trim() ? 'Le nom est requis' : ''"
@blur="touched.name = true" @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"> <div class="mt-6 flex justify-end">
<MalioButton <MalioButton
@@ -25,8 +50,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Client, ClientWrite } from '~/modules/directory/services/dto/client' import type { Client, ClientWrite } from '~/services/dto/client'
import { useClientService } from '~/modules/directory/services/clients' import { useClientService } from '~/services/clients'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -48,16 +73,37 @@ const isSubmitting = ref(false)
const form = reactive({ const form = reactive({
name: '', name: '',
email: '',
phone: '',
street: '',
city: '',
postalCode: '',
}) })
const touched = reactive({ const touched = reactive({
name: false, name: false,
email: false,
}) })
watch(() => props.modelValue, (open) => { watch(() => props.modelValue, (open) => {
if (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.name = false
touched.email = false
} }
}) })
@@ -71,6 +117,11 @@ async function handleSubmit() {
try { try {
const payload: ClientWrite = { const payload: ClientWrite = {
name: form.name.trim(), 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) { if (isEditing.value && props.client) {
@@ -1,12 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import type { MailMessageDetailDto } from '~/modules/mail/services/dto/mail' import type { MailMessageDetailDto } from '~/services/dto/mail'
import type { Task } from '~/modules/project-management/services/dto/task' import type { Task } from '~/services/dto/task'
import type { Project } from '~/modules/project-management/services/dto/project' import type { Project } from '~/services/dto/project'
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group' import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data' import type { UserData } from '~/services/dto/user-data'
import { useMailService } from '~/modules/mail/services/mail' import { useMailService } from '~/services/mail'
import { useProjectService } from '~/modules/project-management/services/projects' import { useProjectService } from '~/services/projects'
import { useTaskGroupService } from '~/modules/project-management/services/task-groups' import { useTaskGroupService } from '~/services/task-groups'
import { useUserService } from '~/services/users' import { useUserService } from '~/services/users'
import { useAuthStore } from '~/shared/stores/auth' import { useAuthStore } from '~/shared/stores/auth'
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { MailFolderDto } from '~/modules/mail/services/dto/mail' import type { MailFolderDto } from '~/services/dto/mail'
const props = defineProps<{ const props = defineProps<{
/** Arbre de dossiers (getter folderTree du store) */ /** Arbre de dossiers (getter folderTree du store) */
@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Task } from '~/modules/project-management/services/dto/task' import type { Task } from '~/services/dto/task'
import type { Project } from '~/modules/project-management/services/dto/project' import type { Project } from '~/services/dto/project'
import { useMailService } from '~/modules/mail/services/mail' import { useMailService } from '~/services/mail'
import { useTaskService } from '~/modules/project-management/services/tasks' import { useTaskService } from '~/services/tasks'
import { useProjectService } from '~/modules/project-management/services/projects' import { useProjectService } from '~/services/projects'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { MailMessageHeaderDto } from '~/modules/mail/services/dto/mail' import type { MailMessageHeaderDto } from '~/services/dto/mail'
const props = defineProps<{ const props = defineProps<{
messages: readonly MailMessageHeaderDto[] messages: readonly MailMessageHeaderDto[]
@@ -1,7 +1,7 @@
<script setup lang="ts"> <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 { sanitizeMailHtml } from '~/utils/sanitizeMailHtml'
import { useMailService } from '~/modules/mail/services/mail' import { useMailService } from '~/services/mail'
const props = defineProps<{ const props = defineProps<{
/** Détail complet du message. null = aucun message sélectionné. */ /** Détail complet du message. null = aucun message sélectionné. */
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { useMailStore } from '~/modules/mail/stores/mail' import { useMailStore } from '~/stores/mail'
const store = useMailStore() const store = useMailStore()
const { syncing } = storeToRefs(store) const { syncing } = storeToRefs(store)
@@ -123,13 +123,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Project, ProjectWrite } from '~/modules/project-management/services/dto/project' import type { Project, ProjectWrite } from '~/services/dto/project'
import type { Client } from '~/modules/directory/services/dto/client' import type { Client } from '~/services/dto/client'
import type { GiteaRepository } from '~/modules/integration/services/dto/gitea' import type { GiteaRepository } from '~/services/dto/gitea'
import type { BookStackShelf } from '~/modules/integration/services/dto/bookstack' import type { BookStackShelf } from '~/services/dto/bookstack'
import { useProjectService } from '~/modules/project-management/services/projects' import { useProjectService } from '~/services/projects'
import { useGiteaService } from '~/modules/integration/services/gitea' import { useGiteaService } from '~/services/gitea'
import { useBookStackService } from '~/modules/integration/services/bookstack' import { useBookStackService } from '~/services/bookstack'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -67,10 +67,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group' import type { TaskGroup } from '~/services/dto/task-group'
import type { Task } from '~/modules/project-management/services/dto/task' import type { Task } from '~/services/dto/task'
import { useTaskGroupService } from '~/modules/project-management/services/task-groups' import { useTaskGroupService } from '~/services/task-groups'
import { useTaskService } from '~/modules/project-management/services/tasks' import { useTaskService } from '~/services/tasks'
import { stripRichText } from '~/utils/format' import { stripRichText } from '~/utils/format'
const props = defineProps<{ const props = defineProps<{
@@ -82,12 +82,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Project } from '~/modules/project-management/services/dto/project' import type { Project } from '~/services/dto/project'
import type { Task } from '~/modules/project-management/services/dto/task' import type { Task } from '~/services/dto/task'
import type { Workflow } from '~/modules/project-management/services/dto/workflow' import type { Workflow } from '~/services/dto/workflow'
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status' import type { TaskStatus } from '~/services/dto/task-status'
import { useWorkflowService } from '~/modules/project-management/services/workflows' import { useWorkflowService } from '~/services/workflows'
import { useTaskService } from '~/modules/project-management/services/tasks' import { useTaskService } from '~/services/tasks'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -167,8 +167,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { FileEntry } from '~/modules/integration/services/dto/share' import type { FileEntry } from '~/services/dto/share'
import { useShareService } from '~/modules/integration/services/share' import { useShareService } from '~/services/share'
import { formatFileSize } from '~/utils/format' import { formatFileSize } from '~/utils/format'
const props = defineProps<{ const props = defineProps<{
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status' import type { TaskStatus } from '~/services/dto/task-status'
defineProps<{ defineProps<{
statuses: TaskStatus[] statuses: TaskStatus[]
@@ -75,8 +75,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { BookStackLink, BookStackSearchResult } from '~/modules/integration/services/dto/bookstack' import type { BookStackLink, BookStackSearchResult } from '~/services/dto/bookstack'
import { useBookStackService } from '~/modules/integration/services/bookstack' import { useBookStackService } from '~/services/bookstack'
const props = defineProps<{ const props = defineProps<{
taskId: number taskId: number
@@ -104,13 +104,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Task } from '~/modules/project-management/services/dto/task' import type { Task } from '~/services/dto/task'
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status' import type { TaskStatus } from '~/services/dto/task-status'
import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort' import type { TaskEffort } from '~/services/dto/task-effort'
import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority' import type { TaskPriority } from '~/services/dto/task-priority'
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group' import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data' 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<{ const props = withDefaults(defineProps<{
selectedCount: number selectedCount: number
@@ -102,7 +102,7 @@
</template> </template>
<script setup lang="ts"> <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<{ const props = withDefaults(defineProps<{
task: Task task: Task
@@ -60,8 +60,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskDocument } from '~/modules/project-management/services/dto/task-document' import type { TaskDocument } from '~/services/dto/task-document'
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents' import { useTaskDocumentService } from '~/services/task-documents'
import { formatFileSize } from '~/utils/format' import { formatFileSize } from '~/utils/format'
defineProps<{ defineProps<{
@@ -121,8 +121,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskDocument } from '~/modules/project-management/services/dto/task-document' import type { TaskDocument } from '~/services/dto/task-document'
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents' import { useTaskDocumentService } from '~/services/task-documents'
import { formatFileSize } from '~/utils/format' import { formatFileSize } from '~/utils/format'
import { copyToClipboard } from '~/utils/clipboard' import { copyToClipboard } from '~/utils/clipboard'
@@ -56,9 +56,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Breadcrumb, FileEntry } from '~/modules/integration/services/dto/share' import type { Breadcrumb, FileEntry } from '~/services/dto/share'
import { useShareService } from '~/modules/integration/services/share' import { useShareService } from '~/services/share'
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents' import { useTaskDocumentService } from '~/services/task-documents'
import { formatFileSize } from '~/utils/format' import { formatFileSize } from '~/utils/format'
const props = defineProps<{ const props = defineProps<{
@@ -46,7 +46,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents' import { useTaskDocumentService } from '~/services/task-documents'
const props = defineProps<{ const props = defineProps<{
taskId?: number taskId?: number
@@ -25,8 +25,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskEffort, TaskEffortWrite } from '~/modules/project-management/services/dto/task-effort' import type { TaskEffort, TaskEffortWrite } from '~/services/dto/task-effort'
import { useTaskEffortService } from '~/modules/project-management/services/task-efforts' import { useTaskEffortService } from '~/services/task-efforts'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -226,9 +226,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Task } from '~/modules/project-management/services/dto/task' import type { Task } from '~/services/dto/task'
import type { GiteaBranch, GiteaPullRequest } from '~/modules/integration/services/dto/gitea' import type { GiteaBranch, GiteaPullRequest } from '~/services/dto/gitea'
import { useGiteaService } from '~/modules/integration/services/gitea' import { useGiteaService } from '~/services/gitea'
import { copyToClipboard } from '~/utils/clipboard' import { copyToClipboard } from '~/utils/clipboard'
const { t } = useI18n() const { t } = useI18n()
@@ -56,10 +56,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskGroup, TaskGroupWrite } from '~/modules/project-management/services/dto/task-group' import type { TaskGroup, TaskGroupWrite } from '~/services/dto/task-group'
import type { Task } from '~/modules/project-management/services/dto/task' import type { Task } from '~/services/dto/task'
import { useTaskGroupService } from '~/modules/project-management/services/task-groups' import { useTaskGroupService } from '~/services/task-groups'
import { useTaskService } from '~/modules/project-management/services/tasks' import { useTaskService } from '~/services/tasks'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -110,7 +110,7 @@
</template> </template>
<script setup lang="ts"> <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<{ const props = withDefaults(defineProps<{
task: Task task: Task
@@ -536,23 +536,23 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Task, TaskWrite } from '~/modules/project-management/services/dto/task' import type { Task, TaskWrite } from '~/services/dto/task'
import type { TaskDocument } from '~/modules/project-management/services/dto/task-document' import type { TaskDocument } from '~/services/dto/task-document'
import { useGiteaService } from '~/modules/integration/services/gitea' import { useGiteaService } from '~/services/gitea'
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents' import { useTaskDocumentService } from '~/services/task-documents'
import ConfirmDeleteDocumentModal from '~/components/ui/ConfirmDeleteDocumentModal.vue' import ConfirmDeleteDocumentModal from '~/components/ui/ConfirmDeleteDocumentModal.vue'
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status' import type { TaskStatus } from '~/services/dto/task-status'
import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort' import type { TaskEffort } from '~/services/dto/task-effort'
import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority' import type { TaskPriority } from '~/services/dto/task-priority'
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag' import type { TaskTag } from '~/services/dto/task-tag'
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group' import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data' import type { UserData } from '~/services/dto/user-data'
import { useTaskService } from '~/modules/project-management/services/tasks' import { useTaskService } from '~/services/tasks'
import { useTaskRecurrenceService } from '~/modules/project-management/services/task-recurrences' import { useTaskRecurrenceService } from '~/services/task-recurrences'
import type { Project } from '~/modules/project-management/services/dto/project' import type { Project } from '~/services/dto/project'
import { useMailService } from '~/modules/mail/services/mail' import { useMailService } from '~/services/mail'
import type { MailMessageHeaderDto } from '~/modules/mail/services/dto/mail' import type { MailMessageHeaderDto } from '~/services/dto/mail'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -569,7 +569,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void (e: 'update:modelValue', value: boolean): void
(e: 'saved', task?: Task): void (e: 'saved'): void
}>() }>()
const isOpen = computed({ const isOpen = computed({
@@ -1042,7 +1042,7 @@ async function handleSubmit() {
await removeRecurrence(props.task.recurrence.id) await removeRecurrence(props.task.recurrence.id)
} }
emit('saved', savedTask) emit('saved')
isOpen.value = false isOpen.value = false
} finally { } finally {
isSubmitting.value = false isSubmitting.value = false
@@ -28,8 +28,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskPriority, TaskPriorityWrite } from '~/modules/project-management/services/dto/task-priority' import type { TaskPriority, TaskPriorityWrite } from '~/services/dto/task-priority'
import { useTaskPriorityService } from '~/modules/project-management/services/task-priorities' import { useTaskPriorityService } from '~/services/task-priorities'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -28,8 +28,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TaskTag, TaskTagWrite } from '~/modules/project-management/services/dto/task-tag' import type { TaskTag, TaskTagWrite } from '~/services/dto/task-tag'
import { useTaskTagService } from '~/modules/project-management/services/task-tags' import { useTaskTagService } from '~/services/task-tags'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -64,7 +64,7 @@
</template> </template>
<script setup lang="ts"> <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<{ const props = defineProps<{
entry: TimeEntry entry: TimeEntry
@@ -35,7 +35,7 @@
</template> </template>
<script setup lang="ts"> <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<{ const props = defineProps<{
visible: boolean visible: boolean
@@ -124,11 +124,11 @@
</template> </template>
<script setup lang="ts"> <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 { UserData } from '~/services/dto/user-data'
import type { Project } from '~/modules/project-management/services/dto/project' import type { Project } from '~/services/dto/project'
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag' import type { TaskTag } from '~/services/dto/task-tag'
import { useTimeEntryService } from '~/modules/time-tracking/services/time-entries' import { useTimeEntryService } from '~/services/time-entries'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -67,7 +67,7 @@
</template> </template>
<script setup lang="ts"> <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' import { stripRichText } from '~/utils/format'
const props = defineProps<{ const props = defineProps<{
@@ -150,8 +150,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TimeEntry } from '~/modules/time-tracking/services/dto/time-entry' import type { TimeEntry } from '~/services/dto/time-entry'
import { useAbsenceService } from '~/modules/absence/services/absences' import { useAbsenceService } from '~/services/absences'
const { t } = useI18n() const { t } = useI18n()
const absenceService = useAbsenceService() const absenceService = useAbsenceService()
@@ -108,9 +108,9 @@
<script setup lang="ts"> <script setup lang="ts">
import type { UserData } from '~/services/dto/user-data' import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/modules/project-management/services/dto/project' import type { Project } from '~/services/dto/project'
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag' import type { TaskTag } from '~/services/dto/task-tag'
import type { Client } from '~/modules/directory/services/dto/client' import type { Client } from '~/services/dto/client'
const props = defineProps<{ const props = defineProps<{
users: UserData[] users: UserData[]
+2 -2
View File
@@ -3,11 +3,11 @@
<div class="flex h-full items-center justify-between"> <div class="flex h-full items-center justify-between">
<MalioButtonIcon <MalioButtonIcon
icon="mdi:menu" icon="mdi:menu"
aria-label="Replier ou déplier le menu" aria-label="Menu"
variant="ghost" variant="ghost"
icon-size="24" icon-size="24"
button-class="lg:hidden text-white hover:bg-primary-600" button-class="lg:hidden text-white hover:bg-primary-600"
@click="ui.toggleSidebar()" @click="ui.openMobileSidebar()"
/> />
<div class="hidden items-center gap-2 lg:flex"> <div class="hidden items-center gap-2 lg:flex">
<h1 class="text-lg font-bold tracking-tight">Lesstime</h1> <h1 class="text-lg font-bold tracking-tight">Lesstime</h1>
@@ -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>
+13 -26
View File
@@ -106,33 +106,15 @@ const touched = reactive({
password: false, password: false,
}) })
const { create, update, getById } = useUserService() watch(() => props.modelValue, (open) => {
if (open) {
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) { if (props.item) {
applyUser(props.item) form.username = props.item.username ?? ''
try { form.firstName = props.item.firstName ?? ''
const full = await getById(props.item.id) form.lastName = props.item.lastName ?? ''
applyUser(full) form.password = ''
} catch { form.roles = [...props.item.roles]
// Keep the list data if the detailed fetch fails. form.isEmployee = props.item.isEmployee ?? false
}
} else { } else {
form.username = '' form.username = ''
form.firstName = '' form.firstName = ''
@@ -141,8 +123,13 @@ watch(() => props.modelValue, async (open) => {
form.roles = ['ROLE_USER'] form.roles = ['ROLE_USER']
form.isEmployee = false form.isEmployee = false
} }
touched.username = false
touched.password = false
}
}) })
const { create, update } = useUserService()
async function handleSubmit() { async function handleSubmit() {
touched.username = true touched.username = true
touched.password = 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' 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() { export function useShareStatus() {
const enabled = useState<boolean | null>('share-enabled', () => null) const enabled = useState<boolean | null>('share-enabled', () => null)
+5 -267
View File
@@ -24,9 +24,7 @@
"updated": "Client mis à jour avec succès.", "updated": "Client mis à jour avec succès.",
"deleted": "Client supprimé avec succès.", "deleted": "Client supprimé avec succès.",
"addClient": "Ajouter un client", "addClient": "Ajouter un client",
"editClient": "Modifier un client", "editClient": "Modifier un client"
"deleteConfirmTitle": "Supprimer le client",
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer le client « {name} » ? Cette action est irréversible."
}, },
"projects": { "projects": {
"title": "Projets", "title": "Projets",
@@ -197,57 +195,6 @@
"addUser": "Ajouter un utilisateur", "addUser": "Ajouter un utilisateur",
"editUser": "Modifier un utilisateur" "editUser": "Modifier un utilisateur"
}, },
"admin": {
"roles": {
"title": "Rôles",
"addRole": "Ajouter un rôle",
"editRole": "Modifier un rôle",
"empty": "Aucun rôle trouvé.",
"system": "Système",
"code": "Code",
"codeHint": "Identifiant technique en snake_case (immuable).",
"codeImmutable": "Le code ne peut pas être modifié après création.",
"codeInvalid": "Code invalide (attendu snake_case : minuscules, chiffres et underscores).",
"label": "Libellé",
"labelRequired": "Le libellé est requis.",
"description": "Description",
"permissions": "Permissions",
"noPermissions": "Aucune permission disponible.",
"created": "Rôle créé avec succès.",
"updated": "Rôle mis à jour avec succès.",
"deleted": "Rôle supprimé avec succès."
},
"audit": {
"title": "Audit",
"empty": "Aucune entrée d'audit trouvée.",
"date": "Date",
"performedBy": "Utilisateur",
"entityType": "Type d'entité",
"action": "Action",
"entityId": "Identifiant",
"filterEntityType": "Type d'entité",
"filterEntityTypeAll": "Tous les types",
"filterAction": "Action",
"filterActionAll": "Toutes les actions",
"previous": "Précédent",
"next": "Suivant",
"page": "Page {page}"
}
},
"audit": {
"entity": {
"core": {
"User": "Utilisateur",
"Role": "Rôle",
"Permission": "Permission"
}
},
"action": {
"create": "Création",
"update": "Modification",
"delete": "Suppression"
}
},
"timeEntries": { "timeEntries": {
"created": "Temps enregistré", "created": "Temps enregistré",
"updated": "Temps modifié", "updated": "Temps modifié",
@@ -349,75 +296,20 @@
} }
}, },
"sidebar": { "sidebar": {
"myTasks": "Mes tâches",
"general": { "general": {
"section": "Général", "section": "Gestion de projet",
"dashboard": "Tableau de bord", "dashboard": "Tableau de bord",
"myTasks": "Mes tâches", "myTasks": "Mes tâches",
"projects": "Projets", "projects": "Projets",
"timeTracking": "Suivi de temps", "timeTracking": "Suivi de temps"
"mail": "Messagerie",
"myAbsences": "Mes absences"
},
"tools": {
"section": "Outils"
},
"project": {
"kanban": "Kanban",
"groups": "Groupes",
"archives": "Archives"
}, },
"admin": { "admin": {
"section": "Administration", "section": "Administration",
"teamAbsences": "Absences équipe", "teamAbsences": "Absences équipe",
"directory": "Répertoire",
"reporting": "Rapports",
"administration": "Administration" "administration": "Administration"
} }
}, },
"reporting": {
"title": "Rapports",
"export": "Exporter (CSV)",
"empty": "Aucune donnée pour cette période.",
"filters": {
"period": "Période",
"from": "Du",
"to": "Au",
"project": "Projet",
"allProjects": "Tous les projets",
"user": "Utilisateur",
"allUsers": "Tous les utilisateurs"
},
"periods": {
"thisMonth": "Mois courant",
"lastMonth": "Mois dernier",
"custom": "Personnalisé"
},
"sections": {
"timePerProject": "Temps par projet",
"timePerUser": "Temps par utilisateur",
"tasksByStatus": "Tâches par statut",
"absencesByType": "Absences par type"
},
"columns": {
"project": "Projet",
"code": "Code",
"user": "Utilisateur",
"status": "Statut",
"type": "Type",
"hours": "Heures",
"entries": "Nb saisies",
"count": "Nombre",
"days": "Jours"
},
"absenceTypes": {
"cp": "Congés payés",
"mariage_pacs": "Mariage / PACS",
"naissance": "Naissance",
"conge_parental": "Congé parental",
"deces": "Décès proche",
"maladie": "Arrêt maladie"
}
},
"common": { "common": {
"cancel": "Annuler", "cancel": "Annuler",
"save": "Enregistrer", "save": "Enregistrer",
@@ -433,10 +325,7 @@
"thisWeek": "Cette semaine", "thisWeek": "Cette semaine",
"clear": "Effacer", "clear": "Effacer",
"day": "Jour", "day": "Jour",
"weekShort": "Sem.", "weekShort": "Sem."
"submit": "Soumettre",
"close": "Fermer",
"back": "Retour"
}, },
"gitea": { "gitea": {
"settings": { "settings": {
@@ -908,156 +797,5 @@
"balanceAdjusted": "Solde ajusté.", "balanceAdjusted": "Solde ajusté.",
"policyUpdated": "Politique mise à jour." "policyUpdated": "Politique mise à jour."
} }
},
"prospects": {
"created": "Prospect créé avec succès.",
"updated": "Prospect mis à jour avec succès.",
"deleted": "Prospect supprimé avec succès.",
"converted": "Prospect converti en client avec succès.",
"addProspect": "Ajouter un prospect",
"editProspect": "Modifier un prospect",
"convert": "Convertir en client",
"alreadyConverted": "Déjà converti en client",
"deleteConfirmTitle": "Supprimer le prospect",
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer le prospect « {name} » ? Cette action est irréversible.",
"fields": {
"name": "Nom",
"company": "Société",
"email": "Email",
"phone": "Téléphone",
"website": "Site web",
"street": "Rue",
"city": "Ville",
"postalCode": "Code postal",
"status": "Statut",
"source": "Source",
"notes": "Notes"
},
"status": {
"new": "Nouveau",
"contacted": "Contacté",
"qualified": "Qualifié",
"won": "Gagné",
"lost": "Perdu"
},
"validation": {
"nameRequired": "Le nom est requis",
"companyRequired": "La société est requise"
}
},
"prestataires": {
"created": "Prestataire créé avec succès.",
"updated": "Prestataire mis à jour avec succès.",
"deleted": "Prestataire supprimé avec succès.",
"addPrestataire": "Ajouter un prestataire",
"editPrestataire": "Modifier un prestataire",
"deleteConfirmTitle": "Supprimer le prestataire",
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer le prestataire « {name} » ? Cette action est irréversible.",
"fields": {
"name": "Nom / Société",
"email": "Email",
"phone": "Téléphone",
"website": "Site web"
}
},
"directory": {
"title": "Répertoire",
"tabs": {
"info": "Informations",
"clients": "Clients",
"prospects": "Prospects",
"prestataires": "Prestataires",
"contact": "Contact",
"address": "Adresse",
"report": "Rapport"
},
"info": {
"fields": {
"name": "Nom",
"email": "Email",
"phone": "Téléphone",
"website": "Site web"
}
},
"validation": {
"nameRequired": "Le nom est requis.",
"subjectRequired": "L'objet est requis.",
"emailInvalid": "Adresse email invalide.",
"phoneInvalid": "Numéro de téléphone invalide (ex. 0549200910).",
"urlInvalid": "URL invalide (ex. https://exemple.fr)."
},
"clients": {
"add": "Ajouter un client",
"empty": "Aucun client trouvé."
},
"prospects": {
"add": "Ajouter un prospect",
"empty": "Aucun prospect trouvé.",
"allStatuses": "Tous les statuts"
},
"prestataires": {
"add": "Ajouter un prestataire",
"empty": "Aucun prestataire trouvé."
},
"contacts": {
"add": "Ajouter un contact",
"item": "Contact {n}",
"saved": "Contact enregistré.",
"deleted": "Contact supprimé.",
"fields": {
"lastName": "Nom",
"firstName": "Prénom",
"jobTitle": "Fonction",
"email": "Email",
"phonePrimary": "Téléphone",
"phoneSecondary": "Téléphone secondaire"
}
},
"addresses": {
"add": "Ajouter une adresse",
"item": "Adresse {n}",
"saved": "Adresse enregistrée.",
"deleted": "Adresse supprimée.",
"streetNotFound": "Aucune adresse trouvée — saisie libre possible.",
"autocompleteUnavailable": "Recherche d'adresse indisponible : saisissez l'adresse manuellement.",
"fields": {
"label": "Libellé",
"street": "Rue",
"streetComplement": "Complément",
"postalCode": "Code postal",
"city": "Ville"
}
},
"reports": {
"add": "Ajouter un compte-rendu",
"addTitle": "Nouveau compte-rendu",
"editTitle": "Modifier le compte-rendu",
"empty": "Aucun compte-rendu",
"emptyHint": "Consignez vos échanges (appels, rendez-vous, emails) pour garder l'historique de la relation.",
"count": "{n} compte-rendu | {n} comptes-rendus",
"documentsLabel": "Documents",
"saved": "Compte-rendu enregistré.",
"deleted": "Compte-rendu supprimé.",
"confirmDeleteTitle": "Supprimer ce compte-rendu ?",
"confirmDeleteMessage": "Cette action est irréversible. Les documents joints seront également supprimés.",
"fields": {
"subject": "Objet",
"type": "Type d'échange",
"occurredAt": "Date",
"body": "Compte-rendu"
},
"types": {
"call": "Appel",
"meeting": "Rendez-vous",
"email": "Email",
"note": "Note"
}
},
"documents": {
"add": "Joindre un document",
"uploading": "Envoi…",
"empty": "Aucun document.",
"deleted": "Document supprimé."
}
} }
} }
-1
View File
@@ -1 +0,0 @@
export default defineNuxtConfig({})
@@ -1,27 +0,0 @@
export function usePermissions() {
const auth = useAuthStore()
function isAdmin(): boolean {
return auth.user?.roles?.includes('ROLE_ADMIN') ?? false
}
function can(code: string): boolean {
if (!auth.user) {
return false
}
if (isAdmin()) {
return true
}
return auth.user.effectivePermissions?.includes(code) ?? false
}
function canAny(codes: string[]): boolean {
return codes.some((c) => can(c))
}
function canAll(codes: string[]): boolean {
return codes.every((c) => can(c))
}
return { can, canAny, canAll, isAdmin }
}
+1 -1
View File
@@ -1,7 +1,7 @@
<template> <template>
<NuxtLayout name="default"> <NuxtLayout name="default">
<div class="mx-auto max-w-lg px-4 py-10"> <div class="mx-auto max-w-lg px-4 py-10">
<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"> <div class="flex flex-col items-center gap-6 rounded-xl border border-neutral-200 bg-white p-8 shadow-sm">
<!-- Current avatar --> <!-- Current avatar -->
@@ -1,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,22 +0,0 @@
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export type Permission = {
id: number
'@id'?: string
code: string
label: string
module: string
orphan?: boolean
}
export function usePermissionService() {
const api = useApi()
async function list(): Promise<Permission[]> {
const data = await api.get<HydraCollection<Permission>>('/permissions')
return extractHydraMembers(data)
}
return { list }
}
-50
View File
@@ -1,50 +0,0 @@
import type { Permission } from './permissions'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export type Role = {
id: number
'@id'?: string
code: string
label: string
description?: string | null
isSystem: boolean
permissions: Permission[]
}
export type RoleWrite = {
code?: string
label: string
description?: string | null
/** IRIs of the granted permissions (e.g. /api/permissions/3). */
permissions: string[]
}
export function useRoleService() {
const api = useApi()
async function list(): Promise<Role[]> {
const data = await api.get<HydraCollection<Role>>('/roles')
return extractHydraMembers(data)
}
async function create(payload: RoleWrite): Promise<Role> {
return api.post<Role>('/roles', payload as Record<string, unknown>, {
toastSuccessKey: 'admin.roles.created',
})
}
async function update(id: number, payload: Partial<RoleWrite>): Promise<Role> {
return api.patch<Role>(`/roles/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'admin.roles.updated',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/roles/${id}`, {}, {
toastSuccessKey: 'admin.roles.deleted',
})
}
return { list, create, update, remove }
}
@@ -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>

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