# 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 :** `<type>(<scope>) : <message>` (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 <TestName>`.

## 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
<?php

declare(strict_types=1);

namespace App\Tests\Module\Directory\Domain\Enum;

use App\Module\Directory\Domain\Enum\ReportType;
use PHPUnit\Framework\TestCase;

final class ReportTypeTest extends TestCase
{
    public function testValuesAndLabels(): void
    {
        self::assertSame('call', ReportType::Call->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
<?php

declare(strict_types=1);

namespace App\Module\Directory\Domain\Enum;

enum ReportType: string
{
    case Call    = 'call';
    case Meeting = 'meeting';
    case Email   = 'email';
    case Note    = 'note';

    public function label(): string
    {
        return match ($this) {
            self::Call    => '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
<?php

declare(strict_types=1);

namespace App\Module\Directory\Domain\Entity;

use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Directory\Infrastructure\Doctrine\DoctrineContactRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;

#[Auditable]
#[ApiResource(
    operations: [
        new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
        new Get(security: "is_granted('ROLE_USER')"),
        new Post(security: "is_granted('ROLE_ADMIN')"),
        new Patch(security: "is_granted('ROLE_ADMIN')"),
        new Delete(security: "is_granted('ROLE_ADMIN')"),
    ],
    normalizationContext: ['groups' => ['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
<?php

declare(strict_types=1);

namespace App\Module\Directory\Domain\Repository;

use App\Module\Directory\Domain\Entity\Contact;

interface ContactRepositoryInterface
{
    public function findById(int $id): ?Contact;
}
```

- [ ] **Step 3 : Créer le repository Doctrine**

```php
<?php

declare(strict_types=1);

namespace App\Module\Directory\Infrastructure\Doctrine;

use App\Module\Directory\Domain\Entity\Contact;
use App\Module\Directory\Domain\Repository\ContactRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @extends ServiceEntityRepository<Contact>
 */
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
<?php

declare(strict_types=1);

namespace App\Module\Directory\Domain\Entity;

use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Directory\Infrastructure\Doctrine\DoctrineAddressRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;

#[Auditable]
#[ApiResource(
    operations: [
        new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
        new Get(security: "is_granted('ROLE_USER')"),
        new Post(security: "is_granted('ROLE_ADMIN')"),
        new Patch(security: "is_granted('ROLE_ADMIN')"),
        new Delete(security: "is_granted('ROLE_ADMIN')"),
    ],
    normalizationContext: ['groups' => ['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
<?php

declare(strict_types=1);

namespace App\Module\Directory\Domain\Repository;

use App\Module\Directory\Domain\Entity\Address;

interface AddressRepositoryInterface
{
    public function findById(int $id): ?Address;
}
```

- [ ] **Step 3 : Créer le repository Doctrine**

```php
<?php

declare(strict_types=1);

namespace App\Module\Directory\Infrastructure\Doctrine;

use App\Module\Directory\Domain\Entity\Address;
use App\Module\Directory\Domain\Repository\AddressRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @extends ServiceEntityRepository<Address>
 */
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
<?php

declare(strict_types=1);

namespace App\Module\Directory\Domain\Entity;

use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Directory\Domain\Enum\ReportType;
use App\Module\Directory\Infrastructure\Doctrine\DoctrineCommercialReportRepository;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Contract\UserInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource(
    operations: [
        new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
        new Get(security: "is_granted('ROLE_USER')"),
        new Post(security: "is_granted('ROLE_ADMIN')"),
        new Patch(security: "is_granted('ROLE_ADMIN')"),
        new Delete(security: "is_granted('ROLE_ADMIN')"),
    ],
    normalizationContext: ['groups' => ['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<int, ReportDocument> */
    #[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<int, ReportDocument> */
    public function getDocuments(): Collection
    {
        return $this->documents;
    }
}
```

- [ ] **Step 2 : Créer l'interface de repository**

```php
<?php

declare(strict_types=1);

namespace App\Module\Directory\Domain\Repository;

use App\Module\Directory\Domain\Entity\CommercialReport;

interface CommercialReportRepositoryInterface
{
    public function findById(int $id): ?CommercialReport;
}
```

- [ ] **Step 3 : Créer le repository Doctrine**

```php
<?php

declare(strict_types=1);

namespace App\Module\Directory\Infrastructure\Doctrine;

use App\Module\Directory\Domain\Entity\CommercialReport;
use App\Module\Directory\Domain\Repository\CommercialReportRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @extends ServiceEntityRepository<CommercialReport>
 */
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
<?php

declare(strict_types=1);

namespace App\Module\Directory\Infrastructure\EventListener;

use App\Module\Directory\Domain\Entity\CommercialReport;
use App\Shared\Domain\Contract\UserInterface;
use Doctrine\ORM\Event\PrePersistEventArgs;
use Symfony\Bundle\SecurityBundle\Security;

final readonly class CommercialReportAuthorListener
{
    public function __construct(private Security $security) {}

    public function prePersist(CommercialReport $report, PrePersistEventArgs $args): void
    {
        if (null !== $report->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
<?php

declare(strict_types=1);

namespace App\Module\Directory\Domain\Entity;

use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use App\Module\Directory\Infrastructure\ApiPlatform\State\ReportDocumentProcessor;
use App\Module\Directory\Infrastructure\EventListener\ReportDocumentListener;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource(
    operations: [
        new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
        new Get(security: "is_granted('ROLE_USER')"),
        new Post(
            security: "is_granted('ROLE_ADMIN')",
            processor: ReportDocumentProcessor::class,
            deserialize: false,
        ),
        new Delete(security: "is_granted('ROLE_ADMIN')"),
    ],
    normalizationContext: ['groups' => ['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
<?php

declare(strict_types=1);

namespace App\Module\Directory\Domain\Repository;

use App\Module\Directory\Domain\Entity\ReportDocument;

interface ReportDocumentRepositoryInterface
{
    public function findById(int $id): ?ReportDocument;
}
```

`src/Module/Directory/Infrastructure/Doctrine/DoctrineReportDocumentRepository.php` :

```php
<?php

declare(strict_types=1);

namespace App\Module\Directory\Infrastructure\Doctrine;

use App\Module\Directory\Domain\Entity\ReportDocument;
use App\Module\Directory\Domain\Repository\ReportDocumentRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @extends ServiceEntityRepository<ReportDocument>
 */
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
<?php

declare(strict_types=1);

namespace App\Module\Directory\Infrastructure\ApiPlatform\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Directory\Domain\Entity\CommercialReport;
use App\Module\Directory\Domain\Entity\ReportDocument;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Uid\Uuid;

use function in_array;

/**
 * @implements ProcessorInterface<ReportDocument, ReportDocument>
 */
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
<?php

declare(strict_types=1);

namespace App\Module\Directory\Infrastructure\Controller;

use App\Module\Directory\Domain\Repository\ReportDocumentRepositoryInterface;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

final class ReportDocumentDownloadController
{
    private const INLINE_MIME_TYPES = [
        'image/jpeg', 'image/png', 'image/gif', 'image/webp',
        'application/pdf',
    ];

    public function __construct(
        private readonly ReportDocumentRepositoryInterface $repository,
        private readonly string $uploadDir,
    ) {}

    #[Route('/api/report_documents/{id}/download', name: 'report_document_download', methods: ['GET'], priority: 1)]
    #[IsGranted('IS_AUTHENTICATED_FULLY')]
    public function __invoke(int $id): Response
    {
        $document = $this->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
<?php

declare(strict_types=1);

namespace App\Module\Directory\Infrastructure\EventListener;

use App\Module\Directory\Domain\Entity\ReportDocument;
use Doctrine\ORM\Event\PreRemoveEventArgs;
use Psr\Log\LoggerInterface;

final readonly class ReportDocumentListener
{
    public function __construct(
        private string $uploadDir,
        private LoggerInterface $logger,
    ) {}

    public function preRemove(ReportDocument $document, PreRemoveEventArgs $args): void
    {
        $fileName = $document->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 = <nouveau client>` et `prospect = null`.

- [ ] **Step 1 : Écrire le test fonctionnel qui échoue**

```php
<?php

declare(strict_types=1);

namespace App\Tests\Module\Directory;

use App\Module\Directory\Domain\Entity\Address;
use App\Module\Directory\Domain\Entity\CommercialReport;
use App\Module\Directory\Domain\Entity\Contact;
use App\Module\Directory\Domain\Entity\Prospect;
use App\Module\Directory\Domain\Enum\ReportType;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

final class ConvertProspectProcessorTest extends KernelTestCase
{
    public function testConvertReassignsContactsAddressesAndReports(): void
    {
        self::bootKernel();
        /** @var EntityManagerInterface $em */
        $em = self::getContainer()->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
<?php

declare(strict_types=1);

namespace App\Module\Directory\Infrastructure\ApiPlatform\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Directory\Domain\Entity\Address;
use App\Module\Directory\Domain\Entity\Client;
use App\Module\Directory\Domain\Entity\CommercialReport;
use App\Module\Directory\Domain\Entity\Contact;
use App\Module\Directory\Domain\Entity\Prospect;
use App\Module\Directory\Domain\Enum\ProspectStatus;
use App\Module\Directory\Domain\Repository\ProspectRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

/**
 * Converts a Prospect into a Client and reassigns its contacts, addresses and
 * commercial reports to the new client (preserving the commercial history).
 *
 * Idempotent: if already converted, returns it unchanged.
 *
 * @implements ProcessorInterface<Prospect, Prospect>
 */
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
<?php

declare(strict_types=1);

namespace App\Tests\Module\Directory;

use App\Module\Directory\DirectoryModule;
use PHPUnit\Framework\TestCase;

final class DirectoryModulePermissionsTest extends TestCase
{
    public function testReportPermissionsAreDeclared(): void
    {
        $codes = array_column(DirectoryModule::permissions(), 'code');

        self::assertContains('directory.reports.view', $codes);
        self::assertContains('directory.reports.manage', $codes);
    }
}
```

- [ ] **Step 2 : Lancer le test, vérifier l'échec**

Run: `docker exec -i php-lesstime-fpm php bin/phpunit --filter DirectoryModulePermissionsTest`
Expected: FAIL.

- [ ] **Step 3 : Ajouter les permissions**

Dans `DirectoryModule::permissions()`, ajouter au tableau retourné :

```php
            ['code' => '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
<?php

declare(strict_types=1);

namespace App\Tests\Module\Directory;

use App\Module\Directory\Domain\Entity\Client;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class CommercialReportApiTest extends WebTestCase
{
    public function testAnonymousCannotReadReports(): void
    {
        $client = self::createClient();
        $client->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<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 }
}
```

- [ ] **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<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 }
}
```

- [ ] **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<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 }
}
```

- [ ] **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<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 }
}
```

- [ ] **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
<template>
    <div class="relative grid grid-cols-2 gap-x-8 gap-y-3 rounded bg-white p-4 shadow">
        <h3 class="col-span-2 text-sm font-semibold text-neutral-700">
            {{ title }}
        </h3>
        <MalioButtonIcon
            v-if="removable && !readonly"
            icon="mdi:trash-can-outline"
            class="absolute right-2 top-2"
            button-class="!text-red-600"
            :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>
```

- [ ] **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
<template>
    <div class="relative grid grid-cols-2 gap-x-8 gap-y-3 rounded bg-white p-4 shadow">
        <h3 class="col-span-2 text-sm font-semibold text-neutral-700">
            {{ title }}
        </h3>
        <MalioButtonIcon
            v-if="removable && !readonly"
            icon="mdi:trash-can-outline"
            class="absolute right-2 top-2"
            button-class="!text-red-600"
            :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>
```

- [ ] **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
<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>
```

- [ ] **Step 2 : `ReportDocumentList.vue`**

```vue
<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>
```

- [ ] **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
<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-8 gap-y-3 rounded bg-white p-4 shadow">
            <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" :aria-label="$t('common.edit')" @click="edit(report)" />
                    <MalioButtonIcon icon="mdi:trash-can-outline" button-class="!text-red-600" :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>
```

> 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<Client> {
        return api.get<Client>(`/clients/${id}`)
    }
```

- [ ] **Step 2 : Écrire la page**

```vue
<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)"
                        />
                        <MalioButton
                            icon-name="mdi:plus"
                            icon-position="left"
                            button-class="w-auto px-4"
                            :label="$t('directory.contacts.add')"
                            @click="addContact"
                        />
                    </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)"
                        />
                        <MalioButton
                            icon-name="mdi:plus"
                            icon-position="left"
                            button-class="w-auto px-4"
                            :label="$t('directory.addresses.add')"
                            @click="addAddress"
                        />
                    </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 type { Contact } from '~/modules/directory/services/dto/contact'
import type { Address } from '~/modules/directory/services/dto/address'
import { useClientService } from '~/modules/directory/services/clients'
import { useContactService } from '~/modules/directory/services/contacts'
import { useAddressService } from '~/modules/directory/services/addresses'

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 contactService = useContactService()
const addressService = useAddressService()

const client = ref<Client | null>(null)
const contacts = ref<Contact[]>([])
const addresses = ref<Address[]>([])
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 emptyContact(): Contact {
    return { id: 0, firstName: null, lastName: null, jobTitle: null, email: null, phonePrimary: null, phoneSecondary: null, client: ownerIri }
}
function emptyAddress(): Address {
    return { id: 0, label: null, street: null, streetComplement: null, postalCode: null, city: null, country: 'FR', client: ownerIri }
}

async function onContactInput(index: number, value: Contact): Promise<void> {
    contacts.value[index] = value
    await persistContact(index)
}
async function persistContact(index: number): Promise<void> {
    const c = contacts.value[index]
    const payload = { firstName: c.firstName, lastName: c.lastName, jobTitle: c.jobTitle, email: c.email, phonePrimary: c.phonePrimary, phoneSecondary: c.phoneSecondary, client: ownerIri }
    if (c.id && c.id > 0) {
        await contactService.update(c.id, payload)
    } else if (c.lastName || c.firstName) {
        const created = await contactService.create(payload)
        contacts.value[index] = created
    }
}
function addContact(): void {
    contacts.value.push(emptyContact())
}
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 onAddressInput(index: number, value: Address): Promise<void> {
    addresses.value[index] = value
    await persistAddress(index)
}
async function persistAddress(index: number): Promise<void> {
    const a = addresses.value[index]
    const payload = { label: a.label, street: a.street, streetComplement: a.streetComplement, postalCode: a.postalCode, city: a.city, country: a.country, client: ownerIri }
    if (a.id && a.id > 0) {
        await addressService.update(a.id, payload)
    } else if (a.street || a.city || a.postalCode) {
        const created = await addressService.create(payload)
        addresses.value[index] = created
    }
}
function addAddress(): void {
    addresses.value.push(emptyAddress())
}
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)
}

function goBack(): void {
    router.push('/directory')
}

onMounted(async () => {
    client.value = await clientService.getById(id)
    contacts.value = await contactService.getByOwner({ client: ownerIri })
    addresses.value = await addressService.getByOwner({ client: ownerIri })
    loading.value = false
})
</script>
```

> 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
<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)"
                        />
                        <MalioButton
                            icon-name="mdi:plus"
                            icon-position="left"
                            button-class="w-auto px-4"
                            :label="$t('directory.contacts.add')"
                            @click="addContact"
                        />
                    </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)"
                        />
                        <MalioButton
                            icon-name="mdi:plus"
                            icon-position="left"
                            button-class="w-auto px-4"
                            :label="$t('directory.addresses.add')"
                            @click="addAddress"
                        />
                    </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 type { Contact } from '~/modules/directory/services/dto/contact'
import type { Address } from '~/modules/directory/services/dto/address'
import { useProspectService } from '~/modules/directory/services/prospects'
import { useContactService } from '~/modules/directory/services/contacts'
import { useAddressService } from '~/modules/directory/services/addresses'

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 contactService = useContactService()
const addressService = useAddressService()

const prospect = ref<Prospect | null>(null)
const contacts = ref<Contact[]>([])
const addresses = ref<Address[]>([])
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 emptyContact(): Contact {
    return { id: 0, firstName: null, lastName: null, jobTitle: null, email: null, phonePrimary: null, phoneSecondary: null, prospect: ownerIri }
}
function emptyAddress(): Address {
    return { id: 0, label: null, street: null, streetComplement: null, postalCode: null, city: null, country: 'FR', prospect: ownerIri }
}

async function onContactInput(index: number, value: Contact): Promise<void> {
    contacts.value[index] = value
    await persistContact(index)
}
async function persistContact(index: number): Promise<void> {
    const c = contacts.value[index]
    const payload = { firstName: c.firstName, lastName: c.lastName, jobTitle: c.jobTitle, email: c.email, phonePrimary: c.phonePrimary, phoneSecondary: c.phoneSecondary, prospect: ownerIri }
    if (c.id && c.id > 0) {
        await contactService.update(c.id, payload)
    } else if (c.lastName || c.firstName) {
        const created = await contactService.create(payload)
        contacts.value[index] = created
    }
}
function addContact(): void {
    contacts.value.push(emptyContact())
}
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 onAddressInput(index: number, value: Address): Promise<void> {
    addresses.value[index] = value
    await persistAddress(index)
}
async function persistAddress(index: number): Promise<void> {
    const a = addresses.value[index]
    const payload = { label: a.label, street: a.street, streetComplement: a.streetComplement, postalCode: a.postalCode, city: a.city, country: a.country, prospect: ownerIri }
    if (a.id && a.id > 0) {
        await addressService.update(a.id, payload)
    } else if (a.street || a.city || a.postalCode) {
        const created = await addressService.create(payload)
        addresses.value[index] = created
    }
}
function addAddress(): void {
    addresses.value.push(emptyAddress())
}
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)
}

function goBack(): void {
    router.push('/directory')
}

onMounted(async () => {
    prospect.value = await prospectService.getById(id)
    contacts.value = await contactService.getByOwner({ prospect: ownerIri })
    addresses.value = await addressService.getByOwner({ prospect: ownerIri })
    loading.value = false
})
</script>
```

- [ ] **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<string, unknown>) {
    navigateTo(`/directory/clients/${(item as Client).id}`)
}

function openEditProspect(item: Record<string, unknown>) {
    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.
