114 KiB
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: 1sur la route. - Sécurité API : lecture
is_granted('ROLE_USER'), écritureis_granted('ROLE_ADMIN')(pattern du moduleDirectory). - 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 contratsApp\Shared\Domain\Contract\*(ex.UserInterface). - Sérialisation : pour embarquer une relation (et non un IRI), ajouter le groupe
*:readdu parent sur les propriétés de l'entité cible. - Upload : valider le MIME via
$file->getMimeType()(serveur), pasgetClientMimeType(). Max 50 MB. Réutiliser le paramètre Symfonytask_document_upload_dir. - Frontend : TypeScript strict, 4 espaces d'indentation ; tous les appels API via
useApi(); collections lues viaextractHydraMembersdoivent être servies non paginées (paginationEnabled: false). - i18n : toutes les chaînes UI dans
frontend/i18n/locales/fr.json(pas de texte en dur). - Tests :
make test(PHPUnit). Base de test non isolée (les POST s'accumulent) → tester des invariants (relations, statuts, présence), jamais des counts absolus. - Commits :
<type>(<scope>) : <message>(espace autour du:). Aucune mention d'IA/Claude. Ne pas tagger/pusher. - Commandes utiles :
make migration-migrate(migrations),make php-cs-fixer-allow-risky(style PHP),make dev-nuxt(front hot reload),make shell(shell PHP). Test ciblé :docker exec -i php-lesstime-fpm php bin/phpunit --filter <TestName>.
File Structure
Backend — src/Module/Directory/
Domain/Enum/ReportType.php— enum type d'échange (call/meeting/email/note) + libellés FR.Domain/Entity/Contact.php— contact répétable (double-FK client/prospect).Domain/Entity/Address.php— adresse répétable (double-FK).Domain/Entity/CommercialReport.php— compte-rendu (double-FK) + OneToMany ReportDocument.Domain/Entity/ReportDocument.php— pièce jointe d'un compte-rendu.Domain/Repository/{Contact,Address,CommercialReport,ReportDocument}RepositoryInterface.php.Infrastructure/Doctrine/Doctrine{Contact,Address,CommercialReport,ReportDocument}Repository.php.Infrastructure/ApiPlatform/State/ReportDocumentProcessor.php— upload multipart.Infrastructure/Controller/ReportDocumentDownloadController.php— download.Infrastructure/EventListener/ReportDocumentListener.php—unlinkfichier aupreRemove.Infrastructure/ApiPlatform/State/ConvertProspectProcessor.php— modifié (réaffectation).DirectoryModule.php— modifié (permissions reports).
Backend — racine
migrations/Version2026XXXXXXXXXX.php— tables + data migration adresse inline.tests/Module/Directory/...— tests fonctionnels.
Frontend — frontend/modules/directory/
services/dto/{contact,address,commercial-report,report-document}.ts.services/{contacts,addresses,commercial-reports,report-documents}.ts.components/DirectoryContactBlock.vue,DirectoryAddressBlock.vue.components/CommercialReportTab.vue,ReportDocumentUpload.vue,ReportDocumentList.vue.pages/clients/[id].vue,pages/prospects/[id].vue.pages/directory.vue— modifié (navigation vers fiche détail).frontend/i18n/locales/fr.json— modifié.
Task 1 : Enum ReportType
Files:
- Create:
src/Module/Directory/Domain/Enum/ReportType.php - Test:
tests/Module/Directory/Domain/Enum/ReportTypeTest.php
Interfaces:
-
Produces:
enum ReportType: string { Call='call'; Meeting='meeting'; Email='email'; Note='note'; public function label(): string } -
Step 1 : Écrire le test qui échoue
<?php
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, groupescontact:read/contact:write, double ManyToOneclient/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:validatemapping) ; 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, groupesaddress: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éReportDocumentest créée en Task 5, qui doit être appliquée avant la migration Task 6). - Produces:
CommercialReport(ApiResource/commercial_reports, groupescommercial_report:read/commercial_report:write),CommercialReportRepositoryInterface::findById(int): ?CommercialReport.authorrenseigné automatiquement auprePersistavec 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. ⚠️authorn'est PAS alimenté par le trait Blamable (qui gèrecreated_by). Il est rempli par un listener dédié (Steps 4–5 ci-dessous), et n'a volontairement pas de groupe:write(non modifiable par le client).
- Step 1 : Créer l'entité
<?php
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 partietagsest nécessaire (vérifier le blocservices:par défaut deconfig/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(injectertask_document_upload_dirdans le processor/controller/listener)
Interfaces:
-
Consumes:
CommercialReport(Task 4),UserInterface, paramètre Symfonytask_document_upload_dir. -
Produces:
ReportDocument(ApiResource/report_documents, groupesreport_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/TaskDocumentListenerreçoivent$uploadDiret 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 inlinestreet/city/postalCode) - Modify:
src/Module/Directory/Domain/Entity/Prospect.php(idem)
Interfaces:
-
Consumes: les 4 entités des Tasks 2–5.
-
Produces: tables
directory_contact,directory_address,commercial_report,report_document; contraintes CHECK d'appartenance ; suppression des colonnes inline d'adresse surclient/prospect(données migrées versdirectory_address). -
Step 1 : Retirer les colonnes inline d'adresse de
Client
Dans src/Module/Directory/Domain/Entity/Client.php, supprimer les 3 propriétés street, city, postalCode (lignes 61–71 actuelles) et leurs getters/setters (getStreet/setStreet/getCity/setCity/getPostalCode/setPostalCode). Conserver name, email, phone, projects.
- Step 2 : Retirer les colonnes inline d'adresse de
Prospect
Dans src/Module/Directory/Domain/Entity/Prospect.php, supprimer les propriétés street, city, postalCode (lignes 74–84 actuelles) et leurs getters/setters. Conserver name, company, email, phone, status, source, notes, convertedClient.
- Step 3 : Générer le diff de migration
Run: docker exec -i php-lesstime-fpm php bin/console doctrine:migrations:diff --no-interaction
Expected: un fichier migrations/Version2026XXXXXXXXXX.php créé contenant les CREATE TABLE des 4 nouvelles tables, les FK, et les ALTER TABLE client/prospect DROP COLUMN street/city/postal_code.
- Step 4 : Éditer la migration — ajouter le CHECK et la data migration
Ouvrir le fichier généré. Dans up(), avant les DROP COLUMN sur client/prospect, insérer la copie des adresses inline existantes vers directory_address, et après les CREATE TABLE, ajouter les contraintes CHECK. Le corps de up() doit contenir (ordre important) :
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_addressaprès le diff (le trait peut générercreated_at/updated_at/created_by_id/updated_by_id) et adapter la liste de colonnes de l'INSERTen conséquence.created_by_id/updated_by_idétant nullable, ne pas les inclure. Colonnes en minuscules. Dansdown(), ajouter en premier lesALTER TABLE client/prospect ADD street/city/postal_code(recréation) générés par le diff, puis lesDROP TABLE. La perte de données audown()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 2–4), repositories Doctrine. -
Produces: après convert, les contacts/adresses/rapports du prospect ont
client = <nouveau client>etprospect = 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 viareassignAddresses.
- 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()inclutdirectory.reports.viewetdirectory.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
loginAsau 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 i18ndirectory.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é surtask-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 TS —
cd 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; eventsupdate:modelValue,remove.
Avant d'écrire : ouvrir
frontend/node_modules/@malio/layer-ui/COMPONENTS.mdpour 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 rendu —
make dev-nuxt, importer le composant dans une page de test ou attendre Task 16. Vérifier l'absence d'erreur console au build :cd frontend && npx nuxi typecheck. -
Step 3 : Commit
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
DirectoryContactBlockmaismodelValue: 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 compilation —
cd 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),ReportDocumentDTO. - Produces:
ReportDocumentUpload(propreportId: number, eventuploaded),ReportDocumentList(propdocuments: ReportDocument[],isAdmin: boolean, eventdelete).
S'inspirer de
frontend/modules/project-management/components/TaskDocumentUpload.vueetTaskDocumentList.vue(les lire d'abord). Version simplifiée : upload simple + liste avec lien download + bouton suppression. Pas de SMB, pas de preview avancé (liengetDownloadUrlouvert 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 compilation —
cd 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,MalioSelectdansCOMPONENTS.md(notammentv-modelvsmodel-value/@update:model-value, et queMalioSelectaccepte une valeurstring— cf. CLAUDE.md). Ajuster si nécessaire.
-
Step 2 : Vérifier la compilation —
cd frontend && npx nuxi typecheck. Expected: pas d'erreur. -
Step 3 : Commit
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' }.
useClientServicen'a pasgetByIdaujourd'hui (services/clients.ts). Ajouter une méthodegetById(id)au service clients (calquée sur prospects.ts) dans cette tâche.
- Step 1 : Ajouter
getByIdau 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' }, serviceuseProspectService().getById(déjà présent). -
Step 1 : Écrire la page
Reprendre intégralement la page de Task 16 (clients/[id].vue) en remplaçant :
useClientService→useProspectService;clientService.getById→prospectService.getById; typeClient→Prospect(import~/modules/directory/services/dto/prospect).const ownerIri = '/api/prospects/${id}';const owner = { prospect: ownerIri }.- Dans
emptyContact/emptyAddresset les payloads : remplacerclient: ownerIriparprospect: 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éscommon.*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
directoryexistantes (title,tabs.clients,tabs.prospects,clients.*,prospects.*). Fusionner, ne pas écraser.tabsreçoitcontact/address/reporten plus declients/prospects.
- Step 2 : Ajouter les clés
commonmanquantes (si absentes — vérifier d'abordgrep '"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 UI —
make dev-nuxt, parcourir les fiches : aucun libellé brutdirectory.xxxaffiché. -
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 :
/directory→ cliquer un client → fiche détail.- Onglet Contact : ajouter 2 contacts, recharger la page → les 2 sont présents.
- Onglet Adresse : ajouter 1 adresse → présente après reload.
- Onglet Rapport : créer un compte-rendu (type « Appel »), joindre un PDF → document listé et téléchargeable.
- 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
CommercialReportréférenceReportDocument. - Points à confirmer en cours de route (signalés inline) : style d'injection des services dans
services.yaml(copier le patternTaskDocument*), props exactes des composants Malio (COMPONENTS.md), chemin du typeUserData, 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.