# Répertoire — Contacts, Adresses & Rapports commerciaux — Plan d'implémentation > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Doter chaque fiche client/prospect du répertoire Lesstime de contacts multiples, adresses multiples et d'un journal de comptes-rendus commerciaux (avec pièces jointes), via une fiche détail à onglets inspirée de Starseed. **Architecture:** Trois entités répétables (`Contact`, `Address`, `CommercialReport`) dans le module `Directory`, chacune rattachée à un `Client` **ou** un `Prospect` via double-FK nullable + contrainte CHECK (pattern existant). Les pièces jointes des comptes-rendus passent par une entité `ReportDocument` dédiée au module (frontière modulaire préservée, réutilisant le même répertoire de stockage que `TaskDocument`). Frontend : fiches détail à route dédiée avec `MalioTabList`, blocs répétables, sauvegarde indépendante par entité. **Tech Stack:** PHP 8.4 / Symfony 8 / API Platform 4 / Doctrine ORM / PostgreSQL 16 ; Nuxt 4 / Vue 3 / Pinia / TypeScript / Tailwind / @malio/layer-ui. ## Global Constraints - **Spec de référence :** `docs/superpowers/specs/2026-06-22-directory-commercial-reports-design.md`. - **Backend :** `declare(strict_types=1)` en tête de chaque fichier PHP ; pas de controller pour le CRUD (API Platform : ApiResource + Provider/Processor) ; controllers custom sous `/api/` → `priority: 1` sur la route. - **Sécurité API :** lecture `is_granted('ROLE_USER')`, écriture `is_granted('ROLE_ADMIN')` (pattern du module `Directory`). - **Doctrine/PostgreSQL :** noms de colonnes en **minuscules** dans le SQL brut. - **Module modular monolith :** code backend sous `src/Module/Directory/{Domain,Infrastructure}` ; relations cross-module via contrats `App\Shared\Domain\Contract\*` (ex. `UserInterface`). - **Sérialisation :** pour embarquer une relation (et non un IRI), ajouter le groupe `*:read` du parent sur les propriétés de l'entité cible. - **Upload :** valider le MIME via `$file->getMimeType()` (serveur), pas `getClientMimeType()`. Max 50 MB. Réutiliser le paramètre Symfony `task_document_upload_dir`. - **Frontend :** TypeScript strict, 4 espaces d'indentation ; tous les appels API via `useApi()` ; collections lues via `extractHydraMembers` doivent être servies non paginées (`paginationEnabled: false`). - **i18n :** toutes les chaînes UI dans `frontend/i18n/locales/fr.json` (pas de texte en dur). - **Tests :** `make test` (PHPUnit). Base de test **non isolée** (les POST s'accumulent) → tester des **invariants** (relations, statuts, présence), **jamais des counts absolus**. - **Commits :** `() : ` (espace autour du `:`). Aucune mention d'IA/Claude. Ne pas tagger/pusher. - **Commandes utiles :** `make migration-migrate` (migrations), `make php-cs-fixer-allow-risky` (style PHP), `make dev-nuxt` (front hot reload), `make shell` (shell PHP). Test ciblé : `docker exec -i php-lesstime-fpm php bin/phpunit --filter `. ## File Structure **Backend — `src/Module/Directory/`** - `Domain/Enum/ReportType.php` — enum type d'échange (call/meeting/email/note) + libellés FR. - `Domain/Entity/Contact.php` — contact répétable (double-FK client/prospect). - `Domain/Entity/Address.php` — adresse répétable (double-FK). - `Domain/Entity/CommercialReport.php` — compte-rendu (double-FK) + OneToMany ReportDocument. - `Domain/Entity/ReportDocument.php` — pièce jointe d'un compte-rendu. - `Domain/Repository/{Contact,Address,CommercialReport,ReportDocument}RepositoryInterface.php`. - `Infrastructure/Doctrine/Doctrine{Contact,Address,CommercialReport,ReportDocument}Repository.php`. - `Infrastructure/ApiPlatform/State/ReportDocumentProcessor.php` — upload multipart. - `Infrastructure/Controller/ReportDocumentDownloadController.php` — download. - `Infrastructure/EventListener/ReportDocumentListener.php` — `unlink` fichier au `preRemove`. - `Infrastructure/ApiPlatform/State/ConvertProspectProcessor.php` — *modifié* (réaffectation). - `DirectoryModule.php` — *modifié* (permissions reports). **Backend — racine** - `migrations/Version2026XXXXXXXXXX.php` — tables + data migration adresse inline. - `tests/Module/Directory/...` — tests fonctionnels. **Frontend — `frontend/modules/directory/`** - `services/dto/{contact,address,commercial-report,report-document}.ts`. - `services/{contacts,addresses,commercial-reports,report-documents}.ts`. - `components/DirectoryContactBlock.vue`, `DirectoryAddressBlock.vue`. - `components/CommercialReportTab.vue`, `ReportDocumentUpload.vue`, `ReportDocumentList.vue`. - `pages/clients/[id].vue`, `pages/prospects/[id].vue`. - `pages/directory.vue` — *modifié* (navigation vers fiche détail). - `frontend/i18n/locales/fr.json` — *modifié*. --- ## Task 1 : Enum `ReportType` **Files:** - Create: `src/Module/Directory/Domain/Enum/ReportType.php` - Test: `tests/Module/Directory/Domain/Enum/ReportTypeTest.php` **Interfaces:** - Produces: `enum ReportType: string { Call='call'; Meeting='meeting'; Email='email'; Note='note'; public function label(): string }` - [ ] **Step 1 : Écrire le test qui échoue** ```php value); self::assertSame('Appel', ReportType::Call->label()); self::assertSame('Rendez-vous', ReportType::Meeting->label()); self::assertSame('Email', ReportType::Email->label()); self::assertSame('Note', ReportType::Note->label()); } } ``` - [ ] **Step 2 : Lancer le test, vérifier l'échec** Run: `docker exec -i php-lesstime-fpm php bin/phpunit --filter ReportTypeTest` Expected: FAIL (classe `ReportType` inexistante). - [ ] **Step 3 : Implémenter l'enum** ```php 'Appel', self::Meeting => 'Rendez-vous', self::Email => 'Email', self::Note => 'Note', }; } } ``` - [ ] **Step 4 : Lancer le test, vérifier le succès** Run: `docker exec -i php-lesstime-fpm php bin/phpunit --filter ReportTypeTest` Expected: PASS. - [ ] **Step 5 : Commit** ```bash git add src/Module/Directory/Domain/Enum/ReportType.php tests/Module/Directory/Domain/Enum/ReportTypeTest.php git commit -m "feat(directory) : add ReportType enum for commercial reports" ``` --- ## Task 2 : Entité `Contact` + repository **Files:** - Create: `src/Module/Directory/Domain/Entity/Contact.php` - Create: `src/Module/Directory/Domain/Repository/ContactRepositoryInterface.php` - Create: `src/Module/Directory/Infrastructure/Doctrine/DoctrineContactRepository.php` **Interfaces:** - Consumes: `App\Shared\Domain\Contract\TimestampableInterface`, `TimestampableBlamableTrait`, `Auditable`. - Produces: `Contact` (ApiResource `/contacts`, groupes `contact:read`/`contact:write`, double ManyToOne `client`/`prospect`), `ContactRepositoryInterface::findById(int): ?Contact`. > Pas de table en base tant que la migration (Task 6) n'a pas tourné. Cette tâche se valide par l'absence d'erreur de mapping Doctrine (`schema:validate` mapping) ; la persistance est testée en Task 6. - [ ] **Step 1 : Créer l'entité** ```php ['contact:read']], denormalizationContext: ['groups' => ['contact:write']], order: ['lastName' => 'ASC'], )] #[ApiFilter(SearchFilter::class, properties: ['client' => 'exact', 'prospect' => 'exact'])] #[ORM\Entity(repositoryClass: DoctrineContactRepository::class)] #[ORM\Table(name: 'directory_contact')] class Contact implements TimestampableInterface, BlamableInterface { use TimestampableBlamableTrait; #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] #[Groups(['contact:read'])] private ?int $id = null; #[ORM\Column(length: 255, nullable: true)] #[Groups(['contact:read', 'contact:write', 'client:read', 'prospect:read'])] private ?string $firstName = null; #[ORM\Column(length: 255, nullable: true)] #[Groups(['contact:read', 'contact:write', 'client:read', 'prospect:read'])] private ?string $lastName = null; #[ORM\Column(length: 255, nullable: true)] #[Groups(['contact:read', 'contact:write', 'client:read', 'prospect:read'])] private ?string $jobTitle = null; #[ORM\Column(length: 255, nullable: true)] #[Groups(['contact:read', 'contact:write', 'client:read', 'prospect:read'])] private ?string $email = null; #[ORM\Column(length: 50, nullable: true)] #[Groups(['contact:read', 'contact:write', 'client:read', 'prospect:read'])] private ?string $phonePrimary = null; #[ORM\Column(length: 50, nullable: true)] #[Groups(['contact:read', 'contact:write', 'client:read', 'prospect:read'])] private ?string $phoneSecondary = null; #[ORM\ManyToOne(targetEntity: Client::class)] #[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] #[Groups(['contact:read', 'contact:write'])] private ?Client $client = null; #[ORM\ManyToOne(targetEntity: Prospect::class)] #[ORM\JoinColumn(name: 'prospect_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] #[Groups(['contact:read', 'contact:write'])] private ?Prospect $prospect = null; public function getId(): ?int { return $this->id; } public function getFirstName(): ?string { return $this->firstName; } public function setFirstName(?string $firstName): static { $this->firstName = $firstName; return $this; } public function getLastName(): ?string { return $this->lastName; } public function setLastName(?string $lastName): static { $this->lastName = $lastName; return $this; } public function getJobTitle(): ?string { return $this->jobTitle; } public function setJobTitle(?string $jobTitle): static { $this->jobTitle = $jobTitle; return $this; } public function getEmail(): ?string { return $this->email; } public function setEmail(?string $email): static { $this->email = $email; return $this; } public function getPhonePrimary(): ?string { return $this->phonePrimary; } public function setPhonePrimary(?string $phonePrimary): static { $this->phonePrimary = $phonePrimary; return $this; } public function getPhoneSecondary(): ?string { return $this->phoneSecondary; } public function setPhoneSecondary(?string $phoneSecondary): static { $this->phoneSecondary = $phoneSecondary; return $this; } public function getClient(): ?Client { return $this->client; } public function setClient(?Client $client): static { $this->client = $client; return $this; } public function getProspect(): ?Prospect { return $this->prospect; } public function setProspect(?Prospect $prospect): static { $this->prospect = $prospect; return $this; } } ``` - [ ] **Step 2 : Créer l'interface de repository** ```php */ final class DoctrineContactRepository extends ServiceEntityRepository implements ContactRepositoryInterface { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Contact::class); } public function findById(int $id): ?Contact { return $this->find($id); } } ``` - [ ] **Step 4 : Valider le mapping Doctrine** Run: `docker exec -i php-lesstime-fpm php bin/console doctrine:schema:validate --skip-sync` Expected: `[Mapping] OK.` (le `--skip-sync` ignore le fait que la table n'existe pas encore). - [ ] **Step 5 : Commit** ```bash git add src/Module/Directory/Domain/Entity/Contact.php src/Module/Directory/Domain/Repository/ContactRepositoryInterface.php src/Module/Directory/Infrastructure/Doctrine/DoctrineContactRepository.php git commit -m "feat(directory) : add Contact entity with client/prospect dual ownership" ``` --- ## Task 3 : Entité `Address` + repository **Files:** - Create: `src/Module/Directory/Domain/Entity/Address.php` - Create: `src/Module/Directory/Domain/Repository/AddressRepositoryInterface.php` - Create: `src/Module/Directory/Infrastructure/Doctrine/DoctrineAddressRepository.php` **Interfaces:** - Produces: `Address` (ApiResource `/addresses`, groupes `address:read`/`address:write`, double ManyToOne), `AddressRepositoryInterface::findById(int): ?Address`. - [ ] **Step 1 : Créer l'entité** ```php ['address:read']], denormalizationContext: ['groups' => ['address:write']], order: ['id' => 'ASC'], )] #[ApiFilter(SearchFilter::class, properties: ['client' => 'exact', 'prospect' => 'exact'])] #[ORM\Entity(repositoryClass: DoctrineAddressRepository::class)] #[ORM\Table(name: 'directory_address')] class Address implements TimestampableInterface, BlamableInterface { use TimestampableBlamableTrait; #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] #[Groups(['address:read'])] private ?int $id = null; #[ORM\Column(length: 255, nullable: true)] #[Groups(['address:read', 'address:write', 'client:read', 'prospect:read'])] private ?string $label = null; #[ORM\Column(length: 255, nullable: true)] #[Groups(['address:read', 'address:write', 'client:read', 'prospect:read'])] private ?string $street = null; #[ORM\Column(length: 255, nullable: true)] #[Groups(['address:read', 'address:write', 'client:read', 'prospect:read'])] private ?string $streetComplement = null; #[ORM\Column(length: 20, nullable: true)] #[Groups(['address:read', 'address:write', 'client:read', 'prospect:read'])] private ?string $postalCode = null; #[ORM\Column(length: 255, nullable: true)] #[Groups(['address:read', 'address:write', 'client:read', 'prospect:read'])] private ?string $city = null; #[ORM\Column(length: 2)] #[Groups(['address:read', 'address:write', 'client:read', 'prospect:read'])] private string $country = 'FR'; #[ORM\ManyToOne(targetEntity: Client::class)] #[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] #[Groups(['address:read', 'address:write'])] private ?Client $client = null; #[ORM\ManyToOne(targetEntity: Prospect::class)] #[ORM\JoinColumn(name: 'prospect_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] #[Groups(['address:read', 'address:write'])] private ?Prospect $prospect = null; public function getId(): ?int { return $this->id; } public function getLabel(): ?string { return $this->label; } public function setLabel(?string $label): static { $this->label = $label; return $this; } public function getStreet(): ?string { return $this->street; } public function setStreet(?string $street): static { $this->street = $street; return $this; } public function getStreetComplement(): ?string { return $this->streetComplement; } public function setStreetComplement(?string $streetComplement): static { $this->streetComplement = $streetComplement; return $this; } public function getPostalCode(): ?string { return $this->postalCode; } public function setPostalCode(?string $postalCode): static { $this->postalCode = $postalCode; return $this; } public function getCity(): ?string { return $this->city; } public function setCity(?string $city): static { $this->city = $city; return $this; } public function getCountry(): string { return $this->country; } public function setCountry(string $country): static { $this->country = $country; return $this; } public function getClient(): ?Client { return $this->client; } public function setClient(?Client $client): static { $this->client = $client; return $this; } public function getProspect(): ?Prospect { return $this->prospect; } public function setProspect(?Prospect $prospect): static { $this->prospect = $prospect; return $this; } } ``` - [ ] **Step 2 : Créer l'interface de repository** ```php */ final class DoctrineAddressRepository extends ServiceEntityRepository implements AddressRepositoryInterface { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Address::class); } public function findById(int $id): ?Address { return $this->find($id); } } ``` - [ ] **Step 4 : Valider le mapping Doctrine** Run: `docker exec -i php-lesstime-fpm php bin/console doctrine:schema:validate --skip-sync` Expected: `[Mapping] OK.` - [ ] **Step 5 : Commit** ```bash git add src/Module/Directory/Domain/Entity/Address.php src/Module/Directory/Domain/Repository/AddressRepositoryInterface.php src/Module/Directory/Infrastructure/Doctrine/DoctrineAddressRepository.php git commit -m "feat(directory) : add Address entity with client/prospect dual ownership" ``` --- ## Task 4 : Entité `CommercialReport` + repository **Files:** - Create: `src/Module/Directory/Domain/Entity/CommercialReport.php` - Create: `src/Module/Directory/Domain/Repository/CommercialReportRepositoryInterface.php` - Create: `src/Module/Directory/Infrastructure/Doctrine/DoctrineCommercialReportRepository.php` **Files (suite) :** - Create: `src/Module/Directory/Infrastructure/EventListener/CommercialReportAuthorListener.php` - Modify: `config/services.yaml` **Interfaces:** - Consumes: `ReportType` (Task 1), `UserInterface` (Shared contract), `ReportDocument` (Task 5 — déclarée ici en OneToMany ; l'entité `ReportDocument` est créée en Task 5, qui doit être appliquée avant la migration Task 6). - Produces: `CommercialReport` (ApiResource `/commercial_reports`, groupes `commercial_report:read`/`commercial_report:write`), `CommercialReportRepositoryInterface::findById(int): ?CommercialReport`. `author` renseigné automatiquement au `prePersist` avec l'utilisateur connecté. > ⚠️ Ordre : cette entité référence `ReportDocument::class` (Task 5). Implémenter Task 5 juste après, avant la migration (Task 6) et toute exécution. > ⚠️ `author` n'est PAS alimenté par le trait Blamable (qui gère `created_by`). Il est rempli par un listener dédié (Steps 4–5 ci-dessous), et n'a volontairement pas de groupe `:write` (non modifiable par le client). - [ ] **Step 1 : Créer l'entité** ```php ['commercial_report:read']], denormalizationContext: ['groups' => ['commercial_report:write']], order: ['occurredAt' => 'DESC'], )] #[ApiFilter(SearchFilter::class, properties: ['client' => 'exact', 'prospect' => 'exact'])] #[ORM\Entity(repositoryClass: DoctrineCommercialReportRepository::class)] #[ORM\Table(name: 'commercial_report')] class CommercialReport implements TimestampableInterface { use TimestampableBlamableTrait; #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] #[Groups(['commercial_report:read'])] private ?int $id = null; #[ORM\Column(length: 255)] #[Groups(['commercial_report:read', 'commercial_report:write'])] private ?string $subject = null; #[ORM\Column(type: Types::TEXT, nullable: true)] #[Groups(['commercial_report:read', 'commercial_report:write'])] private ?string $body = null; #[ORM\Column(type: Types::DATE_IMMUTABLE)] #[Groups(['commercial_report:read', 'commercial_report:write'])] private ?DateTimeImmutable $occurredAt = null; #[ORM\Column(type: Types::STRING, length: 32, enumType: ReportType::class)] #[Groups(['commercial_report:read', 'commercial_report:write'])] private ReportType $type = ReportType::Note; #[ORM\ManyToOne(targetEntity: UserInterface::class)] #[ORM\JoinColumn(name: 'author_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] #[Groups(['commercial_report:read'])] private ?UserInterface $author = null; #[ORM\ManyToOne(targetEntity: Client::class)] #[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] #[Groups(['commercial_report:read', 'commercial_report:write'])] private ?Client $client = null; #[ORM\ManyToOne(targetEntity: Prospect::class)] #[ORM\JoinColumn(name: 'prospect_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] #[Groups(['commercial_report:read', 'commercial_report:write'])] private ?Prospect $prospect = null; /** @var Collection */ #[ORM\OneToMany(targetEntity: ReportDocument::class, mappedBy: 'commercialReport', cascade: ['remove'])] #[Groups(['commercial_report:read'])] private Collection $documents; public function __construct() { $this->documents = new ArrayCollection(); } public function getId(): ?int { return $this->id; } public function getSubject(): ?string { return $this->subject; } public function setSubject(string $subject): static { $this->subject = $subject; return $this; } public function getBody(): ?string { return $this->body; } public function setBody(?string $body): static { $this->body = $body; return $this; } public function getOccurredAt(): ?DateTimeImmutable { return $this->occurredAt; } public function setOccurredAt(DateTimeImmutable $occurredAt): static { $this->occurredAt = $occurredAt; return $this; } public function getType(): ReportType { return $this->type; } public function setType(ReportType $type): static { $this->type = $type; return $this; } public function getAuthor(): ?UserInterface { return $this->author; } public function setAuthor(?UserInterface $author): static { $this->author = $author; return $this; } public function getClient(): ?Client { return $this->client; } public function setClient(?Client $client): static { $this->client = $client; return $this; } public function getProspect(): ?Prospect { return $this->prospect; } public function setProspect(?Prospect $prospect): static { $this->prospect = $prospect; return $this; } /** @return Collection */ public function getDocuments(): Collection { return $this->documents; } } ``` - [ ] **Step 2 : Créer l'interface de repository** ```php */ final class DoctrineCommercialReportRepository extends ServiceEntityRepository implements CommercialReportRepositoryInterface { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, CommercialReport::class); } public function findById(int $id): ?CommercialReport { return $this->find($id); } } ``` - [ ] **Step 4 : Créer le listener qui renseigne l'auteur** `src/Module/Directory/Infrastructure/EventListener/CommercialReportAuthorListener.php` : ```php getAuthor()) { return; } $user = $this->security->getUser(); if ($user instanceof UserInterface) { $report->setAuthor($user); } } } ``` - [ ] **Step 5 : Enregistrer le listener dans `config/services.yaml`** ```yaml App\Module\Directory\Infrastructure\EventListener\CommercialReportAuthorListener: tags: - { name: doctrine.orm.entity_listener, entity: 'App\Module\Directory\Domain\Entity\CommercialReport', event: prePersist } ``` > Si l'autowiring du projet enregistre déjà tous les services de `src/`, seule la partie `tags` est nécessaire (vérifier le bloc `services:` par défaut de `config/services.yaml`). - [ ] **Step 6 : Commit** (validation Doctrine après Task 5, car référence `ReportDocument`) ```bash git add src/Module/Directory/Domain/Entity/CommercialReport.php src/Module/Directory/Domain/Repository/CommercialReportRepositoryInterface.php src/Module/Directory/Infrastructure/Doctrine/DoctrineCommercialReportRepository.php src/Module/Directory/Infrastructure/EventListener/CommercialReportAuthorListener.php config/services.yaml git commit -m "feat(directory) : add CommercialReport entity with dual ownership and author" ``` --- ## Task 5 : Entité `ReportDocument` + processor + download + listener **Files:** - Create: `src/Module/Directory/Domain/Entity/ReportDocument.php` - Create: `src/Module/Directory/Domain/Repository/ReportDocumentRepositoryInterface.php` - Create: `src/Module/Directory/Infrastructure/Doctrine/DoctrineReportDocumentRepository.php` - Create: `src/Module/Directory/Infrastructure/ApiPlatform/State/ReportDocumentProcessor.php` - Create: `src/Module/Directory/Infrastructure/Controller/ReportDocumentDownloadController.php` - Create: `src/Module/Directory/Infrastructure/EventListener/ReportDocumentListener.php` - Modify: `config/services.yaml` (injecter `task_document_upload_dir` dans le processor/controller/listener) **Interfaces:** - Consumes: `CommercialReport` (Task 4), `UserInterface`, paramètre Symfony `task_document_upload_dir`. - Produces: `ReportDocument` (ApiResource `/report_documents`, groupes `report_document:read`/`report_document:write`), endpoint download `/api/report_documents/{id}/download`. - [ ] **Step 1 : Créer l'entité `ReportDocument`** ```php ['report_document:read']], denormalizationContext: ['groups' => ['report_document:write']], order: ['id' => 'DESC'], )] #[ApiFilter(SearchFilter::class, properties: ['commercialReport' => 'exact'])] #[ORM\Entity] #[ORM\Table(name: 'report_document')] #[ORM\EntityListeners([ReportDocumentListener::class])] class ReportDocument { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] #[Groups(['report_document:read', 'commercial_report:read'])] private ?int $id = null; #[ORM\ManyToOne(targetEntity: CommercialReport::class, inversedBy: 'documents')] #[ORM\JoinColumn(name: 'commercial_report_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] #[Groups(['report_document:read', 'report_document:write'])] private ?CommercialReport $commercialReport = null; #[ORM\Column(length: 255)] #[Groups(['report_document:read', 'commercial_report:read'])] private ?string $originalName = null; #[ORM\Column(length: 255, nullable: true)] #[Groups(['report_document:read', 'commercial_report:read'])] private ?string $fileName = null; #[ORM\Column(length: 100)] #[Groups(['report_document:read', 'commercial_report:read'])] private ?string $mimeType = null; #[ORM\Column] #[Groups(['report_document:read', 'commercial_report:read'])] private ?int $size = null; #[ORM\Column(type: 'datetime_immutable')] #[Groups(['report_document:read', 'commercial_report:read'])] private ?DateTimeImmutable $createdAt = null; #[ORM\ManyToOne(targetEntity: UserInterface::class)] #[ORM\JoinColumn(name: 'uploaded_by_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] #[Groups(['report_document:read', 'commercial_report:read'])] private ?UserInterface $uploadedBy = null; public function getId(): ?int { return $this->id; } public function getCommercialReport(): ?CommercialReport { return $this->commercialReport; } public function setCommercialReport(?CommercialReport $commercialReport): static { $this->commercialReport = $commercialReport; return $this; } public function getOriginalName(): ?string { return $this->originalName; } public function setOriginalName(string $originalName): static { $this->originalName = $originalName; return $this; } public function getFileName(): ?string { return $this->fileName; } public function setFileName(?string $fileName): static { $this->fileName = $fileName; return $this; } public function getMimeType(): ?string { return $this->mimeType; } public function setMimeType(string $mimeType): static { $this->mimeType = $mimeType; return $this; } public function getSize(): ?int { return $this->size; } public function setSize(int $size): static { $this->size = $size; return $this; } public function getCreatedAt(): ?DateTimeImmutable { return $this->createdAt; } public function setCreatedAt(DateTimeImmutable $createdAt): static { $this->createdAt = $createdAt; return $this; } public function getUploadedBy(): ?UserInterface { return $this->uploadedBy; } public function setUploadedBy(?UserInterface $uploadedBy): static { $this->uploadedBy = $uploadedBy; return $this; } } ``` - [ ] **Step 2 : Créer l'interface + le repository Doctrine** `src/Module/Directory/Domain/Repository/ReportDocumentRepositoryInterface.php` : ```php */ final class DoctrineReportDocumentRepository extends ServiceEntityRepository implements ReportDocumentRepositoryInterface { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, ReportDocument::class); } public function findById(int $id): ?ReportDocument { return $this->find($id); } } ``` - [ ] **Step 3 : Créer le processor d'upload** (calqué sur `TaskDocumentProcessor`, sans SMB) `src/Module/Directory/Infrastructure/ApiPlatform/State/ReportDocumentProcessor.php` : ```php */ final readonly class ReportDocumentProcessor implements ProcessorInterface { private const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB private const ALLOWED_MIME_TYPES = [ 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'text/plain', 'text/csv', 'application/zip', 'application/x-rar-compressed', 'application/gzip', 'application/json', 'application/xml', 'text/xml', ]; private const MIME_TO_EXTENSION = [ 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp', 'application/pdf' => 'pdf', 'application/msword' => 'doc', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx', 'application/vnd.ms-excel' => 'xls', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx', 'application/vnd.ms-powerpoint' => 'ppt', 'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx', 'text/plain' => 'txt', 'text/csv' => 'csv', 'application/zip' => 'zip', 'application/x-rar-compressed' => 'rar', 'application/gzip' => 'gz', 'application/json' => 'json', 'application/xml' => 'xml', 'text/xml' => 'xml', ]; public function __construct( private EntityManagerInterface $entityManager, private Security $security, private RequestStack $requestStack, private string $uploadDir, ) {} /** * @param ReportDocument $data */ public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ReportDocument { if (!$this->security->isGranted('ROLE_ADMIN')) { throw new AccessDeniedHttpException('Creating report documents requires admin privileges.'); } $request = $this->requestStack->getCurrentRequest(); if (null === $request) { throw new BadRequestHttpException('No request available.'); } $document = $this->createUpload($request); $document->setCreatedAt(new DateTimeImmutable()); $document->setUploadedBy($this->security->getUser()); $this->entityManager->persist($document); $this->entityManager->flush(); return $document; } private function createUpload(Request $request): ReportDocument { $file = $request->files->get('file'); if (null === $file || !$file->isValid()) { throw new BadRequestHttpException('No valid file uploaded.'); } if ($file->getSize() > self::MAX_FILE_SIZE) { throw new BadRequestHttpException('File size exceeds 50 MB limit.'); } $report = $this->resolveReport((string) $request->request->get('commercialReport', '')); $originalName = $file->getClientOriginalName(); $mimeType = $file->getMimeType() ?: 'application/octet-stream'; $fileSize = $file->getSize(); if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)) { throw new BadRequestHttpException(sprintf('File type "%s" is not allowed.', $mimeType)); } $extension = self::MIME_TO_EXTENSION[$mimeType] ?? 'bin'; $fileName = Uuid::v4()->toRfc4122().'.'.$extension; if (!is_dir($this->uploadDir)) { mkdir($this->uploadDir, 0o775, true); } $file->move($this->uploadDir, $fileName); $document = new ReportDocument(); $document->setCommercialReport($report); $document->setOriginalName($originalName); $document->setFileName($fileName); $document->setMimeType($mimeType); $document->setSize($fileSize); return $document; } private function resolveReport(string $iri): CommercialReport { if ('' === $iri) { throw new BadRequestHttpException('A commercialReport IRI is required.'); } $report = $this->entityManager->getRepository(CommercialReport::class)->find((int) basename($iri)); if (null === $report) { throw new BadRequestHttpException('Commercial report not found.'); } return $report; } } ``` - [ ] **Step 4 : Créer le controller de download** (calqué sur `TaskDocumentDownloadController`, fichier disque uniquement) `src/Module/Directory/Infrastructure/Controller/ReportDocumentDownloadController.php` : ```php repository->findById($id); if (null === $document || null === $document->getFileName()) { throw new NotFoundHttpException('Document not found.'); } $filePath = $this->uploadDir.'/'.$document->getFileName(); if (!is_file($filePath)) { throw new NotFoundHttpException('File missing on disk.'); } $response = new BinaryFileResponse($filePath); $mimeType = (string) $document->getMimeType(); $disposition = in_array($mimeType, self::INLINE_MIME_TYPES, true) ? ResponseHeaderBag::DISPOSITION_INLINE : ResponseHeaderBag::DISPOSITION_ATTACHMENT; $response->setContentDisposition($disposition, (string) $document->getOriginalName()); $response->headers->set('Content-Type', $mimeType); $response->headers->set('X-Content-Type-Options', 'nosniff'); return $response; } } ``` - [ ] **Step 5 : Créer le listener de suppression** `src/Module/Directory/Infrastructure/EventListener/ReportDocumentListener.php` : ```php getFileName(); if (null === $fileName) { return; } $path = $this->uploadDir.'/'.$fileName; if (is_file($path) && !@unlink($path)) { $this->logger->warning('Failed to delete report document file', ['path' => $path]); } } } ``` - [ ] **Step 6 : Injecter le paramètre `task_document_upload_dir`** Dans `config/services.yaml`, ajouter (à la suite des autres définitions explicites de services) : ```yaml 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%' tags: ['controller.service_arguments'] App\Module\Directory\Infrastructure\EventListener\ReportDocumentListener: arguments: $uploadDir: '%task_document_upload_dir%' tags: - { name: doctrine.orm.entity_listener, entity: 'App\Module\Directory\Domain\Entity\ReportDocument', event: preRemove } ``` > Vérifier d'abord comment `TaskDocumentProcessor`/`TaskDocumentListener` reçoivent `$uploadDir` et sont tagués (`grep -n "task_document_upload_dir\|TaskDocumentListener" config/services.yaml`) et reproduire exactement le même style (autowiring partiel vs définition explicite). - [ ] **Step 7 : Valider le mapping Doctrine de tout le module** Run: `docker exec -i php-lesstime-fpm php bin/console doctrine:schema:validate --skip-sync` Expected: `[Mapping] OK.` - [ ] **Step 8 : Commit** ```bash git add src/Module/Directory/Domain/Entity/ReportDocument.php src/Module/Directory/Domain/Repository/ReportDocumentRepositoryInterface.php src/Module/Directory/Infrastructure/Doctrine/DoctrineReportDocumentRepository.php src/Module/Directory/Infrastructure/ApiPlatform/State/ReportDocumentProcessor.php src/Module/Directory/Infrastructure/Controller/ReportDocumentDownloadController.php src/Module/Directory/Infrastructure/EventListener/ReportDocumentListener.php config/services.yaml git commit -m "feat(directory) : add ReportDocument storage (entity, upload processor, download, cleanup)" ``` --- ## Task 6 : Migration — tables + data migration adresse inline **Files:** - Create: `migrations/Version2026XXXXXXXXXX.php` (généré puis édité) - Modify: `src/Module/Directory/Domain/Entity/Client.php` (retrait colonnes inline `street`/`city`/`postalCode`) - Modify: `src/Module/Directory/Domain/Entity/Prospect.php` (idem) **Interfaces:** - Consumes: les 4 entités des Tasks 2–5. - Produces: tables `directory_contact`, `directory_address`, `commercial_report`, `report_document` ; contraintes CHECK d'appartenance ; suppression des colonnes inline d'adresse sur `client`/`prospect` (données migrées vers `directory_address`). - [ ] **Step 1 : Retirer les colonnes inline d'adresse de `Client`** Dans `src/Module/Directory/Domain/Entity/Client.php`, **supprimer** les 3 propriétés `street`, `city`, `postalCode` (lignes 61–71 actuelles) **et** leurs getters/setters (`getStreet/setStreet/getCity/setCity/getPostalCode/setPostalCode`). Conserver `name`, `email`, `phone`, `projects`. - [ ] **Step 2 : Retirer les colonnes inline d'adresse de `Prospect`** Dans `src/Module/Directory/Domain/Entity/Prospect.php`, **supprimer** les propriétés `street`, `city`, `postalCode` (lignes 74–84 actuelles) et leurs getters/setters. Conserver `name`, `company`, `email`, `phone`, `status`, `source`, `notes`, `convertedClient`. - [ ] **Step 3 : Générer le diff de migration** Run: `docker exec -i php-lesstime-fpm php bin/console doctrine:migrations:diff --no-interaction` Expected: un fichier `migrations/Version2026XXXXXXXXXX.php` créé contenant les `CREATE TABLE` des 4 nouvelles tables, les FK, et les `ALTER TABLE client/prospect DROP COLUMN street/city/postal_code`. - [ ] **Step 4 : Éditer la migration — ajouter le CHECK et la data migration** Ouvrir le fichier généré. Dans `up()`, **avant** les `DROP COLUMN` sur `client`/`prospect`, insérer la copie des adresses inline existantes vers `directory_address`, et **après** les `CREATE TABLE`, ajouter les contraintes CHECK. Le corps de `up()` doit contenir (ordre important) : ```php public function up(Schema $schema): void { // 1. CREATE TABLE directory_contact / directory_address / commercial_report / report_document // + FK + index : conserver tel quel le SQL généré par le diff. // (ne pas recopier ici : garder les addSql() générés) // 2. Contraintes CHECK d'appartenance (au moins un propriétaire). $this->addSql("ALTER TABLE directory_contact ADD CONSTRAINT chk_contact_owner CHECK (client_id IS NOT NULL OR prospect_id IS NOT NULL)"); $this->addSql("ALTER TABLE directory_address ADD CONSTRAINT chk_address_owner CHECK (client_id IS NOT NULL OR prospect_id IS NOT NULL)"); $this->addSql("ALTER TABLE commercial_report ADD CONSTRAINT chk_report_owner CHECK (client_id IS NOT NULL OR prospect_id IS NOT NULL)"); // 3. Data migration : adresse inline -> directory_address (avant DROP COLUMN). $this->addSql("INSERT INTO directory_address (label, street, postal_code, city, country, client_id, created_at, updated_at) SELECT 'Principale', street, postal_code, city, 'FR', id, NOW(), NOW() FROM client WHERE COALESCE(street, '') <> '' OR COALESCE(city, '') <> '' OR COALESCE(postal_code, '') <> ''"); $this->addSql("INSERT INTO directory_address (label, street, postal_code, city, country, prospect_id, created_at, updated_at) SELECT 'Principale', street, postal_code, city, 'FR', id, NOW(), NOW() FROM prospect WHERE COALESCE(street, '') <> '' OR COALESCE(city, '') <> '' OR COALESCE(postal_code, '') <> ''"); // 4. DROP COLUMN street/city/postal_code sur client et prospect : conserver le SQL généré. } ``` > ⚠️ Vérifier les noms de colonnes Timestampable réels sur `directory_address` après le diff (le trait peut générer `created_at`/`updated_at`/`created_by_id`/`updated_by_id`) et adapter la liste de colonnes de l'`INSERT` en conséquence. `created_by_id`/`updated_by_id` étant nullable, ne pas les inclure. Colonnes en **minuscules**. > Dans `down()`, ajouter en premier les `ALTER TABLE client/prospect ADD street/city/postal_code` (recréation) générés par le diff, puis les `DROP TABLE`. La perte de données au `down()` est acceptable (dev). - [ ] **Step 5 : Appliquer la migration** Run: `docker exec -i php-lesstime-fpm php bin/console doctrine:migrations:migrate --no-interaction` Expected: migration exécutée sans erreur ; `doctrine:schema:validate` → `[Mapping] OK` **et** `[Database] OK`. - [ ] **Step 6 : Vérifier les tables et le CHECK** Run: ```bash docker exec -i php-lesstime-fpm php bin/console dbal:run-sql "SELECT conname FROM pg_constraint WHERE conname LIKE 'chk_%owner'" ``` Expected: `chk_contact_owner`, `chk_address_owner`, `chk_report_owner`. - [ ] **Step 7 : Commit** ```bash git add migrations/Version2026XXXXXXXXXX.php src/Module/Directory/Domain/Entity/Client.php src/Module/Directory/Domain/Entity/Prospect.php git commit -m "feat(directory) : add contact/address/report tables and migrate inline addresses" ``` --- ## Task 7 : Conversion prospect → client réaffecte les sous-entités **Files:** - Modify: `src/Module/Directory/Infrastructure/ApiPlatform/State/ConvertProspectProcessor.php` - Test: `tests/Module/Directory/ConvertProspectProcessorTest.php` **Interfaces:** - Consumes: `Contact`, `Address`, `CommercialReport` (Tasks 2–4), repositories Doctrine. - Produces: après convert, les contacts/adresses/rapports du prospect ont `client = ` et `prospect = null`. - [ ] **Step 1 : Écrire le test fonctionnel qui échoue** ```php get(EntityManagerInterface::class); $prospect = new Prospect(); $prospect->setName('Atelier Test'); $prospect->setCompany('Atelier Test SARL'); $em->persist($prospect); $contact = (new Contact())->setLastName('Durand')->setProspect($prospect); $address = (new Address())->setCity('Niort')->setProspect($prospect); $report = (new CommercialReport()) ->setSubject('Premier contact') ->setOccurredAt(new DateTimeImmutable('2026-06-01')) ->setType(ReportType::Call) ->setProspect($prospect); $em->persist($contact); $em->persist($address); $em->persist($report); $em->flush(); $processor = self::getContainer()->get(\App\Module\Directory\Infrastructure\ApiPlatform\State\ConvertProspectProcessor::class); $operation = new \ApiPlatform\Metadata\Post(); $processor->process($prospect, $operation, ['id' => $prospect->getId()]); $em->refresh($contact); $em->refresh($address); $em->refresh($report); $client = $prospect->getConvertedClient(); self::assertNotNull($client, 'Prospect should be converted to a client'); // Invariants (pas de counts absolus) : chaque sous-entité pointe vers le client, plus vers le prospect. self::assertSame($client->getId(), $contact->getClient()?->getId()); self::assertNull($contact->getProspect()); self::assertSame($client->getId(), $address->getClient()?->getId()); self::assertNull($address->getProspect()); self::assertSame($client->getId(), $report->getClient()?->getId()); self::assertNull($report->getProspect()); } } ``` - [ ] **Step 2 : Lancer le test, vérifier l'échec** Run: `docker exec -i php-lesstime-fpm php bin/phpunit --filter ConvertProspectProcessorTest` Expected: FAIL (le processor ne réaffecte pas encore ; `getClient()` est null). - [ ] **Step 3 : Modifier le processor** Remplacer le contenu de `ConvertProspectProcessor.php` par : ```php */ final readonly class ConvertProspectProcessor implements ProcessorInterface { public function __construct( private EntityManagerInterface $entityManager, private ProspectRepositoryInterface $prospectRepository, ) {} public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Prospect { $id = $uriVariables['id'] ?? null; $prospect = is_numeric($id) ? $this->prospectRepository->findById((int) $id) : null; if (!$prospect instanceof Prospect) { throw new NotFoundHttpException('Prospect not found.'); } if (null !== $prospect->getConvertedClient()) { return $prospect; } $client = new Client(); $client->setName($prospect->getCompany() ?: (string) $prospect->getName()); $client->setEmail($prospect->getEmail()); $client->setPhone($prospect->getPhone()); $this->entityManager->persist($client); $this->reassignContacts($prospect, $client); $this->reassignAddresses($prospect, $client); $this->reassignReports($prospect, $client); $prospect->setConvertedClient($client); $prospect->setStatus(ProspectStatus::Won); $this->entityManager->flush(); return $prospect; } private function reassignContacts(Prospect $prospect, Client $client): void { foreach ($this->entityManager->getRepository(Contact::class)->findBy(['prospect' => $prospect]) as $contact) { $contact->setClient($client); $contact->setProspect(null); } } private function reassignAddresses(Prospect $prospect, Client $client): void { foreach ($this->entityManager->getRepository(Address::class)->findBy(['prospect' => $prospect]) as $address) { $address->setClient($client); $address->setProspect(null); } } private function reassignReports(Prospect $prospect, Client $client): void { foreach ($this->entityManager->getRepository(CommercialReport::class)->findBy(['prospect' => $prospect]) as $report) { $report->setClient($client); $report->setProspect(null); } } } ``` > Note : la copie des champs adresse inline (`street`/`city`/`postalCode`) est retirée du processor (colonnes supprimées en Task 6). Les adresses suivent désormais via `reassignAddresses`. - [ ] **Step 4 : Lancer le test, vérifier le succès** Run: `docker exec -i php-lesstime-fpm php bin/phpunit --filter ConvertProspectProcessorTest` Expected: PASS. - [ ] **Step 5 : Commit** ```bash git add src/Module/Directory/Infrastructure/ApiPlatform/State/ConvertProspectProcessor.php tests/Module/Directory/ConvertProspectProcessorTest.php git commit -m "feat(directory) : carry over contacts/addresses/reports on prospect conversion" ``` --- ## Task 8 : Permissions RBAC du module **Files:** - Modify: `src/Module/Directory/DirectoryModule.php` - Test: `tests/Module/Directory/DirectoryModulePermissionsTest.php` **Interfaces:** - Produces: `DirectoryModule::permissions()` inclut `directory.reports.view` et `directory.reports.manage`. - [ ] **Step 1 : Écrire le test qui échoue** ```php 'directory.reports.view', 'label' => 'Voir les comptes-rendus commerciaux'], ['code' => 'directory.reports.manage', 'label' => 'Gérer les comptes-rendus commerciaux'], ``` - [ ] **Step 4 : Lancer le test, vérifier le succès** Run: `docker exec -i php-lesstime-fpm php bin/phpunit --filter DirectoryModulePermissionsTest` Expected: PASS. - [ ] **Step 5 : Commit** ```bash git add src/Module/Directory/DirectoryModule.php tests/Module/Directory/DirectoryModulePermissionsTest.php git commit -m "feat(directory) : declare commercial report RBAC permissions" ``` --- ## Task 9 : Tests API d'appartenance et de sécurité **Files:** - Test: `tests/Module/Directory/CommercialReportApiTest.php` **Interfaces:** - Consumes: endpoints `/api/commercial_reports`, `/api/contacts`, fixtures users (`admin`/`admin`, `alice`/`alice`). > Réutiliser le pattern de test fonctionnel API existant du projet (`grep -rl "ApiTestCase\|createClient" tests/` pour trouver la classe de base et la façon de s'authentifier en JWT cookie). Adapter le helper d'auth ci-dessous au helper réel du projet. - [ ] **Step 1 : Écrire les tests (invariants, pas de counts)** ```php request('GET', '/api/commercial_reports'); self::assertGreaterThanOrEqual(401, $client->getResponse()->getStatusCode()); } public function testNonAdminCannotCreateReport(): void { $client = self::createClient(); // TODO(remplacer) : authentifier en ROLE_USER via le helper JWT du projet (cf. autres tests API). $this->loginAs($client, 'alice', 'alice'); $target = $this->anyClientIri(); $client->request('POST', '/api/commercial_reports', server: [ 'CONTENT_TYPE' => 'application/ld+json', ], content: json_encode([ 'subject' => 'Test', 'occurredAt' => '2026-06-01', 'type' => 'call', 'client' => $target, ])); self::assertSame(403, $client->getResponse()->getStatusCode()); } private function anyClientIri(): string { /** @var EntityManagerInterface $em */ $em = self::getContainer()->get(EntityManagerInterface::class); $client = $em->getRepository(Client::class)->findOneBy([]); self::assertNotNull($client, 'A client fixture is required'); return '/api/clients/'.$client->getId(); } // loginAs(): à implémenter selon le helper d'auth JWT cookie du projet. } ``` - [ ] **Step 2 : Adapter `loginAs` au helper réel** (lire un test API existant et reproduire l'auth JWT cookie). - [ ] **Step 3 : Lancer les tests** Run: `docker exec -i php-lesstime-fpm php bin/phpunit --filter CommercialReportApiTest` Expected: PASS (anonyme rejeté, ROLE_USER interdit en écriture). - [ ] **Step 4 : Lancer la suite complète** Run: `make test` Expected: suite verte. - [ ] **Step 5 : Commit** ```bash git add tests/Module/Directory/CommercialReportApiTest.php git commit -m "test(directory) : api security for commercial reports" ``` --- ## Task 10 : DTO frontend **Files:** - Create: `frontend/modules/directory/services/dto/contact.ts` - Create: `frontend/modules/directory/services/dto/address.ts` - Create: `frontend/modules/directory/services/dto/commercial-report.ts` - Create: `frontend/modules/directory/services/dto/report-document.ts` **Interfaces:** - Produces: types `Contact`/`ContactWrite`, `Address`/`AddressWrite`, `CommercialReport`/`CommercialReportWrite`, `ReportType`, `ReportDocument`. - [ ] **Step 1 : `contact.ts`** ```ts 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 } ``` - [ ] **Step 2 : `address.ts`** ```ts 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 } ``` - [ ] **Step 3 : `report-document.ts`** ```ts import type { UserData } from '~/services/dto/user' export type ReportDocument = { '@id'?: string id: number commercialReport: string originalName: string fileName?: string | null mimeType: string size: number createdAt: string uploadedBy?: UserData | null } ``` > Vérifier le chemin réel du type `UserData` (`grep -rn "export type UserData\|export interface UserData" frontend`) et ajuster l'import. À défaut, remplacer par `{ id: number, username: string } | null`. - [ ] **Step 4 : `commercial-report.ts`** ```ts 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 } ``` - [ ] **Step 5 : Vérifier la compilation TS** Run: `cd frontend && npx nuxi typecheck` (ou `npm run typecheck` si défini ; sinon `npx vue-tsc --noEmit`). Expected: pas d'erreur sur les nouveaux fichiers. - [ ] **Step 6 : Commit** ```bash git add frontend/modules/directory/services/dto/ git commit -m "feat(directory) : add frontend DTOs for contacts, addresses, reports" ``` --- ## Task 11 : Services frontend **Files:** - Create: `frontend/modules/directory/services/contacts.ts` - Create: `frontend/modules/directory/services/addresses.ts` - Create: `frontend/modules/directory/services/commercial-reports.ts` - Create: `frontend/modules/directory/services/report-documents.ts` **Interfaces:** - Consumes: DTO de Task 10, `useApi()`, `extractHydraMembers`. - Produces: `useContactService`, `useAddressService`, `useCommercialReportService`, `useReportDocumentService` (CRUD + filtre par owner). - [ ] **Step 1 : `contacts.ts`** ```ts 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 { const data = await api.get>('/contacts', owner as Record) return extractHydraMembers(data) } async function create(payload: ContactWrite): Promise { return api.post('/contacts', payload as Record, { toastSuccessKey: 'directory.contacts.saved', }) } async function update(id: number, payload: Partial): Promise { return api.patch(`/contacts/${id}`, payload as Record, { toastSuccessKey: 'directory.contacts.saved', }) } async function remove(id: number): Promise { await api.delete(`/contacts/${id}`, {}, { toastSuccessKey: 'directory.contacts.deleted' }) } return { getByOwner, create, update, remove } } ``` - [ ] **Step 2 : `addresses.ts`** (même structure, ressource `/addresses`, clés i18n `directory.addresses.*`) ```ts 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 { const data = await api.get>('/addresses', owner as Record) return extractHydraMembers(data) } async function create(payload: AddressWrite): Promise
{ return api.post
('/addresses', payload as Record, { toastSuccessKey: 'directory.addresses.saved', }) } async function update(id: number, payload: Partial): Promise
{ return api.patch
(`/addresses/${id}`, payload as Record, { toastSuccessKey: 'directory.addresses.saved', }) } async function remove(id: number): Promise { await api.delete(`/addresses/${id}`, {}, { toastSuccessKey: 'directory.addresses.deleted' }) } return { getByOwner, create, update, remove } } ``` - [ ] **Step 3 : `commercial-reports.ts`** ```ts 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 { const data = await api.get>('/commercial_reports', owner as Record) return extractHydraMembers(data) } async function create(payload: CommercialReportWrite): Promise { return api.post('/commercial_reports', payload as Record, { toastSuccessKey: 'directory.reports.saved', }) } async function update(id: number, payload: Partial): Promise { return api.patch(`/commercial_reports/${id}`, payload as Record, { toastSuccessKey: 'directory.reports.saved', }) } async function remove(id: number): Promise { await api.delete(`/commercial_reports/${id}`, {}, { toastSuccessKey: 'directory.reports.deleted' }) } return { getByOwner, create, update, remove } } ``` - [ ] **Step 4 : `report-documents.ts`** (upload multipart + download URL, calqué sur `task-documents.ts`) ```ts 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 { const data = await api.get>('/report_documents', { commercialReport: `/api/commercial_reports/${reportId}`, }) return extractHydraMembers(data) } async function upload(reportId: number, file: File): Promise { const formData = new FormData() formData.append('file', file) formData.append('commercialReport', `/api/commercial_reports/${reportId}`) return $fetch(`${baseURL}/report_documents`, { method: 'POST', body: formData, credentials: 'include', }) } async function remove(id: number): Promise { 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 } } ``` - [ ] **Step 5 : Vérifier la compilation TS** — `cd frontend && npx nuxi typecheck`. Expected: pas d'erreur. - [ ] **Step 6 : Commit** ```bash git add frontend/modules/directory/services/ git commit -m "feat(directory) : add frontend services for contacts, addresses, reports, documents" ``` --- ## Task 12 : Composant `DirectoryContactBlock` **Files:** - Create: `frontend/modules/directory/components/DirectoryContactBlock.vue` **Interfaces:** - Consumes: type `Contact` (Task 10), composants `@malio/layer-ui` (`MalioInputText`, `MalioInputEmail`, `MalioButtonIcon`). - Produces: bloc d'édition d'un contact ; props `modelValue: Contact`, `title: string`, `removable?: boolean`, `readonly?: boolean` ; events `update:modelValue`, `remove`. > Avant d'écrire : ouvrir `frontend/node_modules/@malio/layer-ui/COMPONENTS.md` pour confirmer les noms de props des inputs (`label`, `model-value`/`v-model`, `readonly`, `error`). Reproduire le style des drawers existants (`ProspectDrawer.vue`). - [ ] **Step 1 : Écrire le composant** ```vue ``` - [ ] **Step 2 : Vérifier le rendu** — `make dev-nuxt`, importer le composant dans une page de test ou attendre Task 16. Vérifier l'absence d'erreur console au build : `cd frontend && npx nuxi typecheck`. - [ ] **Step 3 : Commit** ```bash git add frontend/modules/directory/components/DirectoryContactBlock.vue git commit -m "feat(directory) : add repeatable contact block component" ``` --- ## Task 13 : Composant `DirectoryAddressBlock` **Files:** - Create: `frontend/modules/directory/components/DirectoryAddressBlock.vue` **Interfaces:** - Produces: bloc d'édition d'une adresse ; mêmes props/events que `DirectoryContactBlock` mais `modelValue: Address`. - [ ] **Step 1 : Écrire le composant** ```vue ``` - [ ] **Step 2 : Vérifier la compilation** — `cd frontend && npx nuxi typecheck`. Expected: pas d'erreur. - [ ] **Step 3 : Commit** ```bash git add frontend/modules/directory/components/DirectoryAddressBlock.vue git commit -m "feat(directory) : add repeatable address block component" ``` --- ## Task 14 : Composants documents de rapport (`ReportDocumentUpload`, `ReportDocumentList`) **Files:** - Create: `frontend/modules/directory/components/ReportDocumentUpload.vue` - Create: `frontend/modules/directory/components/ReportDocumentList.vue` **Interfaces:** - Consumes: `useReportDocumentService` (Task 11), `ReportDocument` DTO. - Produces: `ReportDocumentUpload` (prop `reportId: number`, event `uploaded`), `ReportDocumentList` (prop `documents: ReportDocument[]`, `isAdmin: boolean`, event `delete`). > S'inspirer de `frontend/modules/project-management/components/TaskDocumentUpload.vue` et `TaskDocumentList.vue` (les lire d'abord). Version simplifiée : upload simple + liste avec lien download + bouton suppression. Pas de SMB, pas de preview avancé (lien `getDownloadUrl` ouvert dans un onglet). - [ ] **Step 1 : `ReportDocumentUpload.vue`** ```vue ``` - [ ] **Step 2 : `ReportDocumentList.vue`** ```vue ``` - [ ] **Step 3 : Vérifier la compilation** — `cd frontend && npx nuxi typecheck`. Expected: pas d'erreur. - [ ] **Step 4 : Commit** ```bash git add frontend/modules/directory/components/ReportDocumentUpload.vue frontend/modules/directory/components/ReportDocumentList.vue git commit -m "feat(directory) : add report document upload/list components" ``` --- ## Task 15 : Onglet Rapport (`CommercialReportTab`) **Files:** - Create: `frontend/modules/directory/components/CommercialReportTab.vue` **Interfaces:** - Consumes: `useCommercialReportService` (Task 11), `ReportDocumentUpload`/`ReportDocumentList` (Task 14), composants Malio (`MalioInputText`, `MalioInputTextArea`, `MalioSelect`, `MalioDate`, `MalioButton`). - Produces: onglet complet ; prop `owner: { client?: string, prospect?: string }`, `isAdmin: boolean`. Gère liste + formulaire d'ajout/édition + documents. - [ ] **Step 1 : Écrire le composant** ```vue ``` > Confirmer les props réelles de `MalioDate`, `MalioInputTextArea`, `MalioSelect` dans `COMPONENTS.md` (notamment `v-model` vs `model-value`/`@update:model-value`, et que `MalioSelect` accepte une valeur `string` — cf. CLAUDE.md). Ajuster si nécessaire. - [ ] **Step 2 : Vérifier la compilation** — `cd frontend && npx nuxi typecheck`. Expected: pas d'erreur. - [ ] **Step 3 : Commit** ```bash git add frontend/modules/directory/components/CommercialReportTab.vue git commit -m "feat(directory) : add commercial report tab (list, form, documents)" ``` --- ## Task 16 : Fiche détail Client **Files:** - Create: `frontend/modules/directory/pages/clients/[id].vue` **Interfaces:** - Consumes: `useClientService`, `useContactService`, `useAddressService`, blocs Task 12/13, `CommercialReportTab` (Task 15), `MalioTabList`. - Produces: page `/directory/clients/:id` ; onglets Contact/Adresse/Rapport ; owner = `{ client: '/api/clients/:id' }`. > `useClientService` n'a pas `getById` aujourd'hui (`services/clients.ts`). Ajouter une méthode `getById(id)` au service clients (calquée sur prospects.ts) dans cette tâche. - [ ] **Step 1 : Ajouter `getById` au service clients** Dans `frontend/modules/directory/services/clients.ts`, ajouter à l'intérieur de `useClientService` (et l'exposer dans le `return`) : ```ts async function getById(id: number): Promise { return api.get(`/clients/${id}`) } ``` - [ ] **Step 2 : Écrire la page** ```vue ``` > Décision UX explicite : la sauvegarde par bloc se fait au `@update:model-value` (à la frappe). Si ça génère trop d'appels, ajouter un debounce ou un bouton « Enregistrer » par bloc dans une itération ultérieure (hors périmètre de ce plan). - [ ] **Step 3 : Vérifier le routage et le rendu** Run: `make dev-nuxt` puis naviguer vers `/directory/clients/1`. Expected: page chargée, 3 onglets visibles, ajout d'un contact persiste (vérifier via `GET /api/contacts?client=/api/clients/1`). - [ ] **Step 4 : Commit** ```bash git add frontend/modules/directory/pages/clients/ frontend/modules/directory/services/clients.ts git commit -m "feat(directory) : add client detail page with contact/address/report tabs" ``` --- ## Task 17 : Fiche détail Prospect **Files:** - Create: `frontend/modules/directory/pages/prospects/[id].vue` **Interfaces:** - Identique à Task 16 mais owner = `{ prospect: '/api/prospects/:id' }`, service `useProspectService().getById` (déjà présent). - [ ] **Step 1 : Écrire la page** Reprendre **intégralement** la page de Task 16 (`clients/[id].vue`) en remplaçant : - `useClientService` → `useProspectService` ; `clientService.getById` → `prospectService.getById` ; type `Client` → `Prospect` (import `~/modules/directory/services/dto/prospect`). - `const ownerIri = '/api/prospects/${id}'` ; `const owner = { prospect: ownerIri }`. - Dans `emptyContact`/`emptyAddress` et les payloads : remplacer `client: ownerIri` par `prospect: ownerIri`. - `getByOwner({ client: ownerIri })` → `getByOwner({ prospect: ownerIri })` (contacts et adresses). Code complet : ```vue ``` - [ ] **Step 2 : Vérifier le rendu** — `/directory/prospects/1`. Expected: 3 onglets, ajout de contact persiste. - [ ] **Step 3 : Commit** ```bash git add frontend/modules/directory/pages/prospects/ git commit -m "feat(directory) : add prospect detail page with contact/address/report tabs" ``` --- ## Task 18 : Navigation liste → fiche détail **Files:** - Modify: `frontend/modules/directory/pages/directory.vue` **Interfaces:** - Consumes: pages détail Task 16/17. - Produces: clic sur une ligne client/prospect → navigation vers la fiche détail (au lieu d'ouvrir le drawer). Le drawer reste pour la création (`openCreateClient`/`openCreateProspect`). - [ ] **Step 1 : Modifier les handlers de clic** Dans `frontend/modules/directory/pages/directory.vue`, remplacer `openEditClient` et `openEditProspect` par une navigation : ```ts function openEditClient(item: Record) { navigateTo(`/directory/clients/${(item as Client).id}`) } function openEditProspect(item: Record) { navigateTo(`/directory/prospects/${(item as Prospect).id}`) } ``` Laisser `selectedClient`/`selectedProspect` et les drawers en place **uniquement** pour la création (`openCreateClient`/`openCreateProspect` inchangés). Retirer `selectedClient.value = item` des anciens handlers. - [ ] **Step 2 : Vérifier** Run: `make dev-nuxt` ; sur `/directory`, cliquer une ligne client → arrive sur `/directory/clients/:id` ; le bouton « Ajouter » ouvre toujours le drawer. - [ ] **Step 3 : Commit** ```bash git add frontend/modules/directory/pages/directory.vue git commit -m "feat(directory) : open detail page on row click, keep drawer for creation" ``` --- ## Task 19 : Traductions i18n **Files:** - Modify: `frontend/i18n/locales/fr.json` **Interfaces:** - Produces: toutes les clés `directory.tabs.{contact,address,report}`, `directory.contacts.*`, `directory.addresses.*`, `directory.reports.*`, `directory.documents.*`, et les clés `common.*` utilisées (`back`, `loading`, `edit`, `delete`, `save`, `cancel`). - [ ] **Step 1 : Ajouter les clés sous `directory`** Dans `frontend/i18n/locales/fr.json`, étendre l'objet `directory` (fusionner avec l'existant `directory.title`/`directory.tabs.clients`/...) : ```json "directory": { "tabs": { "contact": "Contact", "address": "Adresse", "report": "Rapport" }, "contacts": { "add": "Ajouter un contact", "item": "Contact {n}", "saved": "Contact enregistré.", "deleted": "Contact supprimé.", "fields": { "lastName": "Nom", "firstName": "Prénom", "jobTitle": "Fonction", "email": "Email", "phonePrimary": "Téléphone", "phoneSecondary": "Téléphone secondaire" } }, "addresses": { "add": "Ajouter une adresse", "item": "Adresse {n}", "saved": "Adresse enregistrée.", "deleted": "Adresse supprimée.", "fields": { "label": "Libellé", "street": "Rue", "streetComplement": "Complément", "postalCode": "Code postal", "city": "Ville" } }, "reports": { "add": "Ajouter un compte-rendu", "empty": "Aucun compte-rendu.", "saved": "Compte-rendu enregistré.", "deleted": "Compte-rendu supprimé.", "fields": { "subject": "Objet", "type": "Type d'échange", "occurredAt": "Date", "body": "Compte-rendu" }, "types": { "call": "Appel", "meeting": "Rendez-vous", "email": "Email", "note": "Note" } }, "documents": { "add": "Joindre un document", "uploading": "Envoi…", "empty": "Aucun document.", "deleted": "Document supprimé." } } ``` > ⚠️ Préserver les clés `directory` existantes (`title`, `tabs.clients`, `tabs.prospects`, `clients.*`, `prospects.*`). Fusionner, ne pas écraser. `tabs` reçoit `contact`/`address`/`report` **en plus** de `clients`/`prospects`. - [ ] **Step 2 : Ajouter les clés `common` manquantes** (si absentes — vérifier d'abord `grep '"common"' frontend/i18n/locales/fr.json`) ```json "common": { "back": "Retour", "loading": "Chargement…", "edit": "Modifier", "delete": "Supprimer", "save": "Enregistrer", "cancel": "Annuler" } ``` - [ ] **Step 3 : Vérifier le JSON** Run: `docker exec -i php-lesstime-fpm node -e "require('./frontend/i18n/locales/fr.json')"` (ou `python3 -m json.tool frontend/i18n/locales/fr.json > /dev/null`). Expected: pas d'erreur de parsing. - [ ] **Step 4 : Vérifier l'absence de clés manquantes en UI** — `make dev-nuxt`, parcourir les fiches : aucun libellé brut `directory.xxx` affiché. - [ ] **Step 5 : Commit** ```bash git add frontend/i18n/locales/fr.json git commit -m "feat(directory) : add i18n keys for contacts, addresses, reports tabs" ``` --- ## Task 20 : Vérification de bout en bout **Files:** aucun (vérification manuelle + suite de tests). - [ ] **Step 1 : Suite backend complète** Run: `make test` Expected: vert. - [ ] **Step 2 : Style PHP** Run: `make php-cs-fixer-allow-risky` Expected: fichiers conformes (commit si des corrections sont appliquées). - [ ] **Step 3 : Parcours fonctionnel** Avec `make dev-nuxt` et un compte `admin/admin` : 1. `/directory` → cliquer un client → fiche détail. 2. Onglet Contact : ajouter 2 contacts, recharger la page → les 2 sont présents. 3. Onglet Adresse : ajouter 1 adresse → présente après reload. 4. Onglet Rapport : créer un compte-rendu (type « Appel »), joindre un PDF → document listé et téléchargeable. 5. Sur un prospect avec contacts/adresses/rapports → « Convertir en client » → ouvrir le client créé → contacts/adresses/rapports présents sur le client, absents du prospect. - [ ] **Step 4 : Commit de clôture éventuel** (si php-cs-fixer a modifié des fichiers) ```bash git add -A git commit -m "style(directory) : apply php-cs-fixer" ``` --- ## Notes d'exécution - **Ordre des tâches :** 1 → 9 (backend) avant 10 → 19 (frontend) ; Task 5 doit précéder la migration (Task 6) car `CommercialReport` référence `ReportDocument`. - **Points à confirmer en cours de route** (signalés inline) : style d'injection des services dans `services.yaml` (copier le pattern `TaskDocument*`), props exactes des composants Malio (`COMPONENTS.md`), chemin du type `UserData`, helper d'auth JWT des tests API. - **Décision UX assumée :** sauvegarde par bloc à la frappe (Contact/Adresse). Debounce/bouton explicite = itération ultérieure si besoin.