Files
Lesstime/docs/superpowers/plans/2026-06-22-directory-commercial-reports.md
T
matthieu 8313c759c6
Auto Tag Develop / tag (push) Successful in 9s
Migration modular monolith DDD (0.1 → 3.3) (#17)
## Migration modular monolith DDD — Lesstime (0.1 → 3.3)

Cette MR regroupe l'intégralité de la refonte en monolithe modulaire (strangler progressif, additif). Elle remplace les MR stackées de Phase 1 (#12–#16), désormais incluses ici.

**Ne pas merger avant validation fonctionnelle** : branche destinée à être testée telle quelle.

### Périmètre — 9 modules sous `src/Module/`
| Phase | Module | Contenu |
|------|--------|---------|
| 0.1 | (socle) | infrastructure modulaire, `ModuleInterface`, mapping Doctrine par module |
| 0.2 | (socle front) | auto-détection des layers Nuxt sous `frontend/modules/*` |
| 1.1 | **Core** | Identité (User/Auth), Notifications, Notifier |
| 1.2 | Core | RBAC fin (permissions `module.resource.action`, sidebar gated) |
| 1.3 | Core | Audit log (`#[Auditable]`, listener, provider DBAL) |
| 2.1 | **TimeTracking** | TimeEntry + MCP + export |
| 2.2 | **ProjectManagement** | cœur métier Projets/Tâches + 38 MCP tools |
| 2.3 | **Absence** | demandes, soldes, policies, justificatifs |
| 2.4 | **Directory** | Clients (migrés) + **Prospects** (nouveau, conversion → Client) |
| 2.5 | **Mail** | intégration IMAP OVH + liens tâches |
| 2.6 | **Integration** | Gitea / BookStack / Zimbra / Share |
| 3.1 | **Reporting** | rapports transverses (DBAL read-only, 0 import inter-module) |
| 3.2 | **ClientPortal** | portail client (ROLE_CLIENT cloisonné, tickets, notifications) |
| 3.3 | (finition) | nettoyage legacy — `src/Entity` vide, app 100% modulaire |

### Architecture
- Découplage inter-modules par **contrats** (`UserInterface`, `ProjectInterface`, `TaskInterface`, `TaskTagInterface`, `ClientInterface`, `ClientTicketInterface`, `LeaveProfileInterface`) + `resolve_target_entities` 100% modulaire (aucune cible legacy).
- Repositories : interface `Domain/Repository` + implémentation `Infrastructure/Doctrine`, bindées.
- Reporting en DBAL read-only pur (aucun import d'entité d'un autre module).
- Chaque migration de module : déplacement à comportement préservé (API publique et noms d'outils MCP inchangés), migrations **additives** uniquement (zéro destructif).

### Sécurité
- ROLE_CLIENT cloisonné : un utilisateur client n'accède qu'à `/portal` et à ses propres tickets (filtrés par `allowedProjects`), interdit sur toute l'API interne.
- Correctif : interdiction pour un client de créer un lien vers le partage SMB (upload uniquement).

### QA non-régression (branche reconstruite from scratch)
- Migrations from scratch + fixtures : OK.
- Compilation dev + prod : OK.
- **180 tests PHPUnit verts**, php-cs-fixer clean, ~96 routes, **66 outils MCP** tous sous `App\Module\*`.
- Smoke test runtime multi-rôles (admin / ROLE_USER / ROLE_CLIENT) : 44 vérifications HTTP, **0 écart**, cloisonnement client étanche.
- Build Nuxt OK, 9 layers, 0 import legacy résiduel.

### Points à arbitrer (hors périmètre de cette migration)
- Durcissement MCP/IDOR pré-existant (`userId` explicite sans scoping sur certains tools TimeTracking/Absence/TaskDocument) — ticket dédié recommandé.
- Validation fonctionnelle de **Prospect** et **ClientPortal** (conçus depuis les specs disque).
- **Harmonisation visuelle Malio finale** (3.3) — finition esthétique inter-modules laissée au PO.

---

## ⚠️ Déploiement / migration des données — à ne pas oublier

### 1. Resynchroniser les séquences PostgreSQL après tout import/restore de dump
Si la prod (ou tout environnement) est **montée depuis un dump** (`pg_restore` / `COPY`), les lignes sont chargées avec leurs `id` explicites **sans avancer les séquences** → au premier `INSERT` : `duplicate key value violates unique constraint "..._pkey"` (constaté en local sur `notification`, `task`, `time_entry`…).

À lancer **juste après chaque restore/import** :

```sql
DO $$
DECLARE r RECORD; maxid BIGINT; seq TEXT;
BEGIN
  FOR r IN SELECT table_name, column_name FROM information_schema.columns WHERE table_schema='public'
  LOOP
    seq := pg_get_serial_sequence(quote_ident(r.table_name), r.column_name);
    IF seq IS NOT NULL THEN
      EXECUTE format('SELECT COALESCE(MAX(%I),0) FROM %I', r.column_name, r.table_name) INTO maxid;
      PERFORM setval(seq, GREATEST(maxid,1), maxid > 0);
    END IF;
  END LOOP;
END $$;
```

> Ne concerne **pas** une prod qui tourne déjà (séquences avancées organiquement) — uniquement le cas restore/import. Idempotent, sans risque.

### 2. Fix dénormalisation des collections typées-contrat (code, inclus dans la branche)
Les relations **to-many** typées par une interface `Shared\Domain\Contract\*` (`TimeEntry::tags` → `TaskTagInterface`, `Task::collaborators` → `UserInterface`) étaient **indénormalisables par API Platform** (mono-valué OK via IRI, collection KO) → **tout POST/PATCH portant une telle collection renvoyait 400/500**. Corrigé par un dénormaliseur générique `ContractRelationDenormalizer` (réutilise `resolve_target_entities`, zéro couplage par-entité) + test fonctionnel de non-régression.

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #17
2026-06-23 13:50:42 +00:00

3307 lines
114 KiB
Markdown
Raw Blame History

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