Files
Lesstime/docs/superpowers/plans/2026-06-22-directory-commercial-reports.md
T
Matthieu 1589908e4c
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 41s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 58s
docs : add directory commercial reports spec and implementation plan
2026-06-22 14:17:12 +02:00

114 KiB
Raw Blame History

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.phpunlink fichier au preRemove.
  • Infrastructure/ApiPlatform/State/ConvertProspectProcessor.phpmodifié (réaffectation).
  • DirectoryModule.phpmodifié (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.vuemodifié (navigation vers fiche détail).
  • frontend/i18n/locales/fr.jsonmodifié.

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

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

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

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

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

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

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

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

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
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 45 ci-dessous), et n'a volontairement pas de groupe :write (non modifiable par le client).

  • Step 1 : Créer l'entité
<?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

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

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

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
    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)
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

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

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

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

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

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

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) :

    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
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 25.

  • 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 6171 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 7484 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) :

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:

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
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 24), 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

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

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

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é :

            ['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
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

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

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

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.*)
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
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)
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 TScd frontend && npx nuxi typecheck. Expected: pas d'erreur.

  • Step 6 : Commit

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
<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 rendumake 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

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

<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 compilationcd frontend && npx nuxi typecheck. Expected: pas d'erreur.

  • Step 3 : Commit

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
<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
<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 compilationcd frontend && npx nuxi typecheck. Expected: pas d'erreur.

  • Step 4 : Commit

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

<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 compilationcd frontend && npx nuxi typecheck. Expected: pas d'erreur.

  • Step 3 : Commit

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) :

    async function getById(id: number): Promise<Client> {
        return api.get<Client>(`/clients/${id}`)
    }
  • Step 2 : Écrire la page
<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
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 :

  • useClientServiceuseProspectService ; clientService.getByIdprospectService.getById ; type ClientProspect (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 :

<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

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 :

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
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/...) :

"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)
"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 UImake dev-nuxt, parcourir les fiches : aucun libellé brut directory.xxx affiché.

  • Step 5 : Commit

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)
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.