# Répertoire — Contacts, Adresses & Rapports commerciaux **Date :** 2026-06-22 **Module :** `Directory` (Lesstime) **Statut :** Conception validée — prêt pour plan d'implémentation ## Contexte & objectif Le module `Directory` gère aujourd'hui `Client` et `Prospect` de façon volontairement minimaliste : champs à plat (`name`, `email`, `phone`, `street`, `city`, `postalCode`), adresse *inline*, aucun contact individuel, aucun suivi commercial. Le CRUD se fait via des drawers sur une page unique `/directory` à deux onglets, sans fiche détail. On veut transformer chaque fiche client/prospect en une **vraie fiche détail à onglets**, inspirée du répertoire de Starseed (blocs répétables, sauvegarde indépendante par onglet, validation 422 inline), avec trois onglets : **Contact**, **Adresse**, **Rapport**. Le « rapport commercial » est un **journal de comptes-rendus** (objet + texte + date + type d'échange + auteur) auquel on peut **joindre des documents**. Décisions cadrées avec l'utilisateur : - Contacts et adresses : **plusieurs** par fiche (blocs répétables, façon Starseed). - UX : **fiche détail à route dédiée** (le clic sur une ligne ouvre la fiche, plus le drawer). - Rapport = **comptes-rendus** (objet + texte + date + type) **avec documents joints**. - Conversion prospect → client : **tout est repris** (contacts, adresses, rapports). - Cible : **Lesstime** (Starseed sert uniquement de référence de design). ## Approche retenue **Entités partagées via double-FK** : `Contact`, `Address`, `CommercialReport` sont chacune rattachées à **un `Client` OU un `Prospect`** via deux FK nullables (`client_id?`, `prospect_id?`) + une contrainte CHECK « exactly-one ». C'est le pattern **déjà employé par `task_document`** (`task_id` / `client_ticket_id` + CHECK `task_id IS NOT NULL OR client_ticket_id IS NOT NULL`) — on reste donc cohérent avec le code existant. La conversion prospect→client se réduit à une **réaffectation de FK** (pas de copie), ce qui préserve l'historique. Alternative écartée : entités dupliquées par propriétaire (`ClientContact` + `ProspectContact`, etc.) → 2× plus de tables/code et conversion par recopie. ## Modèle de données (backend — `src/Module/Directory`) Toutes les nouvelles entités vivent dans le module `Directory` (`Domain/Entity`, `Domain/Repository`, `Domain/Enum`, `Infrastructure/Doctrine`, `Infrastructure/ApiPlatform`), suivent les traits `TimestampableBlamableTrait` et sont `#[Auditable]` comme `Client`/`Prospect`. ### `Contact` (répétable) | Champ | Type | Notes | |-------|------|-------| | id | int PK | | | firstName | string? | | | lastName | string? | | | jobTitle | string? | fonction | | email | string? | lowercase | | phonePrimary | string? | | | phoneSecondary | string? | | | client | ManyToOne Client? | FK `client_id`, ON DELETE CASCADE | | prospect | ManyToOne Prospect? | FK `prospect_id`, ON DELETE CASCADE | Contrainte CHECK : `client_id IS NOT NULL OR prospect_id IS NOT NULL` (et au plus un des deux, garanti par la logique applicative + index). « Sans contrainte » fonctionnelle : un contact est valide dès qu'il a au moins un nom **ou** prénom (validation souple, façon `isContactNamed` de Starseed). ### `Address` (répétable) | Champ | Type | Notes | |-------|------|-------| | id | int PK | | | label | string? | libellé libre (« Siège », « Facturation »…) | | street | string? | | | streetComplement | string? | | | postalCode | string? | | | city | string? | | | country | string | défaut `FR` | | client / prospect | ManyToOne ?, FK CASCADE | double-FK + CHECK | ### `CommercialReport` (compte-rendu, répétable) | Champ | Type | Notes | |-------|------|-------| | id | int PK | | | subject | string | objet du compte-rendu | | body | text | le compte-rendu lui-même | | occurredAt | date | date de l'échange | | type | enum `ReportType` | `call` / `meeting` / `email` / `note` | | author | ManyToOne User? | rempli via Blamable (utilisateur connecté) | | documents | OneToMany ReportDocument | pièces jointes (voir section dédiée) | | client / prospect | ManyToOne ?, FK CASCADE | double-FK + CHECK | `ReportType` (enum, libellés FR) : Appel, Rendez-vous, Email, Note. ### Migration de l'adresse *inline* Les colonnes `street`, `city`, `postal_code` de `client` et `prospect` sont **migrées** vers une première ligne `Address` (data migration : pour chaque client/prospect ayant une adresse non vide, créer une `Address` rattachée), puis **supprimées** des tables `client`/`prospect` pour ne pas dédoubler la donnée. Les champs `name`, `email`, `phone` restent sur `Client`/`Prospect` (identité principale). ### Documents des comptes-rendus > **Correction post-exploration :** contrairement à une première hypothèse, `task_document` > n'a **aucune** colonne propriétaire générique. La migration `Version20260522110000` > (suppression du portail client) a **retiré** `client_ticket_id` de `task_document` et > restauré `task_id` en `NOT NULL`. Le `TaskDocumentProcessor` **exige** une tâche. > « Réutiliser TaskDocument » impose donc de le **généraliser** (FK + processor), ce qui > recouple `ProjectManagement` ↔ `Directory`. **Décision d'architecture (`ReportDocument` dédié — recommandé) :** créer une entité `ReportDocument` **propre au module `Directory`**, qui réutilise le **même mécanisme de stockage** (même paramètre `task_document_upload_dir`, mêmes validations MIME/taille, même stratégie de download `BinaryFileResponse`), mais **sans** la mécanique SMB (inutile pour des pièces jointes de compte-rendu). Cela préserve la frontière modulaire (pas de FK croisée `ProjectManagement` → `Directory`) au prix d'une duplication maîtrisée du processor et du controller de download (≈ 150 lignes, sans la partie SMB). Côté front, les composants de preview/list de `ProjectManagement` sont **génériques** et réutilisés tels quels (ils ne dépendent que du DTO document + de l'URL de download). Entité `ReportDocument` (module `Directory`) : `id`, `commercialReport` (ManyToOne, FK `commercial_report_id`, nullable:false, ON DELETE CASCADE), `originalName`, `fileName`, `mimeType`, `size`, `createdAt`, `uploadedBy` (ManyToOne User, SET NULL). Endpoint `POST /api/report_documents` (multipart, `deserialize:false`, `ReportDocumentProcessor`), `GET /api/report_documents/{id}/download` (controller dédié, `priority: 1`), `DELETE /api/report_documents/{id}` (listener `preRemove` qui `unlink` le fichier disque), `GetCollection` filtrable par `commercialReport`. ## API Platform Trois ressources (`Contact`, `Address`, `CommercialReport`) exposées avec : - Opérations : `GetCollection`, `Get`, `Post`, `Patch`, `Delete`. - Filtres : `SearchFilter` sur `client` et `prospect` (exact) pour charger la collection d'une fiche donnée. Collections non paginées (aligné sur `Client`/`Prospect`). - Sécurité : lecture `ROLE_USER`, écriture `ROLE_ADMIN` (pattern existant du module). - Groupes de sérialisation : `contact:read`/`contact:write`, `address:read`/`address:write`, `commercial_report:read`/`commercial_report:write`. `CommercialReport:read` embarque `author` (id + username) et `documents`. Permissions RBAC ajoutées au `Module::permissions()` : `directory.reports.view`, `directory.reports.manage`. (Contacts/adresses couverts par `directory.clients.*` / `directory.prospects.*` existants.) ## Conversion prospect → client `ConvertProspectProcessor` (`src/Module/Directory/Infrastructure/ApiPlatform/State/ConvertProspectProcessor.php`) est étendu : après création/liaison du `Client`, pour chaque `Contact`, `Address` et `CommercialReport` du prospect → set `client = ` et `prospect = null`. Reste **idempotent** (si déjà converti, retourne inchangé). Les documents suivent automatiquement (rattachés au `CommercialReport`, pas au prospect). ## Frontend (Nuxt — `frontend/modules/directory`) ### Liste & navigation - `pages/directory.vue` (2 onglets Clients/Prospects, `MalioDataTable`) **reste**. - Le clic sur une ligne ouvre désormais la **fiche détail** (`navigateTo`), au lieu du drawer. - Le drawer (`ClientDrawer`/`ProspectDrawer`) est **conservé pour la création rapide** (champs principaux : name/email/phone, + company/status/source/notes pour le prospect). ### Fiches détail `pages/clients/[id].vue` et `pages/prospects/[id].vue` : - En-tête : retour + titre + actions (archiver/supprimer selon droits). - Bloc principal (identité : name/email/phone…), éditable en place. - `MalioTabList` avec onglets **Contact**, **Adresse**, **Rapport** : - **Contact** : `DirectoryContactBlock` répétable (ajout/suppression, sauvegarde par bloc POST/PATCH, suppression = DELETE immédiat), validation 422 inline via `useFormErrors`. - **Adresse** : `DirectoryAddressBlock` répétable, même mécanique. - **Rapport** : liste des comptes-rendus (date, type badge, objet, auteur) + formulaire d'ajout/édition (objet, type, date, corps) + zone documents (`ReportDocumentUpload` / `ReportDocumentList`, calqués sur les composants `TaskDocument*` génériques). Les blocs Contact/Adresse sont des composants **génériques** (mêmes pour client et prospect), paramétrés par l'IRI du propriétaire (`client` ou `prospect`). ### Services & DTO Nouveaux services `services/contacts.ts`, `services/addresses.ts`, `services/commercial-reports.ts` (CRUD + filtre par owner) et DTO associés (`dto/contact.ts`, `dto/address.ts`, `dto/commercial-report.ts`). Réutilisation du service existant `task-documents.ts` via `uploadWithRelation('commercialReport', iri, file)`. ## i18n Traductions FR ajoutées sous `directory.*` : libellés des onglets (Contact, Adresse, Rapport), champs des trois entités, types de compte-rendu (Appel/Rendez-vous/Email/Note), toasts de succès (créé/mis à jour/supprimé) et messages de validation. ## Tests (PHPUnit) - Entités + contrainte CHECK double-FK (un contact/adresse/rapport ne peut être orphelin). - Conversion : après convert, contacts/adresses/rapports du prospect pointent vers le client (`prospect = null`), idempotence. - Sécurité : lecture `ROLE_USER`, écriture refusée hors `ROLE_ADMIN`. - Upload : un document peut être rattaché à un `CommercialReport` ; CHECK respecté. - Data migration adresse inline → `Address` (au moins une adresse créée par client/prospect ayant une adresse non vide). > ⚠️ Base de test non isolée (les POST s'accumulent) : tester des **invariants** > (relations, statuts, présence), pas des **counts absolus**. ## Hors périmètre (YAGNI) - Pas de pipeline d'opportunités/affaires avec montants (le `status` du prospect suffit). - Pas de dashboard/statistiques commerciales chiffrées. - Pas de relance/prochaine action datée sur le compte-rendu (non retenu au cadrage). - Pas de gestion de types d'adresse structurés (facturation/livraison) : `label` libre.