Compare commits

..

17 Commits

Author SHA1 Message Date
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
599 changed files with 2851 additions and 21694 deletions
@@ -93,51 +93,6 @@
- Ticket laissé en **"En attente de validation" (4)**, pas "Terminé" : smoke visuel front (dev server + navigateur) et sign-off du **délta cosmétique d'ordre de sidebar** (décision 3 du plan) relèvent du PO. Implémentation + AC API validés.
- Time-tracking 100 % sur la session principale cette fois (consigne des sous-agents : ne jamais toucher aux outils `mcp__lesstime__*`) — respecté.
## Session 2026-06-19 (LST-63 / 1.1 — Module Core : identité User/Auth/JWT + Notifications + layer front)
### Contexte
- Plan TDD dédié (`docs/superpowers/plans/2026-06-19-lst-63-module-core.md`, 7 tasks / 6 phases A→F). Exécution : Phases A/B (1 sous-agent combiné), C (1 sous-agent), D (1 sous-agent), E + F faites en direct par la session principale (tâches courtes). Pilotage chrono/MCP/vérif + re-vérif login après chaque phase touchant l'auth sur la session principale.
- 5 commits impl (`6ca91cb` A, `f8fc4d6`+`d70925b` B, `0b4874e` C, `f1a9b42` D, `a98ea3d` E, `117c2ff` F) + plan `8865bf5`. Tests : 110→120 verts. Timer impl 1012 = 43 min.
### Patterns
- **Move d'entité « strangler » sans migration** : `git mv` `src/Entity/User.php``src/Module/Core/Domain/Entity/User.php` (table + colonnes + backticks VERBATIM) ; mapping Doctrine `Core` ajouté (dir `src/Module/Core/Domain/Entity`, prefix `App\Module\Core\Domain\Entity`) à côté de `App` ; `resolve_target_entities: UserInterface → Core\User`. `migrations:diff` reste vide (hors dérive préexistante `messenger_messages`) → AUCUNE migration. Idem Notification en Phase D.
- **Alias temporaire pour découpler le move des relations** : Phase B pose un `class_alias(App\Entity\User::class → Core\User)` (fichier `_compat_user_alias.php` en `autoload.files`, exclu de l'autowiring `App\:` via `exclude` services.yaml + `notPath` php-cs-fixer). Permet de relier d'abord les 8 relations d'entités au CONTRAT `UserInterface::class` (resolver propre) ; l'alias n'est qu'un pont de type-hint PHP. Phase C retire l'alias EN DERNIER, seulement quand `grep App\Entity\User` est vide.
- **Règle contrat-vs-concret pour migrer les consommateurs** (Phase C, ~50 fichiers) : type-hint `App\Shared\Domain\Contract\UserInterface` si le fichier n'appelle que les méthodes de lecture du contrat / instanceof / type DQL ; FQCN concret `App\Module\Core\Domain\Entity\User` si besoin de getters HR, `apiToken`, `avatarFileName`, setters, `new User()`. Les deux éliminent `App\Entity\User`. Collision de nom avec `Symfony\...\UserInterface` → aliaser en `SharedUserInterface`.
- **Notifier (Phase D)** : `NotifierInterface` (Shared) = API publique inter-modules ; impl `Notifier` (Core) persiste + flush. `TaskNotificationListener` appelle `notify()` UNIQUEMENT en `postFlush` (jamais `onFlush` — le flush interne y est dangereux). Comportement identique conservé.
- **Layer front d'un module (Phase F)** : `frontend/modules/core/nuxt.config.ts` (`export default defineNuxtConfig({})`) + `git mv` des pages d'identité sous `modules/core/pages/`. Les imports `~/...` (alias srcDir) survivent au déplacement ; seuls les imports relatifs/par chemin casseraient. Les URLs (`/login`, `/profile`) restent identiques (fusion auto des `pages/` de layers).
### Gotchas
- **`admin.vue` = shell admin MULTI-domaines** (onglets clients/workflows/efforts/gitea/zimbra/mail/absences + 1 onglet `AdminUserTab`) : NE PAS le déplacer entier dans Core (il porterait les admins d'autres modules pas encore extraits). Conformément au plan, en cas de doute on déplace seulement login + profile, on documente. La décomposition de `admin.vue` viendra avec les modules respectifs.
- **Vérifier la résolution des routes d'un layer Nuxt en SPA** : `ssr:false` → le dev server renvoie 200 pour N'IMPORTE QUEL chemin (shell SPA, routing client) — un `curl /login` = 200 ne prouve RIEN (testé : `/route-bidon-xyz` = 200 aussi). `nuxt prepare` ne génère pas le manifeste de routes. **Preuve déterministe** = `npx nuxt build` puis `grep 'name:"login"\|name:"profile"' .output/server/chunks/build/client.precomputed.mjs` (+ chunk CSS `profile.*.css` généré). Ne pas perturber un dev server déjà lancé (config `extends`/`imports.dirs` figée au démarrage avant création du layer) → lancer un dev frais sur un port libre pour smoke.
- **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.
## 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
- **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.
@@ -145,25 +100,3 @@
- **MCP status**: Toujours mettre "En cours" AVANT de commencer, "Terminé" APRÈS validation
- **PostgreSQL gotchas**: Tester les queries SQL avec agrégation + locking sur PostgreSQL, pas MySQL
- **Agents**: Les agents simples (1-3 fichiers) terminent en ~30s, les complexes (22 fichiers) en ~8min
## Session 2026-06-19 (LST-61 / 1.3 — Audit log : #[Auditable], audit_log, AuditListener, resource)
### Contexte
- Plan TDD dédié (`docs/superpowers/plans/2026-06-19-lst-61-audit-log.md`, Tasks A→F). Exécution : 1 sous-agent par task (A, B, C, D, E) en séquence, vérif + smoke par la session principale entre chaque ; Task F (validation finale + correctif front + learnings + push + statut) en direct.
- Infra portée VERBATIM depuis Starseed (réf canonique `/home/matthieu/dev_malio/Starseed`) : `AuditListener` byte-identique (`diff -q` OK), + 6 fichiers API (DTO/paginator/providers/resources) copiés tels quels — namespaces `App\Module\Core\...` et `App\Shared\Domain\Attribute\...` DÉJÀ alignés entre les deux projets, zéro adaptation.
- 6 commits impl (`934cf08` A, `d8553f0` B, `8c3699a` C, `90b8ca1` D, `e7af415` E, `9b26b43` fix front) + plan `fda03bd`. Tests : 147→157 verts. Branche `feat/lst-61-audit-log` empilée sur `feat/lst-57-rbac-fin`.
### Patterns
- **Audit en 4 couches additives** : (1) marquage déclaratif `#[Auditable]`(TARGET_CLASS) / `#[AuditIgnore]`(TARGET_PROPERTY) dans `src/Shared/Domain/Attribute/` (Shared, pas Core → aucun module n'a de dépendance circulaire) ; (2) capture `AuditListener` Doctrine sur `onFlush` (lit `UnitOfWork` : insertions/updates/deletions + `getScheduledCollectionUpdates/Deletions` pour le M2M) puis `postFlush` (écrit, swap-and-clear anti-réentrance) ; (3) écriture `AuditLogWriter` sur connexion DBAL dédiée `audit` (hors transaction ORM → survit aux rollbacks) ; (4) lecture `AuditLogProvider` DBAL (pas d'entité ORM) + `DbalPaginator implements PaginatorInterface` (API Platform génère `hydra:view` seul).
- **Connexion DBAL dédiée + `schema_filter`** : restructurer `doctrine.yaml` de connexion unique → `connections: {default, audit}` (même DSN), `default_connection: default`, `schema_filter: '~^(?!audit_log$).+~'` sur `default` (la table n'a PAS d'entité → exclue de `migrations:diff`/`schema:validate`). Le bloc `orm` reste INCHANGÉ (l'EM par défaut se lie à `default_connection`). En `when@test`, propager `dbname_suffix` aux DEUX connexions (sinon `audit` écrit en base dev pendant que l'ORM écrit en test).
- **Table append-only hors ORM** : créée par migration manuelle (squelette via `doctrine:migrations:generate` puis contenu écrit à la main — JAMAIS `migrations:diff`, qui ne voit pas la table). `id uuid` natif PG, `changes JSONB`, `performed_at TIMESTAMP(6) WITH TIME ZONE`. UUID v7 (writer, tri monotone) / v4 (requestId par requête HTTP). `entity_type` au format `module.Entity` (regex `App\Module\<module>\...\<Entity>``core.User`).
- **Marquage scope = entités migrées** : `#[Auditable]` posé sur User/Role/Permission (Core) uniquement ; `#[AuditIgnore]` sur `User.password` ET `User.apiToken` (Lesstime n'a pas de `plainPassword`). Défense en profondeur : `AuditLogWriter::SENSITIVE_KEYS` strippe aussi `password/plainPassword/apiToken/token/secret`. Les entités métier legacy (`src/Entity/*`) seront marquées à leur migration en modules (2.x).
### Gotchas
- **Tests fonctionnels Lesstime SANS rollback transactionnel** (pas de DAMADoctrineTestBundle) : les entités persistées survivent d'un run à l'autre → violation d'unicité `username`. Convention projet : `uniqid()` OU nettoyage explicite en `setUp()` (`DELETE FROM "user" WHERE username LIKE 'audit\_%'`). Les données d'audit de test se seedent directement via `doctrine.dbal.audit_connection` (DELETE + inserts UUID v7) pour du déterministe.
- **`migrations:diff` génère un fichier jetable** même quand on ne veut que vérifier : toujours supprimer le `Version<ts>.php` non suivi créé après un diff de contrôle (`git ls-files --others migrations/`). Une dérive préexistante `messenger_messages` (DROP) pollue le diff — sans rapport, ne pas committer.
- **`/audit-log-entity-types` = ressource item unique, pas une collection** : `Get` API Platform avec `uriTemplate` fixe sans `{id}` → renvoie `{ entityTypes: string[] }` (PAS d'enveloppe hydra `member`). Le service front ne doit PAS passer par `extractHydraMembers` ici (bug livré par le sous-agent E, corrigé en `9b26b43`). `/audit-logs` en revanche est bien une collection paginée hydra.
- **Login en curl = `/login_check` (POST), pas `/api/login`** ; le JWT json_login est capricieux en curl pur (405/cookie). La preuve d'auth faisant autorité reste le test fonctionnel (client `loginUser()`), pas un smoke curl.
### Time-tracking / orchestration
- **Interdire explicitement aux sous-agents de toucher au MCP lesstime** (timer + statut ticket) : un sous-agent a spontanément créé/stoppé une time entry (1016) alors que le chrono est piloté par la session principale. Ajouter la consigne « NE TOUCHE PAS au time-tracking » dans chaque prompt de sous-agent. Pas de conflit ici (il avait stoppé l'actif avant), mais découpage involontaire.
-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
-6
View File
@@ -126,12 +126,6 @@ La librairie `@malio/layer-ui` fournit les composants de formulaire et d'action.
- Config Docker : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`)
- Après modif nginx : `docker restart nginx-lesstime`
## Déploiement (prod Docker)
- Script : `infra/prod/deploy.sh` (`./deploy.sh [tag]`) — doc complète : `doc/deployment-docker.md`
- Étapes : maintenance → pull image → up → migrations → **`app:seed-rbac`** → **`app:sync-permissions`** → cache clear/warmup
- **RBAC** : les migrations créent les tables `role`/`permission` mais **n'insèrent aucune donnée**. Les rôles système (`admin`, `user`) viennent de `app:seed-rbac` (idempotent) et le catalogue des permissions de `app:sync-permissions` (à relancer à chaque ajout de permission). Symptôme si oubliées : page admin Rôles vide (« Aucun rôle trouvé »).
## Fixtures
- User admin : `admin` / `admin` (ROLE_ADMIN)
+1 -18
View File
@@ -6,23 +6,6 @@ declare(strict_types=1);
* Liste ordonnée des modules actifs (classes implémentant App\Shared\Domain\Module\ModuleInterface).
* Activer/désactiver un module = ajouter/commenter sa ligne. Exposé par GET /api/modules.
*/
use App\Module\Absence\AbsenceModule;
use App\Module\Core\CoreModule;
use App\Module\Directory\DirectoryModule;
use App\Module\Integration\IntegrationModule;
use App\Module\Mail\MailModule;
use App\Module\ProjectManagement\ProjectManagementModule;
use App\Module\Reporting\ReportingModule;
use App\Module\TimeTracking\TimeTrackingModule;
return [
CoreModule::class,
TimeTrackingModule::class,
ProjectManagementModule::class,
AbsenceModule::class,
DirectoryModule::class,
MailModule::class,
IntegrationModule::class,
ReportingModule::class,
// Aucun module pour l'instant — les modules arrivent à partir du ticket 1.1 (Core).
];
-14
View File
@@ -1,20 +1,6 @@
api_platform:
title: Lesstime API
version: 1.0.0
# Modular monolith: entities (and their #[ApiFilter] attributes) live under
# src/Module/*/Domain/Entity, not the default src/Entity. Resources are still
# discovered via service autoconfiguration, but #[ApiFilter] services are only
# registered for classes found in these paths — without them, every filter is
# silently ignored. Decoupled ApiResource classes stay discovered via tags.
mapping:
paths:
- '%kernel.project_dir%/src/Module/Core/Domain/Entity'
- '%kernel.project_dir%/src/Module/TimeTracking/Domain/Entity'
- '%kernel.project_dir%/src/Module/ProjectManagement/Domain/Entity'
- '%kernel.project_dir%/src/Module/Absence/Domain/Entity'
- '%kernel.project_dir%/src/Module/Directory/Domain/Entity'
- '%kernel.project_dir%/src/Module/Mail/Domain/Entity'
- '%kernel.project_dir%/src/Module/Integration/Domain/Entity'
formats:
jsonld: ['application/ld+json']
json: ['application/json']
+14 -59
View File
@@ -1,19 +1,12 @@
doctrine:
dbal:
default_connection: default
connections:
# ORM uses `default`; AuditLogWriter uses `audit` (same DSN, separate
# service) to write outside the ORM transaction so audit rows survive
# an application-side rollback and avoid transactional entanglement.
default:
url: '%env(resolve:DATABASE_URL)%'
profiling_collect_backtrace: '%kernel.debug%'
# audit_log has no ORM entity (written via raw DBAL). Exclude it
# from schema comparison so migrations:diff / schema:validate stay
# clean. Creation/teardown stay driven by migrations.
schema_filter: '~^(?!audit_log$).+~'
audit:
url: '%env(resolve:DATABASE_URL)%'
url: '%env(resolve:DATABASE_URL)%'
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
#server_version: '16'
profiling_collect_backtrace: '%kernel.debug%'
orm:
validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
@@ -21,60 +14,22 @@ doctrine:
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
auto_mapping: true
resolve_target_entities:
App\Shared\Domain\Contract\UserInterface: App\Module\Core\Domain\Entity\User
App\Shared\Domain\Contract\ProjectInterface: App\Module\ProjectManagement\Domain\Entity\Project
App\Shared\Domain\Contract\TaskInterface: App\Module\ProjectManagement\Domain\Entity\Task
App\Shared\Domain\Contract\TaskTagInterface: App\Module\ProjectManagement\Domain\Entity\TaskTag
App\Shared\Domain\Contract\ClientInterface: App\Module\Directory\Domain\Entity\Client
App\Shared\Domain\Contract\UserInterface: App\Entity\User
mappings:
Core:
App:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/Core/Domain/Entity'
prefix: 'App\Module\Core\Domain\Entity'
TimeTracking:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/TimeTracking/Domain/Entity'
prefix: 'App\Module\TimeTracking\Domain\Entity'
ProjectManagement:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/ProjectManagement/Domain/Entity'
prefix: 'App\Module\ProjectManagement\Domain\Entity'
Absence:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/Absence/Domain/Entity'
prefix: 'App\Module\Absence\Domain\Entity'
Directory:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/Directory/Domain/Entity'
prefix: 'App\Module\Directory\Domain\Entity'
Mail:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/Mail/Domain/Entity'
prefix: 'App\Module\Mail\Domain\Entity'
Integration:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/Integration/Domain/Entity'
prefix: 'App\Module\Integration\Domain\Entity'
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
controller_resolver:
auto_mapping: false
when@test:
doctrine:
dbal:
# Propagate the _test suffix to BOTH connections: the audit
# connection must write to the test DB, not the dev DB.
connections:
default:
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
audit:
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
# "TEST_TOKEN" is typically set by ParaTest
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
when@prod:
doctrine:
+1 -1
View File
@@ -23,7 +23,7 @@ framework:
# messenger:consume à maintenir. La sync de fond reste assurée par le cron OS
# (app:mail:sync, synchrone, indépendant du bus). Repasser à `async` + worker si
# la boîte grossit au point que la sync à la demande approche le timeout PHP.
'App\Module\Mail\Application\Message\MailSyncRequested': sync
'App\Message\MailSyncRequested': sync
when@test:
framework:
+1 -1
View File
@@ -10,7 +10,7 @@ security:
providers:
app_user_provider:
entity:
class: App\Module\Core\Domain\Entity\User
class: App\Entity\User
property: username
firewalls:
+9 -85
View File
@@ -31,114 +31,38 @@ services:
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
App\Module\ProjectManagement\Infrastructure\EventListener\TaskDocumentListener:
App\EventListener\TaskDocumentListener:
arguments:
$uploadDir: '%task_document_upload_dir%'
tags:
- { name: doctrine.orm.entity_listener }
App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\TaskDocumentProcessor:
App\State\TaskDocumentProcessor:
arguments:
$uploadDir: '%task_document_upload_dir%'
App\Module\ProjectManagement\Infrastructure\Controller\TaskDocumentDownloadController:
App\Controller\TaskDocumentDownloadController:
arguments:
$uploadDir: '%task_document_upload_dir%'
App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Task\AddTaskDocumentTool:
App\Mcp\Tool\Task\AddTaskDocumentTool:
arguments:
$uploadDir: '%task_document_upload_dir%'
App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Task\UpdateTaskDocumentTool:
App\Mcp\Tool\Task\UpdateTaskDocumentTool:
arguments:
$uploadDir: '%task_document_upload_dir%'
App\Module\Core\Infrastructure\Controller\UserAvatarController:
App\Controller\UserAvatarController:
arguments:
$avatarUploadDir: '%avatar_upload_dir%'
App\Module\Absence\Infrastructure\Controller\AbsenceJustificationUploadController:
App\Controller\Absence\AbsenceJustificationUploadController:
arguments:
$uploadDir: '%absence_justification_upload_dir%'
App\Module\Absence\Infrastructure\Controller\AbsenceJustificationDownloadController:
App\Controller\Absence\AbsenceJustificationDownloadController:
arguments:
$uploadDir: '%absence_justification_upload_dir%'
App\Module\Integration\Domain\Service\FileSource: '@App\Module\Integration\Infrastructure\Service\SmbFileSource'
App\Module\Integration\Domain\Repository\GiteaConfigurationRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineGiteaConfigurationRepository'
App\Module\Integration\Domain\Repository\BookStackConfigurationRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineBookStackConfigurationRepository'
App\Module\Integration\Domain\Repository\ZimbraConfigurationRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineZimbraConfigurationRepository'
App\Module\Integration\Domain\Repository\ShareConfigurationRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineShareConfigurationRepository'
App\Module\Integration\Domain\Repository\TaskBookStackLinkRepositoryInterface: '@App\Module\Integration\Infrastructure\Doctrine\DoctrineTaskBookStackLinkRepository'
App\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\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\Service\Share\FileSource: '@App\Service\Share\SmbFileSource'
+9 -19
View File
@@ -4,17 +4,11 @@ declare(strict_types=1);
/*
* Définition de la sidebar (sections + items) — navigation GLOBALE uniquement.
* Filtrée par SidebarFilter :
* - `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) ;
* - `permission` : section ou item masqué si la permission effective absente (RBAC fin —
* `User::getEffectivePermissions()` ; ROLE_ADMIN bypasse via le voter, mais la
* sidebar évalue les permissions effectives réelles — combiner avec `roles` au besoin).
* Les items contextuels (Kanban/Groupes/Archives), feature-flag (Documents) et user-flag
* Filtrée par SidebarFilter : `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,
* le RBAC fin par permission arrive en #1.2).
* Les items contextuels (Kanban/Groupes/Archives), feature-flag (Documents, Mail) et user-flag
* (Mes absences) restent rendus côté layout, hors de cet endpoint.
* Mail est déclaré ici UNIQUEMENT pour le gating module (disabledRoutes si module inactif) ;
* son rendu visuel + badge non-lus reste géré côté layout, qui filtre `/mail` de translatedSections
* pour éviter le doublon.
* Les labels sont des clés i18n (sidebar.<domaine>.<item>).
*/
return [
@@ -23,11 +17,9 @@ return [
'icon' => 'mdi:view-dashboard-outline',
'items' => [
['label' => 'sidebar.general.dashboard', 'to' => '/', 'icon' => 'mdi:view-dashboard-outline'],
['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline', 'module' => 'project-management', 'permission' => 'project-management.tasks.view'],
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline', 'module' => 'project-management', 'permission' => 'project-management.projects.view'],
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline', 'module' => 'time-tracking', 'permission' => 'time-tracking.entries.view'],
// Gating module uniquement (cf. en-tête) : rendu visuel + badge gérés côté layout.
['label' => 'sidebar.general.mail', 'to' => '/mail', 'icon' => 'mdi:email-outline', 'module' => 'mail'],
['label' => 'sidebar.general.myTasks', 'to' => '/my-tasks', 'icon' => 'mdi:clipboard-check-outline'],
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline'],
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-outline'],
],
],
[
@@ -35,10 +27,8 @@ return [
'icon' => 'mdi:cog-outline',
'roles' => ['ROLE_ADMIN'],
'items' => [
['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline', 'module' => 'absence'],
['label' => 'sidebar.admin.directory', 'to' => '/directory', 'icon' => 'mdi:card-account-details-outline', 'module' => 'directory'],
['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline', 'permission' => 'core.users.view'],
['label' => 'sidebar.admin.reporting', 'to' => '/reporting', 'icon' => 'mdi:chart-line', 'module' => 'reporting', 'permission' => 'reporting.view'],
['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline'],
['label' => 'sidebar.admin.administration', 'to' => '/admin', 'icon' => 'mdi:cog-outline'],
],
],
];
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.4.33'
app.version: '0.4.30'
+1 -31
View File
@@ -128,12 +128,6 @@ sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintena
echo "==> Running migrations..."
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
echo "==> Seeding RBAC system roles (idempotent)..."
sudo docker compose exec -T -u www-data app php bin/console app:seed-rbac
echo "==> Syncing RBAC permissions catalog..."
sudo docker compose exec -T -u www-data app php bin/console app:sync-permissions
echo "==> Clearing cache..."
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
@@ -300,31 +294,7 @@ cd /var/www/lesstime
./deploy.sh v0.3.13 # deploie une version specifique
```
C'est tout. Le script pull l'image, redemarre le conteneur, lance les migrations, seed les roles
systeme RBAC, synchronise le catalogue des permissions et vide le cache.
---
## RBAC : roles & permissions (post-deploiement)
Le module RBAC (entites `Role` / `Permission`) repose sur des donnees qui ne sont **pas**
inserees par les migrations (celles-ci creent uniquement les tables). Deux commandes idempotentes
les peuplent, integrees au `deploy.sh` :
| Commande | Effet |
|----------|-------|
| `app:seed-rbac` | Cree les **roles systeme** `admin` (Administrateur) et `user` (Utilisateur). Idempotent : ne recree rien si deja present. |
| `app:sync-permissions` | (Re)synchronise le **catalogue des permissions** a partir des modules actifs. A relancer a chaque ajout de permission dans le code. |
Symptome si elles n'ont pas tourne : la page d'admin **Roles** affiche « Aucun role trouve ».
Correctif manuel sur une prod deja deployee (sans relancer un deploiement complet) :
```bash
cd /var/www/lesstime
sudo docker compose exec -T -u www-data app php bin/console app:seed-rbac
sudo docker compose exec -T -u www-data app php bin/console app:sync-permissions
```
C'est tout. Le script pull l'image, redemarre le conteneur, lance les migrations et vide le cache.
---
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,732 +0,0 @@
# LST-63 (1.1) — Module Core : Identité (User/Auth/JWT) & Notifications — 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:** Migrer l'identité (`User` + Auth/JWT + password hashing + `MeProvider`) et les notifications dans `src/Module/Core/`, exposer le contrat `UserInterface` enrichi + `NotifierInterface`, déclarer `CoreModule` (REQUIRED), et créer le premier vrai layer front `modules/core/`**sans aucune migration destructive et sans casser le login à aucune étape**.
**Architecture:** Strangler 100 % additif, phasé. On déplace physiquement la classe `User` vers `App\Module\Core\Domain\Entity\User` (table `user` inchangée → zéro migration), on re-pointe `resolve_target_entities` et le provider de sécurité, puis on bascule les 8 relations d'entités et les 26 consommateurs du concret `App\Entity\User` vers le **contrat** `App\Shared\Domain\Contract\UserInterface` (enrichi des accessors réellement utilisés). Les notifications passent par un `NotifierInterface` (impl Core). Chaque phase laisse `make test` vert ET le login JWT fonctionnel (re-vérifié par curl).
**Tech Stack:** PHP 8.4 / Symfony 8 / API Platform 4 / Doctrine ORM / lexik/jwt-authentication / PostgreSQL 16 / PHPUnit 13 — front Nuxt 4.3 / Vue 3.5 / Pinia 3.
## Global Constraints
- **`declare(strict_types=1);`** en tête de chaque fichier PHP.
- **Zéro migration destructive** : le déplacement de namespace ne change ni la table (`user`) ni les colonnes → `doctrine:migrations:diff` doit produire un diff VIDE. Si un diff non vide apparaît, c'est un bug (mapping mal recopié) — corriger, ne pas générer la migration.
- **Login JWT fonctionnel à chaque phase** : vérif curl obligatoire (voir « Vérification login » ci-dessous) après toute phase touchant `User`/sécurité.
- **AC ticket** : (1) login/JWT OK via le module ; (2) aucun `use App\Entity\User;` hors `src/Module/Core/` ; (3) `make test` vert, aucune migration destructive.
- **Commits** : `<type>(<scope>) : <message>` (espaces autour du `:`). **Jamais** de mention IA/Claude/Anthropic.
- **`config/reference.php`** : auto-généré, **jamais committé** (apparaît modifié dans `git status`).
- **Tests** : `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`. Baseline avant ce ticket : **115 tests, 227 assertions** (16 PHPUnit Notices préexistantes, non bloquantes).
- **Front** : `nuxt typecheck` n'est PAS un gate vert sur ce stack (cf. plan LST-62) — gate front = zéro `Cannot find module`, auto-imports présents dans `.nuxt/imports.d.ts`, smoke runtime.
- **PostgreSQL** : noms de colonnes en minuscules dans le SQL brut.
## Vérification login (à exécuter après chaque phase back touchant User/sécurité)
```bash
# Doit renvoyer http=204 (cookie BEARER posé) puis le profil courant
curl -s -c /tmp/cj.txt -X POST http://localhost:8082/api/login_check \
-H "Content-Type: application/json" -d '{"username":"alice","password":"alice"}' \
-o /dev/null -w "login http=%{http_code}\n"
curl -s -b /tmp/cj.txt http://localhost:8082/api/me -w "\nme http=%{http_code}\n" | head -c 400
# MCP apiToken (ApiTokenAuthenticator) — admin
curl -s -X POST http://localhost:8082/_mcp -H "Authorization: Bearer dev-mcp-token-for-testing-only-do-not-use-in-production" \
-H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"ping"}' -o /dev/null -w "mcp http=%{http_code}\n"
```
Attendu : `login http=204`, `me http=200` avec le JSON de l'utilisateur (`username`, `roles`), MCP répond (200). **Si l'un casse, arrêter la phase et corriger avant de committer.**
## Décisions de conception (actées, à valider PO a posteriori)
1. **`UserInterface` enrichi (contrat de lecture)** — plutôt que de garder `App\Entity\User` partout, on enrichit `App\Shared\Domain\Contract\UserInterface` des accessors **réellement consommés** hors Core (lecture). Les setters/écriture restent sur le concret (Core uniquement). Cela permet de typer les 8 relations et les 26 consommateurs sur le contrat sans casse.
2. **Move physique, table inchangée**`User` change de namespace mais garde `#[ORM\Table(name: '`user`')]` et toutes ses colonnes → aucune migration. La classe reste une entité Doctrine mappée (nouveau dir de mapping `Core`).
3. **Relations via le contrat** — les 8 entités passent à `targetEntity: UserInterface::class` + type `?UserInterface`, résolu par `resolve_target_entities → Core\User`. C'est le pattern Starseed.
4. **Notification dans Core + `NotifierInterface`**`Notification` migre dans Core (couplée à l'identité) ; la création de notif passe par `NotifierInterface` (impl Core), `TaskNotificationListener` (qui reste legacy en Phase D) en dépend par contrat. L'API REST `/api/notifications` est préservée à l'identique.
5. **Front layer `modules/core/`** — login, profile, admin users **déplacés** de `frontend/pages/` vers `frontend/modules/core/pages/` (premier layer réel ; le scan `readdirSync('modules/')` de LST-62 l'enregistre automatiquement). Le routage Nuxt est préservé (mêmes chemins d'URL).
---
## Phase A — Squelette Core + contrats (100 % additif, app inchangée)
### Task 1: `CoreModule` + `UserRepositoryInterface` + `NotifierInterface` + contrat `UserInterface` enrichi
**Files:**
- Create: `src/Module/Core/CoreModule.php`
- Create: `src/Module/Core/Domain/Repository/UserRepositoryInterface.php`
- Create: `src/Shared/Domain/Contract/NotifierInterface.php`
- Modify: `src/Shared/Domain/Contract/UserInterface.php` (enrichir)
- Create: `tests/Unit/Module/Core/CoreModuleTest.php`
**Interfaces:**
- Produces :
- `App\Module\Core\CoreModule implements ModuleInterface` : `id()='core'`, `label()='Core'`, `isRequired()=true`, `permissions()` (stub pour 1.2, voir code).
- `App\Module\Core\Domain\Repository\UserRepositoryInterface` : `findByRole(string $role): array`, `findActiveEmployees(\DateTimeInterface $date): array`, `findOneByUsername(string $username): ?UserInterface`.
- `App\Shared\Domain\Contract\NotifierInterface` : `notify(UserInterface $user, string $type, string $title, string $message): void`.
- `UserInterface` enrichi (lecture) : `getId(): ?int`, `getUserIdentifier(): string`, `getUsername(): string`, `getRoles(): array`, `getFirstName(): ?string`, `getLastName(): ?string`, `getAvatarUrl(): ?string`, `isEmployee(): bool`.
- Consumes : `App\Shared\Domain\Module\ModuleInterface` (existant).
- [ ] **Step 1: Écrire le test unitaire `CoreModule`**
`tests/Unit/Module/Core/CoreModuleTest.php` :
```php
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Module\Core;
use App\Module\Core\CoreModule;
use App\Shared\Domain\Module\ModuleInterface;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class CoreModuleTest extends TestCase
{
public function testItIsAModule(): void
{
self::assertInstanceOf(ModuleInterface::class, new CoreModule());
}
public function testIdentity(): void
{
self::assertSame('core', CoreModule::id());
self::assertTrue(CoreModule::isRequired());
self::assertNotSame('', CoreModule::label());
}
public function testPermissionsAreWellFormed(): void
{
foreach (CoreModule::permissions() as $permission) {
self::assertArrayHasKey('code', $permission);
self::assertArrayHasKey('label', $permission);
}
}
}
```
- [ ] **Step 2: Lancer le test, vérifier l'échec**
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Module/Core/CoreModuleTest.php`
Expected: FAIL (classe `CoreModule` inexistante).
- [ ] **Step 3: Créer `CoreModule`**
`src/Module/Core/CoreModule.php` :
```php
<?php
declare(strict_types=1);
namespace App\Module\Core;
use App\Shared\Domain\Module\ModuleInterface;
final class CoreModule implements ModuleInterface
{
public static function id(): string
{
return 'core';
}
public static function label(): string
{
return 'Core';
}
public static function isRequired(): bool
{
return true;
}
/**
* Permissions posées pour le RBAC fin (1.2). Inertes tant que 1.2 n'est pas livré.
*
* @return list<array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'core.user.read', 'label' => 'Consulter les utilisateurs'],
['code' => 'core.user.manage', 'label' => 'Gérer les utilisateurs'],
['code' => 'core.notification.read', 'label' => 'Consulter ses notifications'],
];
}
}
```
> ⚠️ Confirmer la signature EXACTE de `ModuleInterface` (`src/Shared/Domain/Module/ModuleInterface.php`) avant d'écrire : la cartographie indique `id()`, `label()`, `isRequired()`, `permissions()` statiques. Si une méthode diffère (ex. non statique), aligner `CoreModule` ET le test dessus.
- [ ] **Step 4: Lancer le test, vérifier le vert**
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Unit/Module/Core/CoreModuleTest.php`
Expected: PASS (3 tests).
- [ ] **Step 5: Enrichir le contrat `UserInterface`**
Remplace `src/Shared/Domain/Contract/UserInterface.php` par :
```php
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Contrat de LECTURE de l'identité, consommé hors du module Core.
* Les écritures (setPassword, setters HR…) restent sur le concret Core\Domain\Entity\User.
*/
interface UserInterface
{
public function getId(): ?int;
public function getUserIdentifier(): string;
public function getUsername(): string;
/** @return list<string> */
public function getRoles(): array;
public function getFirstName(): ?string;
public function getLastName(): ?string;
public function getAvatarUrl(): ?string;
public function isEmployee(): bool;
}
```
> ⚠️ Cet enrichissement DOIT correspondre à des méthodes existantes de l'entité `User` (la cartographie confirme `getId`, `getUserIdentifier`, `getUsername`, `getRoles`, `getFirstName`, `getLastName`, `getAvatarUrl`, `isEmployee`). Si une signature diffère (ex. `getAvatarUrl(): string` non-nullable), aligner le contrat sur le réel. Ne PAS ajouter au contrat une méthode absente de `User`.
- [ ] **Step 6: Créer `UserRepositoryInterface`**
`src/Module/Core/Domain/Repository/UserRepositoryInterface.php` :
```php
<?php
declare(strict_types=1);
namespace App\Module\Core\Domain\Repository;
use App\Shared\Domain\Contract\UserInterface;
interface UserRepositoryInterface
{
/**
* @return list<UserInterface>
*/
public function findByRole(string $role): array;
/**
* @return list<UserInterface>
*/
public function findActiveEmployees(\DateTimeInterface $date): array;
public function findOneByUsername(string $username): ?UserInterface;
}
```
- [ ] **Step 7: Créer `NotifierInterface`**
`src/Shared/Domain/Contract/NotifierInterface.php` :
```php
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
interface NotifierInterface
{
public function notify(UserInterface $user, string $type, string $title, string $message): void;
}
```
- [ ] **Step 8: Suite complète + commit**
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`
Expected: PASS (115 + 3 = 118 tests). L'enrichissement du contrat ne casse rien (l'entité `User` implémente déjà ces méthodes ; `resolve_target_entities` pointe encore `App\Entity\User`).
Run: `make php-cs-fixer-allow-risky`
```bash
git add src/Module/Core/CoreModule.php src/Module/Core/Domain/Repository/UserRepositoryInterface.php src/Shared/Domain/Contract/NotifierInterface.php src/Shared/Domain/Contract/UserInterface.php tests/Unit/Module/Core/CoreModuleTest.php
git commit -m "feat(core) : add CoreModule, user repository contract, notifier contract and enriched user contract"
```
---
## Phase B — Déplacer `User` + Auth dans Core (re-pointage, zéro migration)
### Task 2: Déplacer la classe `User` vers Core + mapping Doctrine + provider sécurité
**Files:**
- Move: `src/Entity/User.php``src/Module/Core/Domain/Entity/User.php` (namespace `App\Module\Core\Domain\Entity`)
- Modify: `config/packages/doctrine.yaml` (mapping `Core` + `resolve_target_entities`)
- Modify: `config/packages/security.yaml` (`app_user_provider.entity.class`)
- Modify: `config/packages/api_platform.yaml` (mapping paths : ajouter le dir entité Core)
**Interfaces:**
- Produces : entité `App\Module\Core\Domain\Entity\User` (table `user` inchangée), résolue par `resolve_target_entities`.
- [ ] **Step 1: Déplacer le fichier (git mv) et changer le namespace**
```bash
cd /home/matthieu/dev_malio/Lesstime
mkdir -p src/Module/Core/Domain/Entity
git mv src/Entity/User.php src/Module/Core/Domain/Entity/User.php
```
Puis éditer `src/Module/Core/Domain/Entity/User.php` :
- `namespace App\Entity;``namespace App\Module\Core\Domain\Entity;`
- Adapter les `use` internes devenus nécessaires (l'entité référençait `UserRepository`, `MeProvider`, `UserPasswordHasherProcessor`, l'enum `ContractType`, le contrat `UserInterface as SharedUserInterface`). Mettre les `use` complets vers leurs emplacements ACTUELS (la plupart bougent en Tasks 3/4 ; pour cette task, pointer encore vers `App\Repository\UserRepository`, `App\State\MeProvider`, `App\State\UserPasswordHasherProcessor`, `App\Entity\Enum\ContractType` ou l'emplacement réel — vérifier les `use` d'origine et les conserver tels quels tant que ces classes n'ont pas bougé).
- Garder VERBATIM : tous les attributs `#[ORM\...]` (dont `#[ORM\Table(name: '`user`')]`), `#[ApiResource(...)]`, `#[ApiProperty(...)]`, toutes les propriétés/méthodes, `implements UserInterface, PasswordAuthenticatedUserInterface, SharedUserInterface`.
> ⚠️ Lire le fichier d'origine en entier AVANT de déplacer pour relever tous les `use`. Ne changer QUE le `namespace` et, si besoin, garder les `use` pointant vers les emplacements actuels des classes non encore déplacées.
- [ ] **Step 2: Mapping Doctrine + resolve_target_entities**
Dans `config/packages/doctrine.yaml`, sous `orm:` :
- `resolve_target_entities` :
```yaml
resolve_target_entities:
App\Shared\Domain\Contract\UserInterface: App\Module\Core\Domain\Entity\User
```
- Ajouter un mapping pour les entités Core (en plus du mapping `App` existant qui scanne `src/Entity`) :
```yaml
mappings:
App:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
alias: App
Core:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/Core/Domain/Entity'
prefix: 'App\Module\Core\Domain\Entity'
```
> Le mapping `App` (src/Entity) ne contient plus `User.php` (déplacé) → cohérent. Aucune entité orpheline.
- [ ] **Step 3: Provider de sécurité**
Dans `config/packages/security.yaml` :
```yaml
providers:
app_user_provider:
entity:
class: App\Module\Core\Domain\Entity\User
property: username
```
- [ ] **Step 4: API Platform mapping paths**
Dans `config/packages/api_platform.yaml`, ajouter au `mapping.paths` le dossier entité Core (l'`#[ApiResource]` est porté par l'entité `User` déplacée) :
```yaml
- '%kernel.project_dir%/src/Module/Core/Domain/Entity'
```
> Conserver tous les paths existants. Si `api_platform.yaml` n'a pas de `mapping.paths` explicite (auto-discovery), vérifier que les Resources sous `src/Module/...` sont bien découvertes (comme `src/Shared/...` l'a été en #56 — cf. LEARNINGS : API Platform 4 auto-découvre). Si la découverte auto suffit, NE PAS ajouter de path ; sinon ajouter celui ci-dessus.
- [ ] **Step 5: Vider le cache + vérifier qu'AUCUNE migration n'est nécessaire**
```bash
docker exec -t -u www-data php-lesstime-fpm php bin/console cache:clear
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:schema:validate
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff --no-interaction 2>&1 | tail -20
```
Expected : schema VALID (mapping ok, sync DB ok). Le `diff` doit annoncer **« No changes detected »** (table/colonnes identiques). **Si une migration est générée, la SUPPRIMER** (`git status` → retirer le fichier sous `migrations/`) : un diff non vide = mapping mal recopié, corriger l'entité.
- [ ] **Step 6: Vérif login + suite complète**
Exécuter le bloc « Vérification login » (curl) → `login http=204`, `me http=200`, MCP 200.
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`
Expected: PASS (118). Les consommateurs importent encore `App\Entity\User`**ERREUR attendue** : la classe n'existe plus à cet emplacement. ⇒ Cette task NE PASSE PAS seule ; elle est indissociable de la Task 3 (rewire). **Voir note ci-dessous.**
> 🔴 **Note d'ordonnancement** : déplacer `User` casse les 26 `use App\Entity\User;`. Pour garder l'app bootable entre Task 2 et Task 3, **ajouter un alias de compatibilité TEMPORAIRE** au tout début de Task 2 et le retirer en fin de Task 3 :
> Créer `src/Module/Core/_compat_user_alias.php` (chargé via `composer.json` `autoload.files`) :
> ```php
> <?php
> declare(strict_types=1);
> if (!class_exists(\App\Entity\User::class, false)) {
> class_alias(\App\Module\Core\Domain\Entity\User::class, \App\Entity\User::class);
> }
> ```
> Ajouter `"files": ["src/Module/Core/_compat_user_alias.php"]` sous `autoload` dans `composer.json`, puis `composer dump-autoload`. Cela garde les 26 consommateurs fonctionnels (et Doctrine `targetEntity: User::class` résolu via l'alias) le temps de la Task 3. **L'alias est SUPPRIMÉ en Task 3 Step final** (avec le retrait du fichier, l'entrée composer et un nouveau `dump-autoload`) une fois tous les consommateurs basculés sur le contrat. La verif login de cette Step utilise donc l'alias — c'est attendu.
- [ ] **Step 7: php-cs-fixer + commit (Phase B, avec alias temporaire)**
Run: `make php-cs-fixer-allow-risky`
```bash
git add src/Module/Core/Domain/Entity/User.php src/Module/Core/_compat_user_alias.php composer.json composer.lock config/packages/doctrine.yaml config/packages/security.yaml config/packages/api_platform.yaml
git commit -m "feat(core) : move user entity into core module and repoint security/doctrine (temp legacy alias)"
```
---
## Phase C — Basculer relations + consommateurs sur le contrat, retirer l'alias
### Task 3: Relations d'entités → `UserInterface::class`
**Files (8 entités):**
- Modify: `src/Entity/Task.php` (assignee ManyToOne, collaborators ManyToMany)
- Modify: `src/Entity/TimeEntry.php` (user)
- Modify: `src/Entity/AbsenceRequest.php` (user)
- Modify: `src/Entity/AbsenceBalance.php` (user)
- Modify: `src/Entity/TaskDocument.php` (user)
- Modify: `src/Entity/TaskMailLink.php` (user)
- Modify: `src/Module/Core/Domain/Entity/Notification.php` (user) — **après son déplacement en Phase D** ; en Phase C, `Notification` est encore `src/Entity/Notification.php`, la traiter ici aussi.
> Pour CHAQUE relation vers User : remplacer `use App\Entity\User;` par `use App\Shared\Domain\Contract\UserInterface;`, le `targetEntity: User::class` par `targetEntity: UserInterface::class`, et le type de propriété/param `?User` → `?UserInterface` (idem getters/setters). Doctrine résout via `resolve_target_entities`. La colonne FK et son nom restent identiques → **aucune migration**.
- [ ] **Step 1: Modifier les relations (entité par entité)**
Pour chaque fichier ci-dessus, lire puis appliquer le remplacement décrit. Exemple `Task.php` (assignee) :
```php
// avant
use App\Entity\User;
#[ORM\ManyToOne(targetEntity: User::class)]
private ?User $assignee = null;
public function getAssignee(): ?User { return $this->assignee; }
public function setAssignee(?User $assignee): static { $this->assignee = $assignee; return $this; }
// collaborators
#[ORM\ManyToMany(targetEntity: User::class)]
private Collection $collaborators;
// après
use App\Shared\Domain\Contract\UserInterface;
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
private ?UserInterface $assignee = null;
public function getAssignee(): ?UserInterface { return $this->assignee; }
public function setAssignee(?UserInterface $assignee): static { $this->assignee = $assignee; return $this; }
#[ORM\ManyToMany(targetEntity: UserInterface::class)]
private Collection $collaborators;
```
> ⚠️ Conserver tous les autres attributs de relation (`inversedBy`, `joinTable`, `joinColumn`, `nullable`, `onDelete`, Groups…) VERBATIM. Ne changer que le type et `targetEntity`.
- [ ] **Step 2: Valider le schéma (toujours zéro migration)**
```bash
docker exec -t -u www-data php-lesstime-fpm php bin/console cache:clear
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:schema:validate
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff --no-interaction 2>&1 | tail -5
```
Expected : « No changes detected ». Sinon corriger (un `joinColumn`/`onDelete` a été perdu).
### Task 4: Consommateurs (26 fichiers) → contrat + repository interface, MeProvider/Processor dans Core, retrait alias
**Files:** les 26 fichiers listés dans la cartographie (Controllers, Repositories, State, Services, EventListener, Security, DataFixtures, Mcp). Déplacements vers Core :
- Move: `src/Repository/UserRepository.php``src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php` (implémente `UserRepositoryInterface`, namespace `App\Module\Core\Infrastructure\Doctrine`)
- Move: `src/State/MeProvider.php``src/Module/Core/Infrastructure/ApiPlatform/State/MeProvider.php`
- Move: `src/State/UserPasswordHasherProcessor.php``src/Module/Core/Infrastructure/ApiPlatform/State/UserPasswordHasherProcessor.php`
- Modify: l'`#[ApiResource]` de l'entité `User` (les `provider:`/`processor:` pointent vers les nouveaux FQCN Core).
- Delete (en fin de task): `src/Module/Core/_compat_user_alias.php` + entrée `composer.json`.
- [ ] **Step 1: Déplacer le repository et l'aligner sur l'interface**
```bash
mkdir -p src/Module/Core/Infrastructure/Doctrine src/Module/Core/Infrastructure/ApiPlatform/State
git mv src/Repository/UserRepository.php src/Module/Core/Infrastructure/Doctrine/DoctrineUserRepository.php
git mv src/State/MeProvider.php src/Module/Core/Infrastructure/ApiPlatform/State/MeProvider.php
git mv src/State/UserPasswordHasherProcessor.php src/Module/Core/Infrastructure/ApiPlatform/State/UserPasswordHasherProcessor.php
```
Éditer `DoctrineUserRepository.php` : `namespace App\Module\Core\Infrastructure\Doctrine;`, `class DoctrineUserRepository extends ServiceEntityRepository implements UserRepositoryInterface`, `use App\Module\Core\Domain\Entity\User;`, `use App\Module\Core\Domain\Repository\UserRepositoryInterface;`, et passer `User::class` au constructeur parent. Ajouter `findOneByUsername()` si absent (`return $this->findOneBy(['username' => $username]);`). Conserver `findByRole()` (SQL natif `roles::text LIKE`) et `findActiveEmployees()`.
Éditer `User.php` : `#[ORM\Entity(repositoryClass: DoctrineUserRepository::class)]` avec le bon `use`.
Éditer `MeProvider.php` / `UserPasswordHasherProcessor.php` : nouveaux namespaces ; `use App\Module\Core\Domain\Entity\User;` (le processor manipule le concret — c'est dans Core, autorisé).
Mettre à jour les `provider:`/`processor:` dans l'`#[ApiResource]` de `User` vers les nouveaux FQCN.
- [ ] **Step 2: Lier l'interface repository au service Doctrine**
Dans `config/services.yaml`, alias pour l'injection par interface :
```yaml
App\Module\Core\Domain\Repository\UserRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository'
```
- [ ] **Step 3: Basculer les 25 autres consommateurs sur le contrat**
Pour chaque fichier important `App\Entity\User` (hors Core), remplacer `use App\Entity\User;` par `use App\Shared\Domain\Contract\UserInterface;` et le type-hint `User` par `UserInterface` (params, retours, propriétés, `@var`, expressions). Cas particuliers :
- `src/Repository/{Notification,AbsenceBalance,AbsenceRequest,TimeEntry}Repository.php` : les signatures `countUnreadByUser(User $user)` etc. → `UserInterface`. Ne pas changer la logique DQL (`n.user = :user` fonctionne avec l'instance).
- `src/State/Absence*`, `TaskDocumentProvider`, `src/Service/AbsenceBalanceService`, `src/Security/MailAccessChecker`, `src/EventListener/TaskNotificationListener` (sera retravaillé en Phase D mais peut déjà passer au contrat ici), `src/Controller/*` (7), `src/Mcp/Tool/Absence/ReviewAbsenceRequestTool`, `src/Mcp/Tool/Serializer` : remplacer le type-hint.
- `src/DataFixtures/AppFixtures.php` : **garde le concret** `App\Module\Core\Domain\Entity\User` (les fixtures INSTANCIENT `new User()` et appellent des setters d'écriture — c'est légitime ; importer le concret Core, pas le contrat). C'est hors `src/Module/Core/` mais c'est de l'écriture d'identité → exception documentée (les fixtures sont un cas d'amorçage, pas un consommateur métier).
> Liste de contrôle : après cette step, `grep -rn "use App\\\\Entity\\\\User;" src/` ne doit retourner QUE `src/DataFixtures/AppFixtures.php` (qui importe désormais le FQCN Core, donc 0 occurrence de `App\Entity\User`). Viser **0 occurrence de `App\Entity\User`** dans tout `src/`.
- [ ] **Step 4: Retirer l'alias de compatibilité**
```bash
git rm src/Module/Core/_compat_user_alias.php
```
Retirer l'entrée `"files": [...]` ajoutée sous `autoload` dans `composer.json` (Task 2), puis :
```bash
docker exec -t -u www-data php-lesstime-fpm composer dump-autoload
docker exec -t -u www-data php-lesstime-fpm php bin/console cache:clear
```
- [ ] **Step 5: `grep` de garde (AC 2) + schéma + tests + login**
```bash
grep -rn "App\\\\Entity\\\\User" src/ config/ ; echo "(doit être VIDE)"
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:schema:validate
docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff --no-interaction 2>&1 | tail -5
docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit
```
Expected : grep VIDE, schéma valide, « No changes detected », **118 tests verts**. Puis bloc « Vérification login » (login 204, me 200, MCP 200).
- [ ] **Step 6: php-cs-fixer + commit (Phase C)**
Run: `make php-cs-fixer-allow-risky`
```bash
git add -A -- src config composer.json composer.lock
git commit -m "refactor(core) : wire user relations and consumers to the shared contract, drop legacy alias"
```
> ⚠️ NE PAS `git add config/reference.php`. Vérifier `git status` avant le commit ; si `reference.php` est listé, l'exclure du `git add` (stager explicitement les fichiers voulus).
---
## Phase D — Notifications via `NotifierInterface` (impl Core)
### Task 5: Déplacer `Notification` dans Core + `Notifier` (impl) + recâbler le listener
**Files:**
- Move: `src/Entity/Notification.php``src/Module/Core/Domain/Entity/Notification.php`
- Move: `src/Repository/NotificationRepository.php``src/Module/Core/Infrastructure/Doctrine/DoctrineNotificationRepository.php`
- Move: `src/State/NotificationProvider.php``src/Module/Core/Infrastructure/ApiPlatform/State/NotificationProvider.php`
- Create: `src/Module/Core/Infrastructure/Notifier.php` (implements `NotifierInterface`)
- Modify: `src/EventListener/TaskNotificationListener.php` (dépend de `NotifierInterface`)
- Modify: `config/packages/doctrine.yaml` (le mapping `Core` couvre déjà `Domain/Entity` → Notification incluse automatiquement)
- Modify: `tests/` — ajouter `tests/Unit/Module/Core/NotifierTest.php` (ou Functional) si testable unitairement.
- [ ] **Step 1: Écrire un test du `Notifier`**
`tests/Functional/Module/Core/NotifierTest.php` (crée une notif et vérifie la persistance) :
```php
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Module\Core;
use App\Module\Core\Domain\Entity\User;
use App\Shared\Domain\Contract\NotifierInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
*/
final class NotifierTest extends KernelTestCase
{
public function testNotifyPersistsANotificationForTheUser(): void
{
self::bootKernel();
$em = self::getContainer()->get(EntityManagerInterface::class);
$notifier = self::getContainer()->get(NotifierInterface::class);
$user = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
self::assertNotNull($user);
$notifier->notify($user, 'task_assigned', 'Titre', 'Message');
$count = (int) $em->createQuery(
'SELECT COUNT(n.id) FROM App\\Module\\Core\\Domain\\Entity\\Notification n WHERE n.user = :u AND n.title = :t'
)->setParameter('u', $user)->setParameter('t', 'Titre')->getSingleScalarResult();
self::assertSame(1, $count);
}
}
```
- [ ] **Step 2: Lancer, vérifier l'échec**`NotifierInterface` non instanciable / `Notification` introuvable au nouveau namespace.
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit tests/Functional/Module/Core/NotifierTest.php`
Expected: FAIL.
- [ ] **Step 3: Déplacer `Notification` + repository + provider**
```bash
git mv src/Entity/Notification.php src/Module/Core/Domain/Entity/Notification.php
git mv src/Repository/NotificationRepository.php src/Module/Core/Infrastructure/Doctrine/DoctrineNotificationRepository.php
git mv src/State/NotificationProvider.php src/Module/Core/Infrastructure/ApiPlatform/State/NotificationProvider.php
```
- `Notification.php` : `namespace App\Module\Core\Domain\Entity;`, `use App\Shared\Domain\Contract\UserInterface;`, relation `user``targetEntity: UserInterface::class` + type `?UserInterface`, `repositoryClass: DoctrineNotificationRepository::class`, conserver `#[ORM\Table(name:'notification')]` + index VERBATIM, ApiResource (provider → nouveau FQCN). **Table/colonnes inchangées.**
- `DoctrineNotificationRepository.php` : namespace Core, `use App\Module\Core\Domain\Entity\Notification;`, signatures `UserInterface`.
- `NotificationProvider.php` : namespace Core, mêmes dépendances.
- [ ] **Step 4: Implémenter `Notifier`**
`src/Module/Core/Infrastructure/Notifier.php` :
```php
<?php
declare(strict_types=1);
namespace App\Module\Core\Infrastructure;
use App\Module\Core\Domain\Entity\Notification;
use App\Shared\Domain\Contract\NotifierInterface;
use App\Shared\Domain\Contract\UserInterface;
use Doctrine\ORM\EntityManagerInterface;
final readonly class Notifier implements NotifierInterface
{
public function __construct(private EntityManagerInterface $em) {}
public function notify(UserInterface $user, string $type, string $title, string $message): void
{
$notification = new Notification();
$notification->setUser($user);
$notification->setType($type);
$notification->setTitle($title);
$notification->setMessage($message);
$this->em->persist($notification);
$this->em->flush();
}
}
```
> ⚠️ Aligner sur les setters réels de `Notification` (la cartographie indique `user`, `type`, `title`, `message`, `isRead` default false, `createdAt`). Si `createdAt` n'est pas auto (prePersist), le poser ici. Si `setUser` attend le concret, accepter `UserInterface` (resolve_target_entities) — vérifier le type du setter.
- [ ] **Step 5: Recâbler `TaskNotificationListener` sur `NotifierInterface`**
Lire le listener ; remplacer la création directe de `Notification` (`new Notification()` + persist) par l'injection et l'appel de `NotifierInterface::notify(...)`. **Attention** : le listener tourne sur `onFlush`/`postFlush` — un `flush()` dans `notify()` pendant un `onFlush` est dangereux. Conserver le pattern existant (accumulation en `onFlush`, écriture en `postFlush`). Si `notify()` flush, l'appeler UNIQUEMENT en `postFlush` (jamais pendant `onFlush`). Préserver le comportement exact (mêmes types `task_assigned`/`task_collaborator_added`, mêmes destinataires). Adapter le test existant du listener s'il y en a un.
> Si l'intrication onFlush/postFlush rend `NotifierInterface` inadapté (flush imbriqué), documenter et garder le listener en écriture directe via le repository Core, mais TOUJOURS dépendre du contrat pour le type User. Le but AC est « Notification exposée via NotifierInterface » : `NotifierInterface` doit exister et être l'API publique pour les autres modules ; le listener interne Core peut écrire directement.
- [ ] **Step 6: Tests + login + endpoints notifications**
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`
Expected: PASS (118 + 1 = 119). Vérifier `doctrine:migrations:diff` → « No changes detected ». Bloc login. Puis curl notifications :
```bash
curl -s -b /tmp/cj.txt "http://localhost:8082/api/notifications" -w "\nnotif http=%{http_code}\n" | head -c 200
curl -s -b /tmp/cj.txt "http://localhost:8082/api/notifications/unread-count" -w "\nunread http=%{http_code}\n"
```
Expected : 200 sur les deux.
- [ ] **Step 7: php-cs-fixer + commit**
Run: `make php-cs-fixer-allow-risky`
```bash
git add -A -- src config tests
git commit -m "feat(core) : move notification into core and expose notifier contract"
```
---
## Phase E — Déclarer `CoreModule` actif
### Task 6: Enregistrer Core dans `config/modules.php`
**Files:**
- Modify: `config/modules.php`
- Modify: `tests/Functional/Shared/ModulesEndpointTest.php` (ou équivalent — adapter l'assertion à la présence de `core`)
- [ ] **Step 1: Adapter/écrire le test de l'endpoint modules**
Vérifier le test existant de `/api/modules` (cartographie : `ModulesProvider`/`ModulesResource` créés en #56). Ajouter une assertion :
```php
public function testCoreModuleIsActive(): void
{
$client = self::createClient();
// /api/modules est public (GET) d'après security.yaml
$client->request('GET', '/api/modules');
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
self::assertContains('core', $data['modules']);
}
```
> Adapter le nom de classe/fichier de test à l'existant (#56). Si aucun test fonctionnel modules n'existe, créer `tests/Functional/Shared/ModulesEndpointTest.php`.
- [ ] **Step 2: Lancer, vérifier l'échec** (modules.php retourne `[]`).
- [ ] **Step 3: Activer Core**
`config/modules.php` :
```php
<?php
declare(strict_types=1);
use App\Module\Core\CoreModule;
return [
CoreModule::class,
];
```
- [ ] **Step 4: Tests + curl**
Run: `docker exec -t -u www-data php-lesstime-fpm php vendor/bin/phpunit`
Expected: PASS. Curl :
```bash
curl -s http://localhost:8082/api/modules | head -c 200 # doit contenir "core"
```
- [ ] **Step 5: commit**
```bash
git add config/modules.php tests/
git commit -m "feat(core) : activate core module in modules registry"
```
---
## Phase F — Layer front `modules/core/`
### Task 7: Déplacer login / profile / admin users dans `frontend/modules/core/`
**Files:**
- Create: `frontend/modules/core/nuxt.config.ts` (`export default defineNuxtConfig({})`)
- Move: `frontend/pages/login.vue``frontend/modules/core/pages/login.vue`
- Move: `frontend/pages/profile.vue``frontend/modules/core/pages/profile.vue`
- Move: `frontend/pages/admin/**` (gestion users) → `frontend/modules/core/pages/admin/**`
- Move (si pertinent): composants/services liés à l'identité (ex. `frontend/components/user/**`, `frontend/components/admin/**`, `frontend/services/user.ts`) → `frontend/modules/core/{components,services}/**`
> ⚠️ AVANT de déplacer, LIRE `frontend/pages/` et `frontend/components/` pour identifier précisément les pages/compos d'identité. Le scan `readdirSync('modules/')` (LST-62) ajoute `./modules/core` à `extends` et `modules/core/composables`/`stores` à `imports.dirs`. Les `pages/` d'un layer Nuxt sont fusionnées automatiquement → **les URLs (`/login`, `/profile`, `/admin/...`) restent identiques**. Vérifier qu'aucune page déplacée n'utilise un import PAR CHEMIN cassé (auto-import sinon).
- [ ] **Step 1: Créer le layer + déplacer les pages d'identité**
```bash
cd /home/matthieu/dev_malio/Lesstime/frontend
mkdir -p modules/core/pages
printf 'export default defineNuxtConfig({})\n' > modules/core/nuxt.config.ts
git mv pages/login.vue modules/core/pages/login.vue
git mv pages/profile.vue modules/core/pages/profile.vue
# admin users : adapter au réel (git mv pages/admin/... modules/core/pages/admin/...)
```
> Lister `frontend/pages/admin/` d'abord ; déplacer UNIQUEMENT les pages de gestion des utilisateurs (pas les pages admin d'autres domaines). En cas de doute, déplacer seulement login + profile en 1.1 et laisser admin users (documenter).
- [ ] **Step 2: Corriger les imports par chemin éventuels**
Run: `cd /home/matthieu/dev_malio/Lesstime/frontend && grep -rn "pages/login\|pages/profile\|~/pages" --include=*.ts --include=*.vue . | grep -v node_modules`
Corriger toute référence cassée (les redirections `navigateTo('/login')` restent valides — c'est une URL, pas un chemin de fichier).
- [ ] **Step 3: Gate front (cf. LST-62) + smoke**
Run: `cd frontend && npx nuxt typecheck 2>&1 | grep "Cannot find module" | grep -E "modules/core|login|profile"` → doit être VIDE.
Run: `grep -E "login|profile" frontend/.nuxt/routes.* 2>/dev/null` ou démarrer `make dev-nuxt` et confirmer que `/login`, `/profile` répondent (la fusion des pages du layer est effective).
> Smoke runtime (login via navigateur) : laisser au PO si pas de navigateur côté exécutant.
- [ ] **Step 4: commit**
```bash
git add -A -- frontend
git commit -m "feat(core) : add core front layer with login, profile and admin users pages"
```
---
## Acceptance check (après toutes les phases)
- [ ] **AC1** Login/JWT OK via le module : `login http=204`, `/api/me` 200, MCP apiToken 200, `/api/notifications` 200.
- [ ] **AC2** `grep -rn "App\\Entity\\User" src/ config/`**VIDE** (User vit dans `src/Module/Core/Domain/Entity/`, consommé via contrat ; fixtures importent le FQCN Core).
- [ ] **AC3** `make test` vert (≈119 tests), `doctrine:schema:validate` OK, `doctrine:migrations:diff` = « No changes detected » (**aucune migration destructive ni même additive**).
- [ ] `/api/modules` renvoie `core` ; `CoreModule::isRequired() === true`.
- [ ] `resolve_target_entities: UserInterface → App\Module\Core\Domain\Entity\User`.
- [ ] Front : layer `modules/core/` détecté ; `/login`, `/profile` (+ admin users) accessibles aux mêmes URLs ; aucun `Cannot find module`.
- [ ] `config/reference.php` jamais committé.
## Notes pour le ticket suivant (1.2 — RBAC fin)
`CoreModule::permissions()` est déjà posé (stub). 1.2 ajoutera `Role`/`Permission`, `app:sync-permissions`, `PermissionVoter`, et fera filtrer `SidebarProvider` **par permission** (en plus du module actif + du gate rôle minimal posé en 0.2). Le contrat `UserInterface` enrichi est prêt à recevoir `getPermissions()` si besoin.
@@ -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,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.
+7 -12
View File
@@ -125,8 +125,8 @@
<script setup lang="ts">
import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/modules/project-management/services/dto/project'
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
import type { Project } from '~/services/dto/project'
import type { TaskTag } from '~/services/dto/task-tag'
import { useAppVersion } from '~/composables/useAppVersion'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
@@ -139,20 +139,15 @@ const route = useRoute()
const { t } = useI18n()
const { sections } = useSidebar()
// `/mail` est déclaré dans config/sidebar.php pour le gating module (disabledRoutes),
// mais son rendu visuel + badge non-lus est géré manuellement ci-dessous (feature-flag Mail).
// On le filtre des sections dynamiques pour éviter un doublon dans la nav.
const translatedSections = computed(() =>
sections.value.map((section) => ({
label: t(section.label),
icon: section.icon,
items: section.items
.filter((item) => item.to !== '/mail')
.map((item) => ({
label: t(item.label),
to: item.to,
icon: item.icon,
})),
items: section.items.map((item) => ({
label: t(item.label),
to: item.to,
icon: item.icon,
})),
})),
)
@@ -19,8 +19,8 @@
</template>
<script setup lang="ts">
import type { AbsenceBalance } from '~/modules/absence/services/dto/absence'
import { useAbsenceService } from '~/modules/absence/services/absences'
import type { AbsenceBalance } from '~/services/dto/absence'
import { useAbsenceService } from '~/services/absences'
const props = defineProps<{
modelValue: boolean
@@ -73,7 +73,8 @@
</template>
<script setup lang="ts">
import type { AbsenceBalance } from '~/modules/absence/services/dto/absence'
import type { AbsenceBalance } from '~/services/dto/absence'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
const props = defineProps<{
balances: AbsenceBalance[]
@@ -52,8 +52,9 @@
</template>
<script setup lang="ts">
import type { AbsenceRequest } from '~/modules/absence/services/dto/absence'
import { useAbsenceService } from '~/modules/absence/services/absences'
import type { AbsenceRequest } from '~/services/dto/absence'
import { useAbsenceService } from '~/services/absences'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
const props = defineProps<{
absences: AbsenceRequest[]
@@ -29,7 +29,7 @@
</template>
<script setup lang="ts">
import type { HalfDay } from '~/modules/absence/services/dto/absence'
import type { HalfDay } from '~/services/dto/absence'
const props = withDefaults(defineProps<{
/** ISO date string "YYYY-MM-DD" or null. */
@@ -135,8 +135,9 @@
</template>
<script setup lang="ts">
import type { AbsenceRequest } from '~/modules/absence/services/dto/absence'
import { useAbsenceService } from '~/modules/absence/services/absences'
import type { AbsenceRequest } from '~/services/dto/absence'
import { useAbsenceService } from '~/services/absences'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
const props = defineProps<{
modelValue: boolean
@@ -26,8 +26,9 @@
</template>
<script setup lang="ts">
import type { AbsenceRequest } from '~/modules/absence/services/dto/absence'
import { useAbsenceService } from '~/modules/absence/services/absences'
import type { AbsenceRequest } from '~/services/dto/absence'
import { useAbsenceService } from '~/services/absences'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
const props = defineProps<{
modelValue: boolean
@@ -105,8 +105,9 @@
</template>
<script setup lang="ts">
import type { AbsencePolicy, AbsencePreviewResult, AbsenceType, HalfDay } from '~/modules/absence/services/dto/absence'
import { useAbsenceService } from '~/modules/absence/services/absences'
import type { AbsencePolicy, AbsencePreviewResult, AbsenceType, HalfDay } from '~/services/dto/absence'
import { useAbsenceService } from '~/services/absences'
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
const props = defineProps<{
modelValue: boolean
@@ -51,8 +51,8 @@
</template>
<script setup lang="ts">
import type { AbsencePolicy } from '~/modules/absence/services/dto/absence'
import { useAbsenceService } from '~/modules/absence/services/absences'
import type { AbsencePolicy } from '~/services/dto/absence'
import { useAbsenceService } from '~/services/absences'
const service = useAbsenceService()
const rows = ref<AbsencePolicy[]>([])
-160
View File
@@ -1,160 +0,0 @@
<template>
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">{{ $t('admin.audit.title') }}</h2>
</div>
<div class="mt-4 flex flex-wrap gap-4">
<MalioSelect
v-model="entityTypeFilter"
:options="entityTypeOptions"
:label="$t('admin.audit.filterEntityType')"
:empty-option-label="$t('admin.audit.filterEntityTypeAll')"
group-class="w-64"
/>
<MalioSelect
v-model="actionFilter"
:options="actionOptions"
:label="$t('admin.audit.filterAction')"
:empty-option-label="$t('admin.audit.filterActionAll')"
group-class="w-64"
/>
</div>
<DataTable
:columns="columns"
:items="rows"
:loading="isLoading"
:empty-message="$t('admin.audit.empty')"
>
<template #cell-performedAt="{ item }">
{{ formatDate(item.performedAt) }}
</template>
<template #cell-entityType="{ item }">
{{ entityTypeLabel(item.entityType) }}
</template>
<template #cell-action="{ item }">
{{ actionLabel(item.action) }}
</template>
</DataTable>
<div class="mt-4 flex items-center justify-between">
<span class="text-sm text-neutral-500">{{ $t('admin.audit.page', { page }) }}</span>
<div class="flex gap-2">
<MalioButton
variant="secondary"
button-class="w-auto px-4"
:label="$t('admin.audit.previous')"
:disabled="page <= 1 || isLoading"
@click="goToPage(page - 1)"
/>
<MalioButton
variant="secondary"
button-class="w-auto px-4"
:label="$t('admin.audit.next')"
:disabled="!hasNextPage || isLoading"
@click="goToPage(page + 1)"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { AuditLogAction, AuditLogItem } from '~/modules/core/services/audit-logs'
import { useAuditLogService } from '~/modules/core/services/audit-logs'
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
const { t, te } = useI18n()
const PAGE_SIZE = 30
const columns = computed<DataTableColumn[]>(() => [
{ key: 'performedAt', label: t('admin.audit.date'), primary: true },
{ key: 'performedBy', label: t('admin.audit.performedBy') },
{ key: 'entityType', label: t('admin.audit.entityType') },
{ key: 'action', label: t('admin.audit.action') },
{ key: 'entityId', label: t('admin.audit.entityId') },
])
const actionOptions = computed<{ value: AuditLogAction, label: string }[]>(() => [
{ value: 'create', label: t('audit.action.create') },
{ value: 'update', label: t('audit.action.update') },
{ value: 'delete', label: t('audit.action.delete') },
])
const auditLogService = useAuditLogService()
const rows = ref<AuditLogItem[]>([])
const entityTypes = ref<string[]>([])
const totalItems = ref(0)
const page = ref(1)
const isLoading = ref(true)
const entityTypeFilter = ref<string | null>(null)
const actionFilter = ref<AuditLogAction | null>(null)
const entityTypeOptions = computed<{ value: string, label: string }[]>(() =>
entityTypes.value.map((value) => ({ value, label: entityTypeLabel(value) })),
)
// PAGE_SIZE must match the API default page size. The full-page guard keeps the
// "next" button accurate even on the last (partial) page.
const hasNextPage = computed(() => rows.value.length >= PAGE_SIZE && page.value * PAGE_SIZE < totalItems.value)
function entityTypeLabel(value: string): string {
const key = `audit.entity.${value}`
return te(key) ? t(key) : value
}
function actionLabel(action: AuditLogAction): string {
return t(`audit.action.${action}`)
}
function formatDate(value: string): string {
return new Date(value).toLocaleString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
async function loadItems() {
isLoading.value = true
try {
const result = await auditLogService.list({
page: page.value,
entityType: entityTypeFilter.value ?? undefined,
action: actionFilter.value ?? undefined,
})
rows.value = result.items
totalItems.value = result.totalItems
} finally {
isLoading.value = false
}
}
async function loadEntityTypes() {
entityTypes.value = await auditLogService.entityTypes()
}
function goToPage(target: number) {
if (target < 1) {
return
}
page.value = target
loadItems()
}
watch([entityTypeFilter, actionFilter], () => {
page.value = 1
loadItems()
})
onMounted(() => {
loadItems()
loadEntityTypes()
})
</script>
@@ -51,7 +51,7 @@
</template>
<script setup lang="ts">
import { useBookStackService } from '~/modules/integration/services/bookstack'
import { useBookStackService } from '~/services/bookstack'
const { getSettings, saveSettings, testConnection } = useBookStackService()
+2 -2
View File
@@ -40,8 +40,8 @@
</template>
<script setup lang="ts">
import type { Client } from '~/modules/directory/services/dto/client'
import { useClientService } from '~/modules/directory/services/clients'
import type { Client } from '~/services/dto/client'
import { useClientService } from '~/services/clients'
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
+2 -2
View File
@@ -30,8 +30,8 @@
</template>
<script setup lang="ts">
import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort'
import { useTaskEffortService } from '~/modules/project-management/services/task-efforts'
import type { TaskEffort } from '~/services/dto/task-effort'
import { useTaskEffortService } from '~/services/task-efforts'
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
+1 -1
View File
@@ -45,7 +45,7 @@
</template>
<script setup lang="ts">
import { useGiteaService } from '~/modules/integration/services/gitea'
import { useGiteaService } from '~/services/gitea'
const { getSettings, saveSettings, testConnection } = useGiteaService()
+1 -1
View File
@@ -140,7 +140,7 @@
</template>
<script setup lang="ts">
import { useMailService } from '~/modules/mail/services/mail'
import { useMailService } from '~/services/mail'
const { getConfiguration, updateConfiguration, testConfiguration } = useMailService()
@@ -37,8 +37,8 @@
</template>
<script setup lang="ts">
import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority'
import { useTaskPriorityService } from '~/modules/project-management/services/task-priorities'
import type { TaskPriority } from '~/services/dto/task-priority'
import { useTaskPriorityService } from '~/services/task-priorities'
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
-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>
<script setup lang="ts">
import { useShareSettingsService } from '~/modules/integration/services/share-settings'
import { useShareSettingsService } from '~/services/share-settings'
const { getSettings, saveSettings, testConnection } = useShareSettingsService()
+2 -2
View File
@@ -37,8 +37,8 @@
</template>
<script setup lang="ts">
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
import { useTaskTagService } from '~/modules/project-management/services/task-tags'
import type { TaskTag } from '~/services/dto/task-tag'
import { useTaskTagService } from '~/services/task-tags'
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
@@ -42,8 +42,8 @@
</template>
<script setup lang="ts">
import type { Workflow } from '~/modules/project-management/services/dto/workflow'
import { useWorkflowService } from '~/modules/project-management/services/workflows'
import type { Workflow } from '~/services/dto/workflow'
import { useWorkflowService } from '~/services/workflows'
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
const { t } = useI18n()
+1 -1
View File
@@ -58,7 +58,7 @@
</template>
<script setup lang="ts">
import { useZimbraService } from '~/modules/integration/services/zimbra'
import { useZimbraService } from '~/services/zimbra'
const { getSettings, saveSettings, testConnection } = useZimbraService()
-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>
<script setup lang="ts">
import type { Workflow, StatusCategory } from '~/modules/project-management/services/dto/workflow'
import { STATUS_CATEGORY_COLOR } from '~/modules/project-management/services/dto/workflow'
import type { TaskStatusWrite } from '~/modules/project-management/services/dto/task-status'
import { useWorkflowService } from '~/modules/project-management/services/workflows'
import { useTaskStatusService } from '~/modules/project-management/services/task-statuses'
import type { Workflow, StatusCategory } from '~/services/dto/workflow'
import { STATUS_CATEGORY_COLOR } from '~/services/dto/workflow'
import type { TaskStatusWrite } from '~/services/dto/task-status'
import { useWorkflowService } from '~/services/workflows'
import { useTaskStatusService } from '~/services/task-statuses'
const { t } = useI18n()
@@ -6,11 +6,36 @@
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.name"
label="Nom société"
label="Nom"
input-class="w-full"
:error="touched.name && !form.name.trim() ? 'Le nom est requis' : ''"
@blur="touched.name = true"
/>
<MalioInputText
v-model="form.email"
label="Email"
input-class="w-full"
/>
<MalioInputText
v-model="form.phone"
label="Téléphone"
input-class="w-full"
/>
<MalioInputText
v-model="form.street"
label="Rue"
input-class="w-full"
/>
<MalioInputText
v-model="form.city"
label="Ville"
input-class="w-full"
/>
<MalioInputText
v-model="form.postalCode"
label="Code Postal"
input-class="w-full"
/>
<div class="mt-6 flex justify-end">
<MalioButton
@@ -25,8 +50,8 @@
</template>
<script setup lang="ts">
import type { Client, ClientWrite } from '~/modules/directory/services/dto/client'
import { useClientService } from '~/modules/directory/services/clients'
import type { Client, ClientWrite } from '~/services/dto/client'
import { useClientService } from '~/services/clients'
const props = defineProps<{
modelValue: boolean
@@ -48,16 +73,37 @@ const isSubmitting = ref(false)
const form = reactive({
name: '',
email: '',
phone: '',
street: '',
city: '',
postalCode: '',
})
const touched = reactive({
name: false,
email: false,
})
watch(() => props.modelValue, (open) => {
if (open) {
form.name = props.client?.name ?? ''
if (props.client) {
form.name = props.client.name ?? ''
form.email = props.client.email ?? ''
form.phone = props.client.phone ?? ''
form.street = props.client.street ?? ''
form.city = props.client.city ?? ''
form.postalCode = props.client.postalCode ?? ''
} else {
form.name = ''
form.email = ''
form.phone = ''
form.street = ''
form.city = ''
form.postalCode = ''
}
touched.name = false
touched.email = false
}
})
@@ -71,6 +117,11 @@ async function handleSubmit() {
try {
const payload: ClientWrite = {
name: form.name.trim(),
email: form.email.trim() || null,
phone: form.phone.trim() || null,
street: form.street.trim() || null,
city: form.city.trim() || null,
postalCode: form.postalCode.trim() || null,
}
if (isEditing.value && props.client) {
@@ -1,12 +1,12 @@
<script setup lang="ts">
import type { MailMessageDetailDto } from '~/modules/mail/services/dto/mail'
import type { Task } from '~/modules/project-management/services/dto/task'
import type { Project } from '~/modules/project-management/services/dto/project'
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
import type { MailMessageDetailDto } from '~/services/dto/mail'
import type { Task } from '~/services/dto/task'
import type { Project } from '~/services/dto/project'
import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data'
import { useMailService } from '~/modules/mail/services/mail'
import { useProjectService } from '~/modules/project-management/services/projects'
import { useTaskGroupService } from '~/modules/project-management/services/task-groups'
import { useMailService } from '~/services/mail'
import { useProjectService } from '~/services/projects'
import { useTaskGroupService } from '~/services/task-groups'
import { useUserService } from '~/services/users'
import { useAuthStore } from '~/shared/stores/auth'
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { MailFolderDto } from '~/modules/mail/services/dto/mail'
import type { MailFolderDto } from '~/services/dto/mail'
const props = defineProps<{
/** Arbre de dossiers (getter folderTree du store) */
@@ -1,9 +1,9 @@
<script setup lang="ts">
import type { Task } from '~/modules/project-management/services/dto/task'
import type { Project } from '~/modules/project-management/services/dto/project'
import { useMailService } from '~/modules/mail/services/mail'
import { useTaskService } from '~/modules/project-management/services/tasks'
import { useProjectService } from '~/modules/project-management/services/projects'
import type { Task } from '~/services/dto/task'
import type { Project } from '~/services/dto/project'
import { useMailService } from '~/services/mail'
import { useTaskService } from '~/services/tasks'
import { useProjectService } from '~/services/projects'
const props = defineProps<{
modelValue: boolean
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { MailMessageHeaderDto } from '~/modules/mail/services/dto/mail'
import type { MailMessageHeaderDto } from '~/services/dto/mail'
const props = defineProps<{
messages: readonly MailMessageHeaderDto[]
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { MailMessageDetailDto, MailAddressDto, MailAttachmentDto } from '~/modules/mail/services/dto/mail'
import type { MailMessageDetailDto, MailAddressDto, MailAttachmentDto } from '~/services/dto/mail'
import { sanitizeMailHtml } from '~/utils/sanitizeMailHtml'
import { useMailService } from '~/modules/mail/services/mail'
import { useMailService } from '~/services/mail'
const props = defineProps<{
/** Détail complet du message. null = aucun message sélectionné. */
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { useMailStore } from '~/modules/mail/stores/mail'
import { useMailStore } from '~/stores/mail'
const store = useMailStore()
const { syncing } = storeToRefs(store)
@@ -123,13 +123,13 @@
</template>
<script setup lang="ts">
import type { Project, ProjectWrite } from '~/modules/project-management/services/dto/project'
import type { Client } from '~/modules/directory/services/dto/client'
import type { GiteaRepository } from '~/modules/integration/services/dto/gitea'
import type { BookStackShelf } from '~/modules/integration/services/dto/bookstack'
import { useProjectService } from '~/modules/project-management/services/projects'
import { useGiteaService } from '~/modules/integration/services/gitea'
import { useBookStackService } from '~/modules/integration/services/bookstack'
import type { Project, ProjectWrite } from '~/services/dto/project'
import type { Client } from '~/services/dto/client'
import type { GiteaRepository } from '~/services/dto/gitea'
import type { BookStackShelf } from '~/services/dto/bookstack'
import { useProjectService } from '~/services/projects'
import { useGiteaService } from '~/services/gitea'
import { useBookStackService } from '~/services/bookstack'
const props = defineProps<{
modelValue: boolean
@@ -67,10 +67,10 @@
</template>
<script setup lang="ts">
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
import type { Task } from '~/modules/project-management/services/dto/task'
import { useTaskGroupService } from '~/modules/project-management/services/task-groups'
import { useTaskService } from '~/modules/project-management/services/tasks'
import type { TaskGroup } from '~/services/dto/task-group'
import type { Task } from '~/services/dto/task'
import { useTaskGroupService } from '~/services/task-groups'
import { useTaskService } from '~/services/tasks'
import { stripRichText } from '~/utils/format'
const props = defineProps<{
@@ -82,12 +82,12 @@
</template>
<script setup lang="ts">
import type { Project } from '~/modules/project-management/services/dto/project'
import type { Task } from '~/modules/project-management/services/dto/task'
import type { Workflow } from '~/modules/project-management/services/dto/workflow'
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
import { useWorkflowService } from '~/modules/project-management/services/workflows'
import { useTaskService } from '~/modules/project-management/services/tasks'
import type { Project } from '~/services/dto/project'
import type { Task } from '~/services/dto/task'
import type { Workflow } from '~/services/dto/workflow'
import type { TaskStatus } from '~/services/dto/task-status'
import { useWorkflowService } from '~/services/workflows'
import { useTaskService } from '~/services/tasks'
const props = defineProps<{
modelValue: boolean
@@ -167,8 +167,8 @@
</template>
<script setup lang="ts">
import type { FileEntry } from '~/modules/integration/services/dto/share'
import { useShareService } from '~/modules/integration/services/share'
import type { FileEntry } from '~/services/dto/share'
import { useShareService } from '~/services/share'
import { formatFileSize } from '~/utils/format'
const props = defineProps<{
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
import type { TaskStatus } from '~/services/dto/task-status'
defineProps<{
statuses: TaskStatus[]
@@ -75,8 +75,8 @@
</template>
<script setup lang="ts">
import type { BookStackLink, BookStackSearchResult } from '~/modules/integration/services/dto/bookstack'
import { useBookStackService } from '~/modules/integration/services/bookstack'
import type { BookStackLink, BookStackSearchResult } from '~/services/dto/bookstack'
import { useBookStackService } from '~/services/bookstack'
const props = defineProps<{
taskId: number
@@ -104,13 +104,13 @@
</template>
<script setup lang="ts">
import type { Task } from '~/modules/project-management/services/dto/task'
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort'
import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority'
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
import type { Task } from '~/services/dto/task'
import type { TaskStatus } from '~/services/dto/task-status'
import type { TaskEffort } from '~/services/dto/task-effort'
import type { TaskPriority } from '~/services/dto/task-priority'
import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/modules/project-management/services/dto/project'
import type { Project } from '~/services/dto/project'
const props = withDefaults(defineProps<{
selectedCount: number
@@ -102,7 +102,7 @@
</template>
<script setup lang="ts">
import type { Task } from '~/modules/project-management/services/dto/task'
import type { Task } from '~/services/dto/task'
const props = withDefaults(defineProps<{
task: Task
@@ -60,8 +60,8 @@
</template>
<script setup lang="ts">
import type { TaskDocument } from '~/modules/project-management/services/dto/task-document'
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
import type { TaskDocument } from '~/services/dto/task-document'
import { useTaskDocumentService } from '~/services/task-documents'
import { formatFileSize } from '~/utils/format'
defineProps<{
@@ -121,8 +121,8 @@
</template>
<script setup lang="ts">
import type { TaskDocument } from '~/modules/project-management/services/dto/task-document'
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
import type { TaskDocument } from '~/services/dto/task-document'
import { useTaskDocumentService } from '~/services/task-documents'
import { formatFileSize } from '~/utils/format'
import { copyToClipboard } from '~/utils/clipboard'
@@ -56,9 +56,9 @@
</template>
<script setup lang="ts">
import type { Breadcrumb, FileEntry } from '~/modules/integration/services/dto/share'
import { useShareService } from '~/modules/integration/services/share'
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
import type { Breadcrumb, FileEntry } from '~/services/dto/share'
import { useShareService } from '~/services/share'
import { useTaskDocumentService } from '~/services/task-documents'
import { formatFileSize } from '~/utils/format'
const props = defineProps<{
@@ -46,7 +46,7 @@
</template>
<script setup lang="ts">
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
import { useTaskDocumentService } from '~/services/task-documents'
const props = defineProps<{
taskId?: number
@@ -25,8 +25,8 @@
</template>
<script setup lang="ts">
import type { TaskEffort, TaskEffortWrite } from '~/modules/project-management/services/dto/task-effort'
import { useTaskEffortService } from '~/modules/project-management/services/task-efforts'
import type { TaskEffort, TaskEffortWrite } from '~/services/dto/task-effort'
import { useTaskEffortService } from '~/services/task-efforts'
const props = defineProps<{
modelValue: boolean
@@ -226,9 +226,9 @@
</template>
<script setup lang="ts">
import type { Task } from '~/modules/project-management/services/dto/task'
import type { GiteaBranch, GiteaPullRequest } from '~/modules/integration/services/dto/gitea'
import { useGiteaService } from '~/modules/integration/services/gitea'
import type { Task } from '~/services/dto/task'
import type { GiteaBranch, GiteaPullRequest } from '~/services/dto/gitea'
import { useGiteaService } from '~/services/gitea'
import { copyToClipboard } from '~/utils/clipboard'
const { t } = useI18n()
@@ -56,10 +56,10 @@
</template>
<script setup lang="ts">
import type { TaskGroup, TaskGroupWrite } from '~/modules/project-management/services/dto/task-group'
import type { Task } from '~/modules/project-management/services/dto/task'
import { useTaskGroupService } from '~/modules/project-management/services/task-groups'
import { useTaskService } from '~/modules/project-management/services/tasks'
import type { TaskGroup, TaskGroupWrite } from '~/services/dto/task-group'
import type { Task } from '~/services/dto/task'
import { useTaskGroupService } from '~/services/task-groups'
import { useTaskService } from '~/services/tasks'
const props = defineProps<{
modelValue: boolean
@@ -110,7 +110,7 @@
</template>
<script setup lang="ts">
import type { Task } from '~/modules/project-management/services/dto/task'
import type { Task } from '~/services/dto/task'
const props = withDefaults(defineProps<{
task: Task
@@ -536,23 +536,23 @@
</template>
<script setup lang="ts">
import type { Task, TaskWrite } from '~/modules/project-management/services/dto/task'
import type { TaskDocument } from '~/modules/project-management/services/dto/task-document'
import { useGiteaService } from '~/modules/integration/services/gitea'
import { useTaskDocumentService } from '~/modules/project-management/services/task-documents'
import type { Task, TaskWrite } from '~/services/dto/task'
import type { TaskDocument } from '~/services/dto/task-document'
import { useGiteaService } from '~/services/gitea'
import { useTaskDocumentService } from '~/services/task-documents'
import ConfirmDeleteDocumentModal from '~/components/ui/ConfirmDeleteDocumentModal.vue'
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort'
import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority'
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
import type { TaskGroup } from '~/modules/project-management/services/dto/task-group'
import type { TaskStatus } from '~/services/dto/task-status'
import type { TaskEffort } from '~/services/dto/task-effort'
import type { TaskPriority } from '~/services/dto/task-priority'
import type { TaskTag } from '~/services/dto/task-tag'
import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data'
import { useTaskService } from '~/modules/project-management/services/tasks'
import { useTaskRecurrenceService } from '~/modules/project-management/services/task-recurrences'
import { useTaskService } from '~/services/tasks'
import { useTaskRecurrenceService } from '~/services/task-recurrences'
import type { Project } from '~/modules/project-management/services/dto/project'
import { useMailService } from '~/modules/mail/services/mail'
import type { MailMessageHeaderDto } from '~/modules/mail/services/dto/mail'
import type { Project } from '~/services/dto/project'
import { useMailService } from '~/services/mail'
import type { MailMessageHeaderDto } from '~/services/dto/mail'
const props = defineProps<{
modelValue: boolean
@@ -569,7 +569,7 @@ const props = defineProps<{
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'saved', task?: Task): void
(e: 'saved'): void
}>()
const isOpen = computed({
@@ -1042,7 +1042,7 @@ async function handleSubmit() {
await removeRecurrence(props.task.recurrence.id)
}
emit('saved', savedTask)
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
@@ -28,8 +28,8 @@
</template>
<script setup lang="ts">
import type { TaskPriority, TaskPriorityWrite } from '~/modules/project-management/services/dto/task-priority'
import { useTaskPriorityService } from '~/modules/project-management/services/task-priorities'
import type { TaskPriority, TaskPriorityWrite } from '~/services/dto/task-priority'
import { useTaskPriorityService } from '~/services/task-priorities'
const props = defineProps<{
modelValue: boolean
@@ -28,8 +28,8 @@
</template>
<script setup lang="ts">
import type { TaskTag, TaskTagWrite } from '~/modules/project-management/services/dto/task-tag'
import { useTaskTagService } from '~/modules/project-management/services/task-tags'
import type { TaskTag, TaskTagWrite } from '~/services/dto/task-tag'
import { useTaskTagService } from '~/services/task-tags'
const props = defineProps<{
modelValue: boolean
@@ -64,7 +64,7 @@
</template>
<script setup lang="ts">
import type { TimeEntry } from '~/modules/time-tracking/services/dto/time-entry'
import type { TimeEntry } from '~/services/dto/time-entry'
const props = defineProps<{
entry: TimeEntry
@@ -35,7 +35,7 @@
</template>
<script setup lang="ts">
import type { TimeEntry } from '~/modules/time-tracking/services/dto/time-entry'
import type { TimeEntry } from '~/services/dto/time-entry'
const props = defineProps<{
visible: boolean
@@ -124,11 +124,11 @@
</template>
<script setup lang="ts">
import type { TimeEntry, TimeEntryWrite } from '~/modules/time-tracking/services/dto/time-entry'
import type { TimeEntry, TimeEntryWrite } from '~/services/dto/time-entry'
import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/modules/project-management/services/dto/project'
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
import { useTimeEntryService } from '~/modules/time-tracking/services/time-entries'
import type { Project } from '~/services/dto/project'
import type { TaskTag } from '~/services/dto/task-tag'
import { useTimeEntryService } from '~/services/time-entries'
const props = defineProps<{
modelValue: boolean
@@ -67,7 +67,7 @@
</template>
<script setup lang="ts">
import type { TimeEntry } from '~/modules/time-tracking/services/dto/time-entry'
import type { TimeEntry } from '~/services/dto/time-entry'
import { stripRichText } from '~/utils/format'
const props = defineProps<{
@@ -150,8 +150,8 @@
</template>
<script setup lang="ts">
import type { TimeEntry } from '~/modules/time-tracking/services/dto/time-entry'
import { useAbsenceService } from '~/modules/absence/services/absences'
import type { TimeEntry } from '~/services/dto/time-entry'
import { useAbsenceService } from '~/services/absences'
const { t } = useI18n()
const absenceService = useAbsenceService()
@@ -108,9 +108,9 @@
<script setup lang="ts">
import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/modules/project-management/services/dto/project'
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
import type { Client } from '~/modules/directory/services/dto/client'
import type { Project } from '~/services/dto/project'
import type { TaskTag } from '~/services/dto/task-tag'
import type { Client } from '~/services/dto/client'
const props = defineProps<{
users: UserData[]
+20 -33
View File
@@ -106,43 +106,30 @@ const touched = reactive({
password: false,
})
const { create, update, getById } = useUserService()
function applyUser(user: UserData) {
form.username = user.username ?? ''
form.firstName = user.firstName ?? ''
form.lastName = user.lastName ?? ''
form.password = ''
form.roles = [...user.roles]
form.isEmployee = user.isEmployee ?? false
}
watch(() => props.modelValue, async (open) => {
if (!open) {
return
}
touched.username = false
touched.password = false
if (props.item) {
applyUser(props.item)
try {
const full = await getById(props.item.id)
applyUser(full)
} catch {
// Keep the list data if the detailed fetch fails.
watch(() => props.modelValue, (open) => {
if (open) {
if (props.item) {
form.username = props.item.username ?? ''
form.firstName = props.item.firstName ?? ''
form.lastName = props.item.lastName ?? ''
form.password = ''
form.roles = [...props.item.roles]
form.isEmployee = props.item.isEmployee ?? false
} else {
form.username = ''
form.firstName = ''
form.lastName = ''
form.password = ''
form.roles = ['ROLE_USER']
form.isEmployee = false
}
} else {
form.username = ''
form.firstName = ''
form.lastName = ''
form.password = ''
form.roles = ['ROLE_USER']
form.isEmployee = false
touched.username = false
touched.password = false
}
})
const { create, update } = useUserService()
async function handleSubmit() {
touched.username = true
touched.password = true
@@ -1,4 +1,4 @@
import type { AbsenceRequest, AbsenceStatus, AbsenceType, HalfDay } from '~/modules/absence/services/dto/absence'
import type { AbsenceRequest, AbsenceStatus, AbsenceType, HalfDay } from '~/services/dto/absence'
export type BadgeVariant = 'neutral' | 'info' | 'success' | 'warning' | 'danger'
@@ -1,4 +1,4 @@
import { useShareService } from '~/modules/integration/services/share'
import { useShareService } from '~/services/share'
export function useShareStatus() {
const enabled = useState<boolean | null>('share-enabled', () => null)
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1 +0,0 @@
export default defineNuxtConfig({})
@@ -1,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
View File
@@ -1 +0,0 @@
export default defineNuxtConfig({})
@@ -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,160 +0,0 @@
<template>
<div class="flex flex-col gap-6 pt-6">
<!-- Formulaire d'ajout / édition -->
<div v-if="isAdmin" class="grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]">
<MalioInputText
class="col-span-2"
:label="$t('directory.reports.fields.subject')"
v-model="draft.subject"
/>
<MalioSelect
:label="$t('directory.reports.fields.type')"
v-model="draft.type"
:options="typeOptions"
group-class="w-full"
/>
<MalioDate
:label="$t('directory.reports.fields.occurredAt')"
v-model="draft.occurredAt"
/>
<MalioInputTextArea
class="col-span-2"
:label="$t('directory.reports.fields.body')"
v-model="draft.body"
/>
<div class="col-span-2 flex justify-end gap-3">
<MalioButton
v-if="editingId"
variant="secondary"
button-class="w-auto px-4"
:label="$t('common.cancel')"
@click="resetDraft"
/>
<MalioButton
button-class="w-auto px-4"
:label="editingId ? $t('common.save') : $t('directory.reports.add')"
:disabled="!draft.subject"
@click="save"
/>
</div>
</div>
<!-- Liste des comptes-rendus -->
<div v-for="report in reports" :key="report.id" class="rounded border border-neutral-200 p-4">
<div class="flex items-start justify-between">
<div>
<p class="font-semibold text-neutral-800">{{ report.subject }}</p>
<p class="text-xs text-neutral-500">
{{ formatDate(report.occurredAt) }} · {{ $t(`directory.reports.types.${report.type}`) }}
<span v-if="report.author"> · {{ report.author.username }}</span>
</p>
</div>
<div v-if="isAdmin" class="flex gap-2">
<MalioButtonIcon icon="mdi:pencil-outline" variant="ghost" :aria-label="$t('common.edit')" @click="edit(report)" />
<MalioButtonIcon icon="mdi:delete-outline" variant="ghost" :aria-label="$t('common.delete')" @click="remove(report.id)" />
</div>
</div>
<p v-if="report.body" class="mt-2 whitespace-pre-wrap text-sm text-neutral-700">{{ report.body }}</p>
<div class="mt-3 flex flex-col gap-2">
<ReportDocumentList
:documents="report.documents ?? []"
:is-admin="isAdmin"
@delete="(id) => removeDocument(report, id)"
/>
<ReportDocumentUpload
v-if="isAdmin"
:report-id="report.id"
@uploaded="reload"
/>
</div>
</div>
<p v-if="!reports.length" class="text-sm text-neutral-400">
{{ $t('directory.reports.empty') }}
</p>
</div>
</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'
import { useReportDocumentService } from '~/modules/directory/services/report-documents'
const props = defineProps<{
owner: { client?: string, prospect?: string }
isAdmin: boolean
}>()
const { t } = useI18n()
const reportService = useCommercialReportService()
const documentService = useReportDocumentService()
const reports = ref<CommercialReport[]>([])
const editingId = ref<number | null>(null)
function emptyDraft(): CommercialReportWrite {
return {
subject: '',
body: null,
occurredAt: new Date().toISOString().slice(0, 10),
type: 'note',
...props.owner,
}
}
const draft = ref<CommercialReportWrite>(emptyDraft())
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 formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('fr-FR')
}
async function reload(): Promise<void> {
reports.value = await reportService.getByOwner(props.owner)
}
function resetDraft(): void {
editingId.value = null
draft.value = emptyDraft()
}
function edit(report: CommercialReport): void {
editingId.value = report.id
draft.value = {
subject: report.subject,
body: report.body,
occurredAt: report.occurredAt.slice(0, 10),
type: report.type,
...props.owner,
}
}
async function save(): Promise<void> {
if (editingId.value) {
await reportService.update(editingId.value, draft.value)
} else {
await reportService.create(draft.value)
}
resetDraft()
await reload()
}
async function remove(id: number): Promise<void> {
await reportService.remove(id)
await reload()
}
async function removeDocument(report: CommercialReport, id: number): Promise<void> {
await documentService.remove(id)
await reload()
}
onMounted(reload)
watch(() => props.owner, reload, { deep: true })
</script>
@@ -1,58 +0,0 @@
<template>
<Teleport v-if="modelValue" to="body">
<Transition name="modal" appear>
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click="cancel" />
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-bold text-neutral-900">{{ title }}</h3>
<p class="mt-3 text-sm text-neutral-600">
{{ message }}
</p>
<div class="mt-6 flex justify-end gap-3">
<MalioButton
variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
@click="cancel"
/>
<MalioButton
variant="danger"
:label="$t('common.delete')"
button-class="w-auto px-4"
@click="$emit('confirm')"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
defineProps<{
modelValue: boolean
title: string
message: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'confirm'): void
}>()
function cancel() {
emit('update:modelValue', false)
}
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
</style>
@@ -1,69 +0,0 @@
<template>
<div class="relative grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]">
<h3 class="col-span-2 text-sm font-semibold text-neutral-700">
{{ title }}
</h3>
<MalioButtonIcon
v-if="removable && !readonly"
icon="mdi:delete-outline"
variant="ghost"
class="absolute right-3 top-3"
:aria-label="$t('common.delete')"
@click="$emit('remove')"
/>
<MalioInputText
class="col-span-2"
:label="$t('directory.addresses.fields.label')"
:model-value="modelValue.label ?? ''"
:readonly="readonly"
@update:model-value="update('label', $event)"
/>
<MalioInputText
class="col-span-2"
:label="$t('directory.addresses.fields.street')"
:model-value="modelValue.street ?? ''"
:readonly="readonly"
@update:model-value="update('street', $event)"
/>
<MalioInputText
class="col-span-2"
:label="$t('directory.addresses.fields.streetComplement')"
:model-value="modelValue.streetComplement ?? ''"
:readonly="readonly"
@update:model-value="update('streetComplement', $event)"
/>
<MalioInputText
:label="$t('directory.addresses.fields.postalCode')"
:model-value="modelValue.postalCode ?? ''"
:readonly="readonly"
@update:model-value="update('postalCode', $event)"
/>
<MalioInputText
:label="$t('directory.addresses.fields.city')"
:model-value="modelValue.city ?? ''"
:readonly="readonly"
@update:model-value="update('city', $event)"
/>
</div>
</template>
<script setup lang="ts">
import type { Address } from '~/modules/directory/services/dto/address'
const props = defineProps<{
modelValue: Address
title: string
removable?: boolean
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: Address]
'remove': []
}>()
function update(field: keyof Address, value: string): void {
emit('update:modelValue', { ...props.modelValue, [field]: value === '' ? null : value })
}
</script>
@@ -1,73 +0,0 @@
<template>
<div class="relative grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]">
<h3 class="col-span-2 text-sm font-semibold text-neutral-700">
{{ title }}
</h3>
<MalioButtonIcon
v-if="removable && !readonly"
icon="mdi:delete-outline"
variant="ghost"
class="absolute right-3 top-3"
:aria-label="$t('common.delete')"
@click="$emit('remove')"
/>
<MalioInputText
:label="$t('directory.contacts.fields.lastName')"
:model-value="modelValue.lastName ?? ''"
:readonly="readonly"
@update:model-value="update('lastName', $event)"
/>
<MalioInputText
:label="$t('directory.contacts.fields.firstName')"
:model-value="modelValue.firstName ?? ''"
:readonly="readonly"
@update:model-value="update('firstName', $event)"
/>
<MalioInputText
class="col-span-2"
:label="$t('directory.contacts.fields.jobTitle')"
:model-value="modelValue.jobTitle ?? ''"
:readonly="readonly"
@update:model-value="update('jobTitle', $event)"
/>
<MalioInputText
:label="$t('directory.contacts.fields.email')"
:model-value="modelValue.email ?? ''"
:readonly="readonly"
@update:model-value="update('email', $event)"
/>
<MalioInputText
:label="$t('directory.contacts.fields.phonePrimary')"
:model-value="modelValue.phonePrimary ?? ''"
:readonly="readonly"
@update:model-value="update('phonePrimary', $event)"
/>
<MalioInputText
:label="$t('directory.contacts.fields.phoneSecondary')"
:model-value="modelValue.phoneSecondary ?? ''"
:readonly="readonly"
@update:model-value="update('phoneSecondary', $event)"
/>
</div>
</template>
<script setup lang="ts">
import type { Contact } from '~/modules/directory/services/dto/contact'
const props = defineProps<{
modelValue: Contact
title: string
removable?: boolean
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: Contact]
'remove': []
}>()
function update(field: keyof Contact, value: string): void {
emit('update:modelValue', { ...props.modelValue, [field]: value === '' ? null : value })
}
</script>
@@ -1,115 +0,0 @@
<template>
<MalioDrawer v-model="isOpen">
<template #header>
<h2 class="text-xl font-bold">{{ isEditing ? $t('prospects.editProspect') : $t('prospects.addProspect') }}</h2>
</template>
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.name"
label="Nom société"
input-class="w-full"
:error="touched.name && !form.name.trim() ? $t('prospects.validation.nameRequired') : ''"
@blur="touched.name = true"
/>
<div class="mt-6 flex items-center justify-between gap-2">
<MalioButton
v-if="isEditing && !isConverted"
:label="$t('prospects.convert')"
variant="secondary"
icon-name="mdi:account-convert"
icon-position="left"
button-class="w-auto px-4"
:disabled="isSubmitting"
@click="handleConvert"
/>
<span v-else-if="isConverted" class="text-sm text-green-700">
{{ $t('prospects.alreadyConverted') }}
</span>
<span v-else />
<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 { Prospect, ProspectWrite } from '~/modules/directory/services/dto/prospect'
import { useProspectService } from '~/modules/directory/services/prospects'
const props = defineProps<{
modelValue: boolean
prospect: Prospect | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'saved'): void
}>()
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const isEditing = computed(() => !!props.prospect)
const isConverted = computed(() => !!props.prospect?.convertedClient)
const isSubmitting = ref(false)
const form = reactive({
name: '',
})
const touched = reactive({
name: false,
})
watch(() => props.modelValue, (open) => {
if (open) {
form.name = props.prospect?.name ?? ''
touched.name = false
}
})
const { create, update, convert } = useProspectService()
async function handleSubmit() {
touched.name = true
if (!form.name.trim()) return
isSubmitting.value = true
try {
const payload: ProspectWrite = {
name: form.name.trim(),
}
if (isEditing.value && props.prospect) {
await update(props.prospect.id, payload)
} else {
await create(payload)
}
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
async function handleConvert() {
if (!props.prospect) return
isSubmitting.value = true
try {
await convert(props.prospect.id)
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
</script>
@@ -1,42 +0,0 @@
<template>
<ul v-if="documents.length" class="flex flex-col gap-2">
<li
v-for="doc in documents"
:key="doc.id"
class="flex items-center justify-between rounded border border-neutral-200 px-3 py-2"
>
<a
:href="downloadUrl(doc.id)"
target="_blank"
rel="noopener"
class="flex items-center gap-2 text-sm text-blue-700 hover:underline"
>
<Icon name="mdi:file-document-outline" />
{{ doc.originalName }}
</a>
<MalioButtonIcon
v-if="isAdmin"
icon="mdi:trash-can-outline"
button-class="!text-red-600"
:aria-label="$t('common.delete')"
@click="$emit('delete', doc.id)"
/>
</li>
</ul>
<p v-else class="text-sm text-neutral-400">
{{ $t('directory.documents.empty') }}
</p>
</template>
<script setup lang="ts">
import type { ReportDocument } from '~/modules/directory/services/dto/report-document'
import { useReportDocumentService } from '~/modules/directory/services/report-documents'
defineProps<{ documents: ReportDocument[], isAdmin: boolean }>()
defineEmits<{ delete: [id: number] }>()
const { getDownloadUrl } = useReportDocumentService()
function downloadUrl(id: number): string {
return getDownloadUrl(id)
}
</script>
@@ -1,45 +0,0 @@
<template>
<div class="flex items-center gap-3">
<input
ref="fileInput"
type="file"
class="hidden"
@change="onFileSelected"
>
<MalioButton
icon-name="mdi:paperclip"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.documents.add')"
:disabled="uploading"
@click="fileInput?.click()"
/>
<span v-if="uploading" class="text-sm text-neutral-500">{{ $t('directory.documents.uploading') }}</span>
</div>
</template>
<script setup lang="ts">
import { useReportDocumentService } from '~/modules/directory/services/report-documents'
const props = defineProps<{ reportId: number }>()
const emit = defineEmits<{ uploaded: [] }>()
const service = useReportDocumentService()
const fileInput = ref<HTMLInputElement | null>(null)
const uploading = ref(false)
async function onFileSelected(event: Event): Promise<void> {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
uploading.value = true
try {
await service.upload(props.reportId, file)
emit('uploaded')
} finally {
uploading.value = false
input.value = ''
}
}
</script>
@@ -1,121 +0,0 @@
import type { Contact } from '~/modules/directory/services/dto/contact'
import type { Address } from '~/modules/directory/services/dto/address'
import { useContactService } from '~/modules/directory/services/contacts'
import { useAddressService } from '~/modules/directory/services/addresses'
type Owner = { client?: string, prospect?: string }
/**
* Logique partagée des fiches détail Client/Prospect : blocs répétables Contact
* et Adresse (chargement, ajout, suppression). L'édition est tenue en mémoire
* localement ; la persistance se fait au clic sur « Enregistrer » (saveContacts/
* saveAddresses), comme les formulaires de tâche pas d'enregistrement au blur.
* Paramétré par l'IRI du propriétaire (`{ client }` ou `{ prospect }`), réutilisé
* tel quel par les deux pages.
*/
export function useDirectoryDetail(owner: Owner) {
const contactService = useContactService()
const addressService = useAddressService()
const contacts = ref<Contact[]>([])
const addresses = ref<Address[]>([])
const savingContacts = ref(false)
const savingAddresses = ref(false)
function emptyContact(): Contact {
return { id: 0, firstName: null, lastName: null, jobTitle: null, email: null, phonePrimary: null, phoneSecondary: null, ...owner }
}
function emptyAddress(): Address {
return { id: 0, label: null, street: null, streetComplement: null, postalCode: null, city: null, country: 'FR', ...owner }
}
// Édition locale uniquement : on remplace le bloc en mémoire, rien n'est
// persisté tant que l'utilisateur n'a pas cliqué sur « Enregistrer ».
function onContactInput(index: number, value: Contact): void {
contacts.value[index] = value
}
function onAddressInput(index: number, value: Address): void {
addresses.value[index] = value
}
function addContact(): void {
contacts.value.push(emptyContact())
}
function addAddress(): void {
addresses.value.push(emptyAddress())
}
// Suppression immédiate (comme la corbeille du formulaire de tâche) : un bloc
// déjà enregistré est supprimé côté serveur, une amorce non enregistrée est
// simplement retirée de la liste.
async function removeContact(index: number): Promise<void> {
const c = contacts.value[index]
if (c?.id && c.id > 0) await contactService.remove(c.id)
contacts.value.splice(index, 1)
}
async function removeAddress(index: number): Promise<void> {
const a = addresses.value[index]
if (a?.id && a.id > 0) await addressService.remove(a.id)
addresses.value.splice(index, 1)
}
// Persistance au clic : met à jour les blocs existants, crée les nouveaux
// blocs renseignés. Les amorces vides (sans contenu) sont ignorées.
async function saveContacts(): Promise<void> {
if (savingContacts.value) return
savingContacts.value = true
try {
for (let i = 0; i < contacts.value.length; i++) {
const c = contacts.value[i]
if (!c) continue
const payload = { firstName: c.firstName, lastName: c.lastName, jobTitle: c.jobTitle, email: c.email, phonePrimary: c.phonePrimary, phoneSecondary: c.phoneSecondary, ...owner }
if (c.id && c.id > 0) {
contacts.value[i] = await contactService.update(c.id, payload)
} else if (c.lastName || c.firstName) {
contacts.value[i] = await contactService.create(payload)
}
}
} finally {
savingContacts.value = false
}
}
async function saveAddresses(): Promise<void> {
if (savingAddresses.value) return
savingAddresses.value = true
try {
for (let i = 0; i < addresses.value.length; i++) {
const a = addresses.value[i]
if (!a) continue
const payload = { label: a.label, street: a.street, streetComplement: a.streetComplement, postalCode: a.postalCode, city: a.city, country: a.country, ...owner }
if (a.id && a.id > 0) {
addresses.value[i] = await addressService.update(a.id, payload)
} else if (a.street || a.city || a.postalCode) {
addresses.value[i] = await addressService.create(payload)
}
}
} finally {
savingAddresses.value = false
}
}
async function load(): Promise<void> {
contacts.value = await contactService.getByOwner(owner)
addresses.value = await addressService.getByOwner(owner)
}
return {
contacts,
addresses,
savingContacts,
savingAddresses,
onContactInput,
addContact,
removeContact,
saveContacts,
onAddressInput,
addAddress,
removeAddress,
saveAddresses,
load,
}
}
@@ -1 +0,0 @@
export default defineNuxtConfig({})
@@ -1,129 +0,0 @@
<template>
<div class="flex flex-col gap-6">
<div class="flex items-center gap-3 pt-4">
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" />
<h1 class="text-2xl font-bold text-neutral-900">{{ client?.name ?? '…' }}</h1>
</div>
<p v-if="loading">{{ $t('common.loading') }}</p>
<template v-else-if="client">
<MalioTabList v-model="activeTab" :tabs="tabs">
<template #contact>
<div class="flex flex-col gap-4 pt-6">
<DirectoryContactBlock
v-for="(contact, i) in contacts"
:key="contact.id || `new-${i}`"
:model-value="contact"
:title="$t('directory.contacts.item', { n: i + 1 })"
:removable="contacts.length > 0"
@update:model-value="(v) => onContactInput(i, v)"
@remove="removeContact(i)"
/>
<div class="flex justify-center gap-3 pt-2">
<MalioButton
variant="tertiary"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.contacts.add')"
@click="addContact"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingContacts"
@click="saveContacts"
/>
</div>
</div>
</template>
<template #address>
<div class="flex flex-col gap-4 pt-6">
<DirectoryAddressBlock
v-for="(address, i) in addresses"
:key="address.id || `new-${i}`"
:model-value="address"
:title="$t('directory.addresses.item', { n: i + 1 })"
:removable="addresses.length > 0"
@update:model-value="(v) => onAddressInput(i, v)"
@remove="removeAddress(i)"
/>
<div class="flex justify-center gap-3 pt-2">
<MalioButton
variant="tertiary"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.addresses.add')"
@click="addAddress"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingAddresses"
@click="saveAddresses"
/>
</div>
</div>
</template>
<template #report>
<CommercialReportTab :owner="owner" :is-admin="true" />
</template>
</MalioTabList>
</template>
</div>
</template>
<script setup lang="ts">
import type { Client } from '~/modules/directory/services/dto/client'
import { useClientService } from '~/modules/directory/services/clients'
definePageMeta({ middleware: ['admin'] })
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const id = Number(route.params.id)
const ownerIri = `/api/clients/${id}`
const owner = { client: ownerIri }
const clientService = useClientService()
const {
contacts,
addresses,
savingContacts,
savingAddresses,
onContactInput,
addContact,
removeContact,
saveContacts,
onAddressInput,
addAddress,
removeAddress,
saveAddresses,
load,
} = useDirectoryDetail(owner)
const client = ref<Client | null>(null)
const loading = ref(true)
const activeTab = ref('contact')
const tabs = [
{ key: 'contact', label: t('directory.tabs.contact'), icon: 'mdi:account-outline' },
{ key: 'address', label: t('directory.tabs.address'), icon: 'mdi:map-marker-outline' },
{ key: 'report', label: t('directory.tabs.report'), icon: 'mdi:file-document-outline' },
]
function goBack(): void {
router.push('/directory')
}
onMounted(async () => {
client.value = await clientService.getById(id)
await load()
loading.value = false
})
</script>
@@ -1,311 +0,0 @@
<template>
<div class="flex flex-col gap-6">
<h1 class="text-2xl font-bold text-neutral-900">
{{ $t('directory.title') }}
</h1>
<MalioTabList v-model="activeTab" :tabs="tabs">
<!-- Clients -->
<template #clients>
<div class="flex min-h-[30rem] flex-col gap-4 pt-10">
<div class="flex items-center justify-end">
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.clients.add')"
@click="openCreateClient"
/>
</div>
<MalioDataTable
:columns="clientColumns"
:items="clients"
:total-items="clients.length"
:empty-message="$t('directory.clients.empty')"
@row-click="openEditClient"
>
<template #cell-email="{ item }">
{{ (item as Client).email ?? '—' }}
</template>
<template #cell-phone="{ item }">
{{ (item as Client).phone ?? '—' }}
</template>
<template #cell-actions="{ item }">
<div class="flex justify-end" @click.stop>
<MalioButtonIcon
icon="mdi:trash-can-outline"
:aria-label="$t('common.delete')"
button-class="!bg-red-100 !text-red-700"
:icon-size="18"
@click="askDeleteClient(item as Client)"
/>
</div>
</template>
</MalioDataTable>
</div>
</template>
<!-- Prospects -->
<template #prospects>
<div class="flex min-h-[30rem] flex-col gap-4 pt-10">
<div class="flex flex-wrap items-end justify-between gap-3">
<MalioSelect
v-model="statusFilter"
:label="$t('prospects.fields.status')"
:options="statusOptions"
:empty-option-label="$t('directory.prospects.allStatuses')"
group-class="w-48"
/>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.prospects.add')"
@click="openCreateProspect"
/>
</div>
<MalioDataTable
:columns="prospectColumns"
:items="prospectRows"
:total-items="prospectRows.length"
:empty-message="$t('directory.prospects.empty')"
@row-click="openEditProspect"
>
<template #cell-status="{ item }">
<StatusBadge
:label="statusLabel((item as ProspectRow).status)"
:variant="statusVariant((item as ProspectRow).status)"
/>
</template>
<template #cell-email="{ item }">
{{ (item as ProspectRow).email ?? '—' }}
</template>
<template #cell-phone="{ item }">
{{ (item as ProspectRow).phone ?? '—' }}
</template>
<template #cell-actions="{ item }">
<div class="flex justify-end gap-2" @click.stop>
<MalioButtonIcon
v-if="!(item as ProspectRow).convertedClient"
icon="mdi:account-convert"
:aria-label="$t('prospects.convert')"
button-class="!bg-green-100 !text-green-700"
:icon-size="18"
@click="convertProspect(item as ProspectRow)"
/>
<MalioButtonIcon
icon="mdi:trash-can-outline"
:aria-label="$t('common.delete')"
button-class="!bg-red-100 !text-red-700"
:icon-size="18"
@click="askDeleteProspect(item as ProspectRow)"
/>
</div>
</template>
</MalioDataTable>
</div>
</template>
</MalioTabList>
<ClientDrawer
v-model="clientDrawerOpen"
:client="selectedClient"
@saved="loadClients"
/>
<ProspectDrawer
v-model="prospectDrawerOpen"
:prospect="selectedProspect"
@saved="onProspectSaved"
/>
<ConfirmDeleteModal
v-model="deleteModalOpen"
:title="deleteModalTitle"
:message="deleteModalMessage"
@confirm="confirmDelete"
/>
</div>
</template>
<script setup lang="ts">
import type { Client } from '~/modules/directory/services/dto/client'
import { useClientService } from '~/modules/directory/services/clients'
import type { Prospect, ProspectStatus } from '~/modules/directory/services/dto/prospect'
import { useProspectService } from '~/modules/directory/services/prospects'
definePageMeta({ middleware: ['admin'] })
type ProspectRow = Prospect
const { t } = useI18n()
useHead({ title: t('directory.title') })
const clientService = useClientService()
const prospectService = useProspectService()
const activeTab = ref('clients')
const tabs = [
{ key: 'clients', label: t('directory.tabs.clients'), icon: 'mdi:account-tie-outline' },
{ key: 'prospects', label: t('directory.tabs.prospects'), icon: 'mdi:account-search-outline' },
]
// --- Clients ---
const clients = ref<Client[]>([])
const clientDrawerOpen = ref(false)
const selectedClient = ref<Client | null>(null)
const clientColumns = [
{ key: 'name', label: t('prospects.fields.name') },
{ key: 'email', label: t('prospects.fields.email') },
{ key: 'phone', label: t('prospects.fields.phone') },
{ key: 'actions', label: '' },
]
async function loadClients() {
clients.value = await clientService.getAll()
}
function openCreateClient() {
selectedClient.value = null
clientDrawerOpen.value = true
}
function openEditClient(item: Record<string, unknown>) {
navigateTo(`/directory/clients/${(item as Client).id}`)
}
// --- Prospects ---
const prospects = ref<Prospect[]>([])
const prospectDrawerOpen = ref(false)
const selectedProspect = ref<Prospect | null>(null)
const statusFilter = ref<ProspectStatus | null>(null)
const statusOptions = [
{ label: t('prospects.status.new'), value: 'new' },
{ label: t('prospects.status.contacted'), value: 'contacted' },
{ label: t('prospects.status.qualified'), value: 'qualified' },
{ label: t('prospects.status.won'), value: 'won' },
{ label: t('prospects.status.lost'), value: 'lost' },
]
const prospectColumns = [
{ key: 'name', label: t('prospects.fields.name') },
{ key: 'company', label: t('prospects.fields.company') },
{ key: 'status', label: t('prospects.fields.status') },
{ key: 'email', label: t('prospects.fields.email') },
{ key: 'phone', label: t('prospects.fields.phone') },
{ key: 'actions', label: '' },
]
const prospectRows = computed<ProspectRow[]>(() => prospects.value)
function statusLabel(status: ProspectStatus): string {
return t(`prospects.status.${status}`)
}
function statusVariant(status: ProspectStatus): 'neutral' | 'info' | 'success' | 'warning' | 'danger' {
switch (status) {
case 'new':
return 'info'
case 'contacted':
return 'warning'
case 'qualified':
return 'neutral'
case 'won':
return 'success'
case 'lost':
return 'danger'
default:
return 'neutral'
}
}
async function loadProspects() {
prospects.value = await prospectService.getAll(statusFilter.value ?? undefined)
}
function openCreateProspect() {
selectedProspect.value = null
prospectDrawerOpen.value = true
}
function openEditProspect(item: Record<string, unknown>) {
navigateTo(`/directory/prospects/${(item as Prospect).id}`)
}
async function convertProspect(row: ProspectRow) {
await prospectService.convert(row.id)
// La conversion crée un client et retire le prospect : rafraîchir les deux listes.
await Promise.all([loadProspects(), loadClients()])
}
// Le ProspectDrawer porte aussi le bouton « Convertir » : son event 'saved' peut
// donc être une conversion toujours rafraîchir les deux listes par sécurité.
async function onProspectSaved() {
await Promise.all([loadProspects(), loadClients()])
}
// --- Suppression (clients & prospects) ---
type DeleteTarget =
| { type: 'client'; item: Client }
| { type: 'prospect'; item: Prospect }
const deleteModalOpen = ref(false)
const deleteTarget = ref<DeleteTarget | null>(null)
const deleteModalTitle = computed(() =>
deleteTarget.value?.type === 'prospect'
? t('prospects.deleteConfirmTitle')
: t('clients.deleteConfirmTitle'),
)
const deleteModalMessage = computed(() => {
if (!deleteTarget.value) return ''
const name = deleteTarget.value.item.name
return deleteTarget.value.type === 'prospect'
? t('prospects.deleteConfirmMessage', { name })
: t('clients.deleteConfirmMessage', { name })
})
function askDeleteClient(item: Client) {
deleteTarget.value = { type: 'client', item }
deleteModalOpen.value = true
}
function askDeleteProspect(item: Prospect) {
deleteTarget.value = { type: 'prospect', item }
deleteModalOpen.value = true
}
async function confirmDelete() {
const target = deleteTarget.value
if (!target) return
if (target.type === 'client') {
await clientService.remove(target.item.id)
await loadClients()
} else {
await prospectService.remove(target.item.id)
await loadProspects()
}
deleteModalOpen.value = false
deleteTarget.value = null
}
watch(statusFilter, loadProspects)
onMounted(async () => {
await Promise.all([loadClients(), loadProspects()])
})
</script>
<style scoped>
/* MalioTabList (lib) : aère les onglets verticalement (espace haut/bas du texte) */
:deep([role="tab"]) {
padding-top: 0.9rem;
padding-bottom: 0.25rem;
}
</style>
@@ -1,129 +0,0 @@
<template>
<div class="flex flex-col gap-6">
<div class="flex items-center gap-3 pt-4">
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" />
<h1 class="text-2xl font-bold text-neutral-900">{{ prospect?.name ?? '…' }}</h1>
</div>
<p v-if="loading">{{ $t('common.loading') }}</p>
<template v-else-if="prospect">
<MalioTabList v-model="activeTab" :tabs="tabs">
<template #contact>
<div class="flex flex-col gap-4 pt-6">
<DirectoryContactBlock
v-for="(contact, i) in contacts"
:key="contact.id || `new-${i}`"
:model-value="contact"
:title="$t('directory.contacts.item', { n: i + 1 })"
:removable="contacts.length > 0"
@update:model-value="(v) => onContactInput(i, v)"
@remove="removeContact(i)"
/>
<div class="flex justify-center gap-3 pt-2">
<MalioButton
variant="tertiary"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.contacts.add')"
@click="addContact"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingContacts"
@click="saveContacts"
/>
</div>
</div>
</template>
<template #address>
<div class="flex flex-col gap-4 pt-6">
<DirectoryAddressBlock
v-for="(address, i) in addresses"
:key="address.id || `new-${i}`"
:model-value="address"
:title="$t('directory.addresses.item', { n: i + 1 })"
:removable="addresses.length > 0"
@update:model-value="(v) => onAddressInput(i, v)"
@remove="removeAddress(i)"
/>
<div class="flex justify-center gap-3 pt-2">
<MalioButton
variant="tertiary"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.addresses.add')"
@click="addAddress"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingAddresses"
@click="saveAddresses"
/>
</div>
</div>
</template>
<template #report>
<CommercialReportTab :owner="owner" :is-admin="true" />
</template>
</MalioTabList>
</template>
</div>
</template>
<script setup lang="ts">
import type { Prospect } from '~/modules/directory/services/dto/prospect'
import { useProspectService } from '~/modules/directory/services/prospects'
definePageMeta({ middleware: ['admin'] })
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const id = Number(route.params.id)
const ownerIri = `/api/prospects/${id}`
const owner = { prospect: ownerIri }
const prospectService = useProspectService()
const {
contacts,
addresses,
savingContacts,
savingAddresses,
onContactInput,
addContact,
removeContact,
saveContacts,
onAddressInput,
addAddress,
removeAddress,
saveAddresses,
load,
} = useDirectoryDetail(owner)
const prospect = ref<Prospect | null>(null)
const loading = ref(true)
const activeTab = ref('contact')
const tabs = [
{ key: 'contact', label: t('directory.tabs.contact'), icon: 'mdi:account-outline' },
{ key: 'address', label: t('directory.tabs.address'), icon: 'mdi:map-marker-outline' },
{ key: 'report', label: t('directory.tabs.report'), icon: 'mdi:file-document-outline' },
]
function goBack(): void {
router.push('/directory')
}
onMounted(async () => {
prospect.value = await prospectService.getById(id)
await load()
loading.value = false
})
</script>
@@ -1,32 +0,0 @@
import type { Address, AddressWrite } from './dto/address'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
type Owner = { client?: string, prospect?: string }
export function useAddressService() {
const api = useApi()
async function getByOwner(owner: Owner): Promise<Address[]> {
const data = await api.get<HydraCollection<Address>>('/addresses', owner as Record<string, unknown>)
return extractHydraMembers(data)
}
async function create(payload: AddressWrite): Promise<Address> {
return api.post<Address>('/addresses', payload as Record<string, unknown>, {
toastSuccessKey: 'directory.addresses.saved',
})
}
async function update(id: number, payload: Partial<AddressWrite>): Promise<Address> {
return api.patch<Address>(`/addresses/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'directory.addresses.saved',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/addresses/${id}`, {}, { toastSuccessKey: 'directory.addresses.deleted' })
}
return { getByOwner, create, update, remove }
}
@@ -1,32 +0,0 @@
import type { CommercialReport, CommercialReportWrite } from './dto/commercial-report'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
type Owner = { client?: string, prospect?: string }
export function useCommercialReportService() {
const api = useApi()
async function getByOwner(owner: Owner): Promise<CommercialReport[]> {
const data = await api.get<HydraCollection<CommercialReport>>('/commercial_reports', owner as Record<string, unknown>)
return extractHydraMembers(data)
}
async function create(payload: CommercialReportWrite): Promise<CommercialReport> {
return api.post<CommercialReport>('/commercial_reports', payload as Record<string, unknown>, {
toastSuccessKey: 'directory.reports.saved',
})
}
async function update(id: number, payload: Partial<CommercialReportWrite>): Promise<CommercialReport> {
return api.patch<CommercialReport>(`/commercial_reports/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'directory.reports.saved',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/commercial_reports/${id}`, {}, { toastSuccessKey: 'directory.reports.deleted' })
}
return { getByOwner, create, update, remove }
}

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