Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4062618f7 | |||
| 3d991f78e5 | |||
| 3294b0c361 | |||
| 46e23874bd | |||
| 4a7fd46493 | |||
| 5e3607658a | |||
| 9705b335ef | |||
| 903030afbc | |||
| 961b7f56b4 | |||
| 8e00c5f5a8 | |||
| f2d945b0c3 | |||
| 610e99eeb9 | |||
| 932fccf75f | |||
| 8313c759c6 |
@@ -145,3 +145,25 @@
|
||||
- **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.
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
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
|
||||
@@ -126,6 +126,12 @@ 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)
|
||||
|
||||
@@ -7,8 +7,22 @@ declare(strict_types=1);
|
||||
* Activer/désactiver un module = ajouter/commenter sa ligne. Exposé par GET /api/modules.
|
||||
*/
|
||||
|
||||
use App\Module\Absence\AbsenceModule;
|
||||
use App\Module\Core\CoreModule;
|
||||
use App\Module\Directory\DirectoryModule;
|
||||
use App\Module\Integration\IntegrationModule;
|
||||
use App\Module\Mail\MailModule;
|
||||
use App\Module\ProjectManagement\ProjectManagementModule;
|
||||
use App\Module\Reporting\ReportingModule;
|
||||
use App\Module\TimeTracking\TimeTrackingModule;
|
||||
|
||||
return [
|
||||
CoreModule::class,
|
||||
TimeTrackingModule::class,
|
||||
ProjectManagementModule::class,
|
||||
AbsenceModule::class,
|
||||
DirectoryModule::class,
|
||||
MailModule::class,
|
||||
IntegrationModule::class,
|
||||
ReportingModule::class,
|
||||
];
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
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']
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
doctrine:
|
||||
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%'
|
||||
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)%'
|
||||
orm:
|
||||
validate_xml_mapping: true
|
||||
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
||||
@@ -15,26 +22,59 @@ doctrine:
|
||||
auto_mapping: true
|
||||
resolve_target_entities:
|
||||
App\Shared\Domain\Contract\UserInterface: App\Module\Core\Domain\Entity\User
|
||||
App\Shared\Domain\Contract\ProjectInterface: App\Module\ProjectManagement\Domain\Entity\Project
|
||||
App\Shared\Domain\Contract\TaskInterface: App\Module\ProjectManagement\Domain\Entity\Task
|
||||
App\Shared\Domain\Contract\TaskTagInterface: App\Module\ProjectManagement\Domain\Entity\TaskTag
|
||||
App\Shared\Domain\Contract\ClientInterface: App\Module\Directory\Domain\Entity\Client
|
||||
mappings:
|
||||
App:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Entity'
|
||||
prefix: 'App\Entity'
|
||||
alias: App
|
||||
Core:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/Core/Domain/Entity'
|
||||
prefix: 'App\Module\Core\Domain\Entity'
|
||||
TimeTracking:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/TimeTracking/Domain/Entity'
|
||||
prefix: 'App\Module\TimeTracking\Domain\Entity'
|
||||
ProjectManagement:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/ProjectManagement/Domain/Entity'
|
||||
prefix: 'App\Module\ProjectManagement\Domain\Entity'
|
||||
Absence:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/Absence/Domain/Entity'
|
||||
prefix: 'App\Module\Absence\Domain\Entity'
|
||||
Directory:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/Directory/Domain/Entity'
|
||||
prefix: 'App\Module\Directory\Domain\Entity'
|
||||
Mail:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/Mail/Domain/Entity'
|
||||
prefix: 'App\Module\Mail\Domain\Entity'
|
||||
Integration:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Module/Integration/Domain/Entity'
|
||||
prefix: 'App\Module\Integration\Domain\Entity'
|
||||
controller_resolver:
|
||||
auto_mapping: false
|
||||
|
||||
when@test:
|
||||
doctrine:
|
||||
dbal:
|
||||
# "TEST_TOKEN" is typically set by ParaTest
|
||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||
# 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)%'
|
||||
|
||||
when@prod:
|
||||
doctrine:
|
||||
|
||||
@@ -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\Message\MailSyncRequested': sync
|
||||
'App\Module\Mail\Application\Message\MailSyncRequested': sync
|
||||
|
||||
when@test:
|
||||
framework:
|
||||
|
||||
+77
-9
@@ -31,41 +31,51 @@ services:
|
||||
# add more service definitions when explicit configuration is needed
|
||||
# please note that last definitions always *replace* previous ones
|
||||
|
||||
App\EventListener\TaskDocumentListener:
|
||||
App\Module\ProjectManagement\Infrastructure\EventListener\TaskDocumentListener:
|
||||
arguments:
|
||||
$uploadDir: '%task_document_upload_dir%'
|
||||
tags:
|
||||
- { name: doctrine.orm.entity_listener }
|
||||
|
||||
App\State\TaskDocumentProcessor:
|
||||
App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\TaskDocumentProcessor:
|
||||
arguments:
|
||||
$uploadDir: '%task_document_upload_dir%'
|
||||
|
||||
App\Controller\TaskDocumentDownloadController:
|
||||
App\Module\ProjectManagement\Infrastructure\Controller\TaskDocumentDownloadController:
|
||||
arguments:
|
||||
$uploadDir: '%task_document_upload_dir%'
|
||||
|
||||
App\Mcp\Tool\Task\AddTaskDocumentTool:
|
||||
App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Task\AddTaskDocumentTool:
|
||||
arguments:
|
||||
$uploadDir: '%task_document_upload_dir%'
|
||||
|
||||
App\Mcp\Tool\Task\UpdateTaskDocumentTool:
|
||||
App\Module\ProjectManagement\Infrastructure\Mcp\Tool\Task\UpdateTaskDocumentTool:
|
||||
arguments:
|
||||
$uploadDir: '%task_document_upload_dir%'
|
||||
|
||||
App\Controller\UserAvatarController:
|
||||
App\Module\Core\Infrastructure\Controller\UserAvatarController:
|
||||
arguments:
|
||||
$avatarUploadDir: '%avatar_upload_dir%'
|
||||
|
||||
App\Controller\Absence\AbsenceJustificationUploadController:
|
||||
App\Module\Absence\Infrastructure\Controller\AbsenceJustificationUploadController:
|
||||
arguments:
|
||||
$uploadDir: '%absence_justification_upload_dir%'
|
||||
|
||||
App\Controller\Absence\AbsenceJustificationDownloadController:
|
||||
App\Module\Absence\Infrastructure\Controller\AbsenceJustificationDownloadController:
|
||||
arguments:
|
||||
$uploadDir: '%absence_justification_upload_dir%'
|
||||
|
||||
App\Service\Share\FileSource: '@App\Service\Share\SmbFileSource'
|
||||
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'
|
||||
|
||||
@@ -73,4 +83,62 @@ services:
|
||||
|
||||
App\Module\Core\Domain\Repository\RoleRepositoryInterface: '@App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository'
|
||||
|
||||
App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface: '@App\Module\TimeTracking\Infrastructure\Doctrine\DoctrineTimeEntryRepository'
|
||||
|
||||
App\Module\ProjectManagement\Domain\Repository\ProjectRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineProjectRepository'
|
||||
|
||||
App\Module\ProjectManagement\Domain\Repository\TaskRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskRepository'
|
||||
|
||||
App\Module\ProjectManagement\Domain\Repository\WorkflowRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineWorkflowRepository'
|
||||
|
||||
App\Module\ProjectManagement\Domain\Repository\TaskStatusRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskStatusRepository'
|
||||
|
||||
App\Module\ProjectManagement\Domain\Repository\TaskGroupRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskGroupRepository'
|
||||
|
||||
App\Module\ProjectManagement\Domain\Repository\TaskEffortRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskEffortRepository'
|
||||
|
||||
App\Module\ProjectManagement\Domain\Repository\TaskPriorityRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskPriorityRepository'
|
||||
|
||||
App\Module\ProjectManagement\Domain\Repository\TaskTagRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskTagRepository'
|
||||
|
||||
App\Module\ProjectManagement\Domain\Repository\TaskRecurrenceRepositoryInterface: '@App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskRecurrenceRepository'
|
||||
|
||||
App\Module\Absence\Domain\Repository\AbsenceRequestRepositoryInterface: '@App\Module\Absence\Infrastructure\Doctrine\DoctrineAbsenceRequestRepository'
|
||||
|
||||
App\Module\Absence\Domain\Repository\AbsencePolicyRepositoryInterface: '@App\Module\Absence\Infrastructure\Doctrine\DoctrineAbsencePolicyRepository'
|
||||
|
||||
App\Module\Absence\Domain\Repository\AbsenceBalanceRepositoryInterface: '@App\Module\Absence\Infrastructure\Doctrine\DoctrineAbsenceBalanceRepository'
|
||||
|
||||
App\Module\Directory\Domain\Repository\ClientRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineClientRepository'
|
||||
|
||||
App\Module\Directory\Domain\Repository\ProspectRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineProspectRepository'
|
||||
|
||||
App\Module\Directory\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'
|
||||
|
||||
+12
-5
@@ -10,8 +10,11 @@ declare(strict_types=1);
|
||||
* - `permission` : section ou item masqué si la permission effective absente (RBAC fin —
|
||||
* `User::getEffectivePermissions()` ; ROLE_ADMIN bypasse via le voter, mais la
|
||||
* sidebar évalue les permissions effectives réelles — combiner avec `roles` au besoin).
|
||||
* Les items contextuels (Kanban/Groupes/Archives), feature-flag (Documents, Mail) et user-flag
|
||||
* Les items contextuels (Kanban/Groupes/Archives), feature-flag (Documents) 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 [
|
||||
@@ -20,9 +23,11 @@ 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'],
|
||||
['label' => 'sidebar.general.projects', 'to' => '/projects', 'icon' => 'mdi:folder-outline'],
|
||||
['label' => 'sidebar.general.timeTracking', 'to' => '/time-tracking', 'icon' => 'mdi:calendar-edit-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'],
|
||||
],
|
||||
],
|
||||
[
|
||||
@@ -30,8 +35,10 @@ return [
|
||||
'icon' => 'mdi:cog-outline',
|
||||
'roles' => ['ROLE_ADMIN'],
|
||||
'items' => [
|
||||
['label' => 'sidebar.admin.teamAbsences', 'to' => '/team-absences', 'icon' => 'mdi:calendar-account-outline'],
|
||||
['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'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.4.30'
|
||||
app.version: '0.4.33'
|
||||
|
||||
@@ -128,6 +128,12 @@ 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
|
||||
@@ -294,7 +300,31 @@ 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 et vide le cache.
|
||||
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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,706 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,66 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,82 @@
|
||||
# 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
@@ -0,0 +1,203 @@
|
||||
# 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.
|
||||
@@ -125,8 +125,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { TaskTag } from '~/services/dto/task-tag'
|
||||
import type { Project } from '~/modules/project-management/services/dto/project'
|
||||
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
|
||||
import { useAppVersion } from '~/composables/useAppVersion'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
@@ -139,15 +139,20 @@ 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.map((item) => ({
|
||||
label: t(item.label),
|
||||
to: item.to,
|
||||
icon: item.icon,
|
||||
})),
|
||||
items: section.items
|
||||
.filter((item) => item.to !== '/mail')
|
||||
.map((item) => ({
|
||||
label: t(item.label),
|
||||
to: item.to,
|
||||
icon: item.icon,
|
||||
})),
|
||||
})),
|
||||
)
|
||||
|
||||
|
||||
@@ -51,8 +51,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsencePolicy } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
import type { AbsencePolicy } from '~/modules/absence/services/dto/absence'
|
||||
import { useAbsenceService } from '~/modules/absence/services/absences'
|
||||
|
||||
const service = useAbsenceService()
|
||||
const rows = ref<AbsencePolicy[]>([])
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
<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 '~/services/bookstack'
|
||||
import { useBookStackService } from '~/modules/integration/services/bookstack'
|
||||
|
||||
const { getSettings, saveSettings, testConnection } = useBookStackService()
|
||||
|
||||
|
||||
@@ -40,8 +40,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Client } from '~/services/dto/client'
|
||||
import { useClientService } from '~/services/clients'
|
||||
import type { Client } from '~/modules/directory/services/dto/client'
|
||||
import { useClientService } from '~/modules/directory/services/clients'
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskEffort } from '~/services/dto/task-effort'
|
||||
import { useTaskEffortService } from '~/services/task-efforts'
|
||||
import type { TaskEffort } from '~/modules/project-management/services/dto/task-effort'
|
||||
import { useTaskEffortService } from '~/modules/project-management/services/task-efforts'
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useGiteaService } from '~/services/gitea'
|
||||
import { useGiteaService } from '~/modules/integration/services/gitea'
|
||||
|
||||
const { getSettings, saveSettings, testConnection } = useGiteaService()
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMailService } from '~/services/mail'
|
||||
import { useMailService } from '~/modules/mail/services/mail'
|
||||
|
||||
const { getConfiguration, updateConfiguration, testConfiguration } = useMailService()
|
||||
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskPriority } from '~/services/dto/task-priority'
|
||||
import { useTaskPriorityService } from '~/services/task-priorities'
|
||||
import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority'
|
||||
import { useTaskPriorityService } from '~/modules/project-management/services/task-priorities'
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useShareSettingsService } from '~/services/share-settings'
|
||||
import { useShareSettingsService } from '~/modules/integration/services/share-settings'
|
||||
|
||||
const { getSettings, saveSettings, testConnection } = useShareSettingsService()
|
||||
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TaskTag } from '~/services/dto/task-tag'
|
||||
import { useTaskTagService } from '~/services/task-tags'
|
||||
import type { TaskTag } from '~/modules/project-management/services/dto/task-tag'
|
||||
import { useTaskTagService } from '~/modules/project-management/services/task-tags'
|
||||
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Workflow } from '~/services/dto/workflow'
|
||||
import { useWorkflowService } from '~/services/workflows'
|
||||
import type { Workflow } from '~/modules/project-management/services/dto/workflow'
|
||||
import { useWorkflowService } from '~/modules/project-management/services/workflows'
|
||||
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useZimbraService } from '~/services/zimbra'
|
||||
import { useZimbraService } from '~/modules/integration/services/zimbra'
|
||||
|
||||
const { getSettings, saveSettings, testConnection } = useZimbraService()
|
||||
|
||||
|
||||
@@ -96,11 +96,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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'
|
||||
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'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
||||
@@ -167,8 +167,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FileEntry } from '~/services/dto/share'
|
||||
import { useShareService } from '~/services/share'
|
||||
import type { FileEntry } from '~/modules/integration/services/dto/share'
|
||||
import { useShareService } from '~/modules/integration/services/share'
|
||||
import { formatFileSize } from '~/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -106,30 +106,43 @@ const touched = reactive({
|
||||
password: false,
|
||||
})
|
||||
|
||||
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
|
||||
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.
|
||||
}
|
||||
touched.username = false
|
||||
touched.password = false
|
||||
} else {
|
||||
form.username = ''
|
||||
form.firstName = ''
|
||||
form.lastName = ''
|
||||
form.password = ''
|
||||
form.roles = ['ROLE_USER']
|
||||
form.isEmployee = false
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update } = useUserService()
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.username = true
|
||||
touched.password = true
|
||||
|
||||
+996
-810
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -19,8 +19,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsenceBalance } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
import type { AbsenceBalance } from '~/modules/absence/services/dto/absence'
|
||||
import { useAbsenceService } from '~/modules/absence/services/absences'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
+1
-2
@@ -73,8 +73,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsenceBalance } from '~/services/dto/absence'
|
||||
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||
import type { AbsenceBalance } from '~/modules/absence/services/dto/absence'
|
||||
|
||||
const props = defineProps<{
|
||||
balances: AbsenceBalance[]
|
||||
+2
-3
@@ -52,9 +52,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsenceRequest } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||
import type { AbsenceRequest } from '~/modules/absence/services/dto/absence'
|
||||
import { useAbsenceService } from '~/modules/absence/services/absences'
|
||||
|
||||
const props = defineProps<{
|
||||
absences: AbsenceRequest[]
|
||||
+1
-1
@@ -29,7 +29,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HalfDay } from '~/services/dto/absence'
|
||||
import type { HalfDay } from '~/modules/absence/services/dto/absence'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** ISO date string "YYYY-MM-DD" or null. */
|
||||
+2
-3
@@ -135,9 +135,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsenceRequest } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||
import type { AbsenceRequest } from '~/modules/absence/services/dto/absence'
|
||||
import { useAbsenceService } from '~/modules/absence/services/absences'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
+2
-3
@@ -26,9 +26,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsenceRequest } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||
import type { AbsenceRequest } from '~/modules/absence/services/dto/absence'
|
||||
import { useAbsenceService } from '~/modules/absence/services/absences'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
+2
-3
@@ -105,9 +105,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsencePolicy, AbsencePreviewResult, AbsenceType, HalfDay } from '~/services/dto/absence'
|
||||
import { useAbsenceService } from '~/services/absences'
|
||||
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||
import type { AbsencePolicy, AbsencePreviewResult, AbsenceType, HalfDay } from '~/modules/absence/services/dto/absence'
|
||||
import { useAbsenceService } from '~/modules/absence/services/absences'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { AbsenceRequest, AbsenceStatus, AbsenceType, HalfDay } from '~/services/dto/absence'
|
||||
import type { AbsenceRequest, AbsenceStatus, AbsenceType, HalfDay } from '~/modules/absence/services/dto/absence'
|
||||
|
||||
export type BadgeVariant = 'neutral' | 'info' | 'success' | 'warning' | 'danger'
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export default defineNuxtConfig({})
|
||||
@@ -69,9 +69,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AbsenceBalance, AbsencePolicy, AbsenceRequest, AbsenceStatus, AbsenceType } from '~/services/dto/absence'
|
||||
import { useAbsenceService, type AbsenceRequestFilters } from '~/services/absences'
|
||||
import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
|
||||
import type { AbsenceBalance, AbsencePolicy, AbsenceRequest, AbsenceStatus, AbsenceType } from '~/modules/absence/services/dto/absence'
|
||||
import { useAbsenceService, type AbsenceRequestFilters } from '~/modules/absence/services/absences'
|
||||
|
||||
type Row = AbsenceRequest & { typeLabelText: string; periodText: string; daysText: string; createdAtText: string }
|
||||
|
||||
@@ -198,12 +198,11 @@ import type {
|
||||
AbsenceRequest,
|
||||
AbsenceStatus,
|
||||
AbsenceType,
|
||||
} from "~/services/dto/absence";
|
||||
} from "~/modules/absence/services/dto/absence";
|
||||
import {
|
||||
useAbsenceService,
|
||||
type AbsenceRequestFilters,
|
||||
} from "~/services/absences";
|
||||
import { useAbsenceHelpers } from "~/composables/useAbsenceHelpers";
|
||||
} from "~/modules/absence/services/absences";
|
||||
import { useUserService } from "~/services/users";
|
||||
import type { UserData } from "~/services/dto/user-data";
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
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 }
|
||||
}
|
||||
+4
-55
@@ -6,36 +6,11 @@
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
label="Nom"
|
||||
label="Nom société"
|
||||
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
|
||||
@@ -50,8 +25,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Client, ClientWrite } from '~/services/dto/client'
|
||||
import { useClientService } from '~/services/clients'
|
||||
import type { Client, ClientWrite } from '~/modules/directory/services/dto/client'
|
||||
import { useClientService } from '~/modules/directory/services/clients'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -73,37 +48,16 @@ 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) {
|
||||
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 = ''
|
||||
}
|
||||
form.name = props.client?.name ?? ''
|
||||
touched.name = false
|
||||
touched.email = false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -117,11 +71,6 @@ 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) {
|
||||
@@ -0,0 +1,160 @@
|
||||
<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>
|
||||
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<Teleport v-if="modelValue" to="body">
|
||||
<Transition name="modal" appear>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/30" @click="cancel" />
|
||||
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ title }}</h3>
|
||||
<p class="mt-3 text-sm text-neutral-600">
|
||||
{{ message }}
|
||||
</p>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('common.cancel')"
|
||||
button-class="w-auto px-4"
|
||||
@click="cancel"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="danger"
|
||||
:label="$t('common.delete')"
|
||||
button-class="w-auto px-4"
|
||||
@click="$emit('confirm')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
title: string
|
||||
message: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'confirm'): void
|
||||
}>()
|
||||
|
||||
function cancel() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
<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>
|
||||
@@ -0,0 +1,73 @@
|
||||
<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>
|
||||
@@ -0,0 +1,115 @@
|
||||
<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>
|
||||
@@ -0,0 +1,42 @@
|
||||
<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>
|
||||
@@ -0,0 +1,45 @@
|
||||
<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>
|
||||
@@ -0,0 +1,121 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default defineNuxtConfig({})
|
||||
@@ -0,0 +1,129 @@
|
||||
<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>
|
||||
@@ -0,0 +1,311 @@
|
||||
<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>
|
||||
@@ -0,0 +1,129 @@
|
||||
<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>
|
||||
@@ -0,0 +1,32 @@
|
||||
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 }
|
||||
}
|
||||
@@ -10,6 +10,10 @@ export function useClientService() {
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function getById(id: number): Promise<Client> {
|
||||
return api.get<Client>(`/clients/${id}`)
|
||||
}
|
||||
|
||||
async function create(payload: ClientWrite): Promise<Client> {
|
||||
return api.post<Client>('/clients', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'clients.created',
|
||||
@@ -28,5 +32,5 @@ export function useClientService() {
|
||||
})
|
||||
}
|
||||
|
||||
return { getAll, create, update, remove }
|
||||
return { getAll, getById, create, update, remove }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Contact, ContactWrite } from './dto/contact'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
type Owner = { client?: string, prospect?: string }
|
||||
|
||||
export function useContactService() {
|
||||
const api = useApi()
|
||||
|
||||
async function getByOwner(owner: Owner): Promise<Contact[]> {
|
||||
const data = await api.get<HydraCollection<Contact>>('/contacts', owner as Record<string, unknown>)
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function create(payload: ContactWrite): Promise<Contact> {
|
||||
return api.post<Contact>('/contacts', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'directory.contacts.saved',
|
||||
})
|
||||
}
|
||||
|
||||
async function update(id: number, payload: Partial<ContactWrite>): Promise<Contact> {
|
||||
return api.patch<Contact>(`/contacts/${id}`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'directory.contacts.saved',
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/contacts/${id}`, {}, { toastSuccessKey: 'directory.contacts.deleted' })
|
||||
}
|
||||
|
||||
return { getByOwner, create, update, remove }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export type Address = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
label: string | null
|
||||
street: string | null
|
||||
streetComplement: string | null
|
||||
postalCode: string | null
|
||||
city: string | null
|
||||
country: string
|
||||
client?: string | null
|
||||
prospect?: string | null
|
||||
}
|
||||
|
||||
export type AddressWrite = {
|
||||
label: string | null
|
||||
street: string | null
|
||||
streetComplement: string | null
|
||||
postalCode: string | null
|
||||
city: string | null
|
||||
country: string
|
||||
client?: string | null
|
||||
prospect?: string | null
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export type Client = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
name: string
|
||||
email: string | null
|
||||
phone: string | null
|
||||
}
|
||||
|
||||
export type ClientWrite = {
|
||||
name: string
|
||||
email?: string | null
|
||||
phone?: string | null
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ReportDocument } from './report-document'
|
||||
|
||||
export type ReportType = 'call' | 'meeting' | 'email' | 'note'
|
||||
|
||||
export type CommercialReport = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
subject: string
|
||||
body: string | null
|
||||
occurredAt: string
|
||||
type: ReportType
|
||||
author?: { id: number, username: string } | null
|
||||
client?: string | null
|
||||
prospect?: string | null
|
||||
documents?: ReportDocument[]
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export type CommercialReportWrite = {
|
||||
subject: string
|
||||
body: string | null
|
||||
occurredAt: string
|
||||
type: ReportType
|
||||
client?: string | null
|
||||
prospect?: string | null
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export type Contact = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
firstName: string | null
|
||||
lastName: string | null
|
||||
jobTitle: string | null
|
||||
email: string | null
|
||||
phonePrimary: string | null
|
||||
phoneSecondary: string | null
|
||||
client?: string | null
|
||||
prospect?: string | null
|
||||
}
|
||||
|
||||
export type ContactWrite = {
|
||||
firstName: string | null
|
||||
lastName: string | null
|
||||
jobTitle: string | null
|
||||
email: string | null
|
||||
phonePrimary: string | null
|
||||
phoneSecondary: string | null
|
||||
client?: string | null
|
||||
prospect?: string | null
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Client } from './client'
|
||||
|
||||
export type ProspectStatus = 'new' | 'contacted' | 'qualified' | 'won' | 'lost'
|
||||
|
||||
export type Prospect = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
name: string
|
||||
company: string | null
|
||||
email: string | null
|
||||
phone: string | null
|
||||
status: ProspectStatus
|
||||
source: string | null
|
||||
notes: string | null
|
||||
convertedClient: Client | string | null
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export type ProspectWrite = {
|
||||
name: string
|
||||
company?: string | null
|
||||
email?: string | null
|
||||
phone?: string | null
|
||||
status?: ProspectStatus
|
||||
source?: string | null
|
||||
notes?: string | null
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
|
||||
export type ReportDocument = {
|
||||
'@id'?: string
|
||||
id: number
|
||||
commercialReport: string
|
||||
originalName: string
|
||||
fileName?: string | null
|
||||
mimeType: string
|
||||
size: number
|
||||
createdAt: string
|
||||
uploadedBy?: UserData | null
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Prospect, ProspectStatus, ProspectWrite } from './dto/prospect'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
export function useProspectService() {
|
||||
const api = useApi()
|
||||
|
||||
async function getAll(status?: ProspectStatus): Promise<Prospect[]> {
|
||||
const query: Record<string, unknown> = {}
|
||||
if (status) query.status = status
|
||||
const data = await api.get<HydraCollection<Prospect>>('/prospects', query)
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function getById(id: number): Promise<Prospect> {
|
||||
return api.get<Prospect>(`/prospects/${id}`)
|
||||
}
|
||||
|
||||
async function create(payload: ProspectWrite): Promise<Prospect> {
|
||||
return api.post<Prospect>('/prospects', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'prospects.created',
|
||||
})
|
||||
}
|
||||
|
||||
async function update(id: number, payload: Partial<ProspectWrite>): Promise<Prospect> {
|
||||
return api.patch<Prospect>(`/prospects/${id}`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'prospects.updated',
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/prospects/${id}`, {}, {
|
||||
toastSuccessKey: 'prospects.deleted',
|
||||
})
|
||||
}
|
||||
|
||||
async function convert(id: number): Promise<Prospect> {
|
||||
return api.post<Prospect>(`/prospects/${id}/convert`, {}, {
|
||||
toastSuccessKey: 'prospects.converted',
|
||||
})
|
||||
}
|
||||
|
||||
return { getAll, getById, create, update, remove, convert }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ReportDocument } from './dto/report-document'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
import { $fetch } from 'ofetch'
|
||||
|
||||
export function useReportDocumentService() {
|
||||
const api = useApi()
|
||||
const config = useRuntimeConfig()
|
||||
const baseURL = config.public.apiBase || '/api'
|
||||
|
||||
async function getByReport(reportId: number): Promise<ReportDocument[]> {
|
||||
const data = await api.get<HydraCollection<ReportDocument>>('/report_documents', {
|
||||
commercialReport: `/api/commercial_reports/${reportId}`,
|
||||
})
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function upload(reportId: number, file: File): Promise<ReportDocument> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('commercialReport', `/api/commercial_reports/${reportId}`)
|
||||
|
||||
return $fetch<ReportDocument>(`${baseURL}/report_documents`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/report_documents/${id}`, {}, { toastSuccessKey: 'directory.documents.deleted' })
|
||||
}
|
||||
|
||||
function getDownloadUrl(id: number): string {
|
||||
return `${baseURL}/report_documents/${id}/download`
|
||||
}
|
||||
|
||||
return { getByReport, upload, remove, getDownloadUrl }
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { useShareService } from '~/services/share'
|
||||
import { useShareService } from '~/modules/integration/services/share'
|
||||
|
||||
export function useShareStatus() {
|
||||
const enabled = useState<boolean | null>('share-enabled', () => null)
|
||||
@@ -0,0 +1 @@
|
||||
export default defineNuxtConfig({})
|
||||
+7
-7
@@ -1,12 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
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 { 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 { UserData } from '~/services/dto/user-data'
|
||||
import { useMailService } from '~/services/mail'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
import { useTaskGroupService } from '~/services/task-groups'
|
||||
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 { useUserService } from '~/services/users'
|
||||
import { useAuthStore } from '~/shared/stores/auth'
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { MailFolderDto } from '~/services/dto/mail'
|
||||
import type { MailFolderDto } from '~/modules/mail/services/dto/mail'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Arbre de dossiers (getter folderTree du store) */
|
||||
+5
-5
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
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'
|
||||
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'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { MailMessageHeaderDto } from '~/services/dto/mail'
|
||||
import type { MailMessageHeaderDto } from '~/modules/mail/services/dto/mail'
|
||||
|
||||
const props = defineProps<{
|
||||
messages: readonly MailMessageHeaderDto[]
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { MailMessageDetailDto, MailAddressDto, MailAttachmentDto } from '~/services/dto/mail'
|
||||
import type { MailMessageDetailDto, MailAddressDto, MailAttachmentDto } from '~/modules/mail/services/dto/mail'
|
||||
import { sanitizeMailHtml } from '~/utils/sanitizeMailHtml'
|
||||
import { useMailService } from '~/services/mail'
|
||||
import { useMailService } from '~/modules/mail/services/mail'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Détail complet du message. null = aucun message sélectionné. */
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useMailStore } from '~/stores/mail'
|
||||
import { useMailStore } from '~/modules/mail/stores/mail'
|
||||
|
||||
const store = useMailStore()
|
||||
const { syncing } = storeToRefs(store)
|
||||
@@ -0,0 +1 @@
|
||||
export default defineNuxtConfig({})
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import { useMailStore } from '~/stores/mail'
|
||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
||||
import { useMailStore } from '~/modules/mail/stores/mail'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
@@ -11,8 +11,8 @@ import type {
|
||||
MailCreateTaskInput,
|
||||
MailLinkTaskInput,
|
||||
MailSyncResultDto,
|
||||
} from './dto/mail'
|
||||
import type { Task } from './dto/task'
|
||||
} from '~/modules/mail/services/dto/mail'
|
||||
import type { Task } from '~/modules/project-management/services/dto/task'
|
||||
|
||||
type BackendMailMessage = {
|
||||
id: number
|
||||
@@ -3,8 +3,8 @@ import type {
|
||||
MailFolderDto,
|
||||
MailMessageHeaderDto,
|
||||
MailMessageDetailDto,
|
||||
} from '~/services/dto/mail'
|
||||
import { useMailService } from '~/services/mail'
|
||||
} from '~/modules/mail/services/dto/mail'
|
||||
import { useMailService } from '~/modules/mail/services/mail'
|
||||
|
||||
const POLL_INTERVAL_MS = 30 * 1000 // 30 secondes
|
||||
|
||||
+7
-7
@@ -123,13 +123,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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'
|
||||
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'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
+4
-4
@@ -67,10 +67,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 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 { stripRichText } from '~/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
+6
-6
@@ -82,12 +82,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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'
|
||||
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'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
|
||||
|
||||
defineProps<{
|
||||
statuses: TaskStatus[]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user