diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3c62503..227d96b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,7 +2,7 @@ name: Quality on: push: - branches: [ master, develop ] + branches: [ master ] pull_request: branches: [ master, develop ] diff --git a/backend/migrations/Version20260522120000.php b/backend/migrations/Version20260522120000.php new file mode 100644 index 0000000..015e643 --- /dev/null +++ b/backend/migrations/Version20260522120000.php @@ -0,0 +1,28 @@ +addSql('CREATE TABLE person_link (id INT AUTO_INCREMENT NOT NULL, person_id INT NOT NULL, title VARCHAR(255) NOT NULL, url VARCHAR(2048) NOT NULL, INDEX IDX_person_link_person (person_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); + $this->addSql('ALTER TABLE person_link ADD CONSTRAINT FK_person_link_person FOREIGN KEY (person_id) REFERENCES person (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE person_link DROP FOREIGN KEY FK_person_link_person'); + $this->addSql('DROP TABLE person_link'); + } +} diff --git a/backend/migrations/Version20260522154347.php b/backend/migrations/Version20260522154347.php new file mode 100644 index 0000000..92e18d3 --- /dev/null +++ b/backend/migrations/Version20260522154347.php @@ -0,0 +1,35 @@ +addSql('ALTER TABLE person_link DROP FOREIGN KEY `FK_person_link_person`'); + $this->addSql('ALTER TABLE person_link ADD CONSTRAINT FK_BC4A1DDA217BBB47 FOREIGN KEY (person_id) REFERENCES person (id)'); + $this->addSql('ALTER TABLE person_link RENAME INDEX idx_person_link_person TO IDX_BC4A1DDA217BBB47'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE person_link DROP FOREIGN KEY FK_BC4A1DDA217BBB47'); + $this->addSql('ALTER TABLE person_link ADD CONSTRAINT `FK_person_link_person` FOREIGN KEY (person_id) REFERENCES person (id) ON UPDATE NO ACTION ON DELETE CASCADE'); + $this->addSql('ALTER TABLE person_link RENAME INDEX idx_bc4a1dda217bbb47 TO IDX_person_link_person'); + } +} diff --git a/backend/src/Controller/Admin/DashboardController.php b/backend/src/Controller/Admin/DashboardController.php index 5d18d1a..5a1e1b5 100644 --- a/backend/src/Controller/Admin/DashboardController.php +++ b/backend/src/Controller/Admin/DashboardController.php @@ -62,6 +62,8 @@ public function configureMenuItems(): iterable yield MenuItem::section('Demandes'); yield MenuItem::linkTo(ContactCrudController::class, 'Contacts', 'fa fa-envelope'); yield MenuItem::section('Outils'); + $mergeUrl = $this->adminUrlGenerator->setRoute('admin_merge')->generateUrl(); + yield MenuItem::linkToUrl('Fusionner référentiels', 'fa fa-code-merge', $mergeUrl); yield MenuItem::linkToUrl('Test mail', 'fa fa-paper-plane', $this->adminUrlGenerator->setRoute('admin_test_mail')->generateUrl()); yield MenuItem::section(); yield MenuItem::linkToUrl('Retour au site', 'fa fa-arrow-left', '/'); diff --git a/backend/src/Controller/Admin/MergeAdminController.php b/backend/src/Controller/Admin/MergeAdminController.php new file mode 100644 index 0000000..331dbc7 --- /dev/null +++ b/backend/src/Controller/Admin/MergeAdminController.php @@ -0,0 +1,121 @@ +value)] +final class MergeAdminController extends AbstractController +{ + public function __construct( + private readonly MergeService $mergeService, + private readonly FiliereRepository $filiereRepository, + private readonly AssociationRepository $associationRepository, + private readonly SchoolRepository $schoolRepository, + private readonly AdminUrlGenerator $adminUrlGenerator, + ) { + } + + #[Route('/admin/merge', name: 'admin_merge', methods: ['GET', 'POST'])] + public function index(Request $request): Response + { + $mergeUrl = $this->adminUrlGenerator->setRoute('admin_merge')->generateUrl(); + + if ($request->isMethod('POST')) { + return $this->handleMerge($request, $mergeUrl); + } + + return $this->render('admin/merge.html.twig', [ + 'merge_url' => $mergeUrl, + 'filieres' => $this->filiereRepository->findAllOrderedByName(), + 'associations' => $this->associationRepository->findAllOrderedByName(), + 'schools' => $this->schoolRepository->findAllOrderedByName(), + ]); + } + + private function handleMerge(Request $request, string $mergeUrl): RedirectResponse + { + if (!$this->isCsrfTokenValid('admin_merge', $request->request->getString('_token'))) { + $this->addFlash('danger', 'Token CSRF invalide.'); + return $this->redirect($mergeUrl); + } + + $type = $request->request->getString('type'); + $sourceId = $request->request->getInt('source_id'); + $targetId = $request->request->getInt('target_id'); + + if ($sourceId === $targetId) { + $this->addFlash('warning', 'La source et la cible sont identiques.'); + return $this->redirect($mergeUrl); + } + + try { + $count = match ($type) { + 'filiere' => $this->mergeFiliere($sourceId, $targetId), + 'association' => $this->mergeAssociation($sourceId, $targetId), + 'school' => $this->mergeSchool($sourceId, $targetId), + default => throw new \InvalidArgumentException('Type inconnu : ' . $type), + }; + + $this->addFlash('success', sprintf('Fusion effectuée : %d enregistrement(s) réassigné(s).', $count)); + } catch (\InvalidArgumentException $invalidArgumentException) { + $this->addFlash('danger', $invalidArgumentException->getMessage()); + } catch (\Throwable) { + $this->addFlash('danger', 'Une erreur inattendue est survenue lors de la fusion.'); + } + + return $this->redirect($mergeUrl); + } + + private function mergeFiliere(int $sourceId, int $targetId): int + { + $source = $this->filiereRepository->find($sourceId); + $target = $this->filiereRepository->find($targetId); + + if (!$source instanceof Filiere || !$target instanceof Filiere) { + throw new \InvalidArgumentException('Filière source ou cible introuvable.'); + } + + return $this->mergeService->mergeFiliere($source, $target); + } + + private function mergeAssociation(int $sourceId, int $targetId): int + { + $source = $this->associationRepository->find($sourceId); + $target = $this->associationRepository->find($targetId); + + if (!$source instanceof Association || !$target instanceof Association) { + throw new \InvalidArgumentException('Association source ou cible introuvable.'); + } + + return $this->mergeService->mergeAssociation($source, $target); + } + + private function mergeSchool(int $sourceId, int $targetId): int + { + $source = $this->schoolRepository->find($sourceId); + $target = $this->schoolRepository->find($targetId); + + if (!$source instanceof School || !$target instanceof School) { + throw new \InvalidArgumentException('École source ou cible introuvable.'); + } + + return $this->mergeService->mergeSchool($source, $target); + } +} diff --git a/backend/src/Controller/Api/CharacteristicTypeApiController.php b/backend/src/Controller/Api/CharacteristicTypeApiController.php new file mode 100644 index 0000000..4a8aa21 --- /dev/null +++ b/backend/src/Controller/Api/CharacteristicTypeApiController.php @@ -0,0 +1,36 @@ +characteristicTypeRepository->getAll(); + + return ApiResponse::success(array_map( + static fn(CharacteristicType $type): array => [ + 'id' => $type->getId(), + 'title' => $type->getTitle(), + 'url' => $type->getUrl(), + 'image' => $type->getImage(), + ], + $types, + )); + } +} diff --git a/backend/src/Controller/Api/PersonApiController.php b/backend/src/Controller/Api/PersonApiController.php index c8df7ee..ca84509 100644 --- a/backend/src/Controller/Api/PersonApiController.php +++ b/backend/src/Controller/Api/PersonApiController.php @@ -95,6 +95,14 @@ public function update( $this->personService->syncFilieres($person, $dto->filieres ?? []); $this->personService->syncAssociations($person, $dto->associations ?? []); + if ($dto->characteristics !== null) { + $this->personService->syncCharacteristics($person, $dto->characteristics); + } + + if ($dto->links !== null) { + $this->personService->syncLinks($person, $dto->links); + } + $this->personService->update($person); return ApiResponse::success($this->personService->mapToResponseDto($person)); diff --git a/backend/src/Dto/Person/CharacteristicRequestDto.php b/backend/src/Dto/Person/CharacteristicRequestDto.php new file mode 100644 index 0000000..ed3609a --- /dev/null +++ b/backend/src/Dto/Person/CharacteristicRequestDto.php @@ -0,0 +1,16 @@ + + */ + #[ORM\OneToMany(targetEntity: PersonLink::class, mappedBy: 'person', cascade: ['persist'], orphanRemoval: true)] + private Collection $links; + public function __construct() { $this->godFathers = new ArrayCollection(); @@ -117,6 +123,7 @@ public function __construct() $this->createdAt = new \DateTime(); $this->filieres = new ArrayCollection(); $this->associations = new ArrayCollection(); + $this->links = new ArrayCollection(); } public function getId(): int @@ -463,4 +470,33 @@ public function replaceAssociations(array $personAssociations): void $this->addAssociation($pa); } } + + /** + * @return Collection + */ + public function getLinks(): Collection + { + return $this->links; + } + + public function addLink(PersonLink $link): static + { + if (!$this->links->contains($link)) { + $this->links->add($link); + $link->setPerson($this); + } + + return $this; + } + + /** + * @param PersonLink[] $links + */ + public function replaceLinks(array $links): void + { + $this->links->clear(); + foreach ($links as $link) { + $this->addLink($link); + } + } } diff --git a/backend/src/Entity/Person/PersonLink.php b/backend/src/Entity/Person/PersonLink.php new file mode 100644 index 0000000..78f297a --- /dev/null +++ b/backend/src/Entity/Person/PersonLink.php @@ -0,0 +1,68 @@ +id; + } + + public function getTitle(): string + { + return $this->title; + } + + public function setTitle(string $title): static + { + $this->title = $title; + + return $this; + } + + public function getUrl(): string + { + return $this->url; + } + + public function setUrl(string $url): static + { + $this->url = $url; + + return $this; + } + + public function getPerson(): ?Person + { + return $this->person; + } + + public function setPerson(?Person $person): static + { + $this->person = $person; + + return $this; + } +} diff --git a/backend/src/Fixture/PersonLinkFixture.php b/backend/src/Fixture/PersonLinkFixture.php new file mode 100644 index 0000000..4b168a7 --- /dev/null +++ b/backend/src/Fixture/PersonLinkFixture.php @@ -0,0 +1,76 @@ +setPerson($this->getReference($personRef, Person::class)) + ->setTitle($title) + ->setUrl($url); + $manager->persist($link); + } + + $manager->flush(); + } + + /** + * @return string[] + */ + #[\Override] + public function getDependencies(): array + { + return [PersonFixture::class]; + } +} diff --git a/backend/src/Repository/CharacteristicRepository.php b/backend/src/Repository/CharacteristicRepository.php index 99d3c79..e175c4f 100644 --- a/backend/src/Repository/CharacteristicRepository.php +++ b/backend/src/Repository/CharacteristicRepository.php @@ -17,4 +17,9 @@ public function __construct(ManagerRegistry $managerRegistry) { parent::__construct($managerRegistry, Characteristic::class); } + + public function create(Characteristic $characteristic): void + { + $this->getEntityManager()->persist($characteristic); + } } diff --git a/backend/src/Repository/Person/PersonLinkRepository.php b/backend/src/Repository/Person/PersonLinkRepository.php new file mode 100644 index 0000000..80a834a --- /dev/null +++ b/backend/src/Repository/Person/PersonLinkRepository.php @@ -0,0 +1,20 @@ + + */ +class PersonLinkRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $managerRegistry) + { + parent::__construct($managerRegistry, PersonLink::class); + } +} diff --git a/backend/src/Service/MergeService.php b/backend/src/Service/MergeService.php new file mode 100644 index 0000000..e3b8d36 --- /dev/null +++ b/backend/src/Service/MergeService.php @@ -0,0 +1,78 @@ +doMerge( + 'UPDATE App\Entity\Person\PersonFiliere pf SET pf.filiere = :target WHERE pf.filiere = :source', + $source, + $target, + ); + } + + /** + * Réassigne tous les PersonAssociation de $source vers $target puis supprime $source. + */ + public function mergeAssociation(Association $source, Association $target): int + { + return $this->doMerge( + 'UPDATE App\Entity\Person\PersonAssociation pa SET pa.association = :target WHERE pa.association = :source', + $source, + $target, + ); + } + + /** + * Réassigne tous les PersonFiliere (school) de $source vers $target puis supprime $source. + */ + public function mergeSchool(School $source, School $target): int + { + return $this->doMerge( + 'UPDATE App\Entity\Person\PersonFiliere pf SET pf.school = :target WHERE pf.school = :source', + $source, + $target, + ); + } + + private function doMerge(string $dql, object $source, object $target): int + { + $conn = $this->em->getConnection(); + $conn->beginTransaction(); + + try { + $raw = $this->em->createQuery($dql) + ->setParameter('target', $target) + ->setParameter('source', $source) + ->execute(); + + $count = is_int($raw) ? $raw : 0; + + $this->em->remove($source); + $this->em->flush(); + + $conn->commit(); + } catch (\Throwable $throwable) { + $conn->rollBack(); + throw $throwable; + } + + return $count; + } +} diff --git a/backend/src/Service/PersonService.php b/backend/src/Service/PersonService.php index 33be7b5..a643380 100644 --- a/backend/src/Service/PersonService.php +++ b/backend/src/Service/PersonService.php @@ -7,8 +7,11 @@ use App\Dto\Person\AssociationRequestDto; use App\Dto\Person\AssociationResponseDto; use App\Dto\Person\CharacteristicDto; +use App\Dto\Person\CharacteristicRequestDto; use App\Dto\Person\FiliereRequestDto; use App\Dto\Person\FiliereResponseDto; +use App\Dto\Person\PersonLinkDto; +use App\Dto\Person\PersonLinkRequestDto; use App\Dto\Person\PersonResponseDto; use App\Dto\Sponsor\SponsorResponseDto; use App\Entity\Characteristic\Characteristic; @@ -16,10 +19,12 @@ use App\Entity\Person\Association; use App\Entity\Person\Person; use App\Entity\Person\PersonAssociation; +use App\Entity\Person\PersonLink; use App\Entity\Sponsor\Sponsor; use App\Entity\Person\Filiere; use App\Entity\Person\PersonFiliere; use App\Entity\Person\School; +use App\Repository\CharacteristicRepository; use App\Repository\CharacteristicTypeRepository; use App\Repository\Person\AssociationRepository; use App\Repository\Person\FiliereRepository; @@ -31,6 +36,7 @@ public function __construct( private PersonRepository $personRepository, private CharacteristicTypeRepository $characteristicTypeRepository, + private CharacteristicRepository $characteristicRepository, private FiliereRepository $filiereRepository, private SchoolRepository $schoolRepository, private AssociationRepository $associationRepository, @@ -167,7 +173,15 @@ public function mapToResponseDto(Person $person): PersonResponseDto endDate: $pa->getEndDate()?->format('Y-m-d'), ), $person->getAssociations()->toArray() - ) + ), + links: array_map( + static fn(PersonLink $l): PersonLinkDto => new PersonLinkDto( + id: (int) $l->getId(), + title: $l->getTitle(), + url: $l->getUrl(), + ), + $person->getLinks()->toArray() + ), ); } @@ -242,6 +256,74 @@ private function buildPersonAssociation(AssociationRequestDto $dto): PersonAssoc return $personAssociation; } + /** + * @param CharacteristicRequestDto[] $dtos + */ + public function syncCharacteristics(Person $person, array $dtos): void + { + // Build a map of existing characteristics by id for fast lookup + $existingById = []; + foreach ($person->getCharacteristics() as $characteristic) { + $id = $characteristic->getId(); + if ($id !== null) { + $existingById[$id] = $characteristic; + } + } + + $seenIds = []; + + foreach ($dtos as $dto) { + if ($dto->id !== null && isset($existingById[$dto->id])) { + // Update existing + $existingById[$dto->id]->setValue($dto->value ?? ''); + $existingById[$dto->id]->setVisible($dto->visible); + $seenIds[] = $dto->id; + } elseif ($dto->typeId !== null) { + // Create new characteristic for this type if not already present + $type = $this->characteristicTypeRepository->find($dto->typeId); + if ($type === null) { + continue; + } + + $alreadyExists = $person->getCharacteristics()->exists( + static fn(int $key, Characteristic $c): bool => $c->getType()?->getId() === $type->getId(), + ); + if ($alreadyExists) { + continue; + } + + $characteristic = new Characteristic(); + $characteristic->setPerson($person); + $characteristic->setType($type); + $characteristic->setValue($dto->value ?? ''); + $characteristic->setVisible($dto->visible); + $person->addCharacteristic($characteristic); + $this->characteristicRepository->create($characteristic); + $seenIds[] = $characteristic->getId(); + } + } + + // Remove characteristics absent from the payload (orphanRemoval handles DELETE) + foreach ($existingById as $id => $characteristic) { + if (!in_array($id, $seenIds, true)) { + $person->removeCharacteristic($characteristic); + } + } + } + + /** + * @param PersonLinkRequestDto[] $dtos + */ + public function syncLinks(Person $person, array $dtos): void + { + $person->replaceLinks(array_map( + static fn(PersonLinkRequestDto $dto): PersonLink => new PersonLink() + ->setTitle($dto->title) + ->setUrl($dto->url), + $dtos, + )); + } + private function buildPersonFiliere(FiliereRequestDto $dto): PersonFiliere { $canonical = Filiere::normalize($dto->name); diff --git a/backend/templates/admin/merge.html.twig b/backend/templates/admin/merge.html.twig new file mode 100644 index 0000000..508d079 --- /dev/null +++ b/backend/templates/admin/merge.html.twig @@ -0,0 +1,69 @@ +{% extends '@EasyAdmin/page/content.html.twig' %} + +{% block page_title %}Fusionner des référentiels{% endblock %} + +{% block content %} +
+
+

+ Fusionnez deux éléments d'un référentiel : tous les liens de la source seront + réassignés vers la cible, puis la source sera supprimée. + Les champs annexes (dates, poste, diplôme…) sont conservés. +

+ + {% macro merge_card(title, icon, type, items, merge_url) %} +
+
+
+ {{ title }} +
+
+
+
+ + + +
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+
+
+
+ {% endmacro %} + + {{ _self.merge_card('Filières', 'fa-graduation-cap', 'filiere', filieres, merge_url) }} + {{ _self.merge_card('Associations', 'fa-users', 'association', associations, merge_url) }} + {{ _self.merge_card('Écoles', 'fa-school', 'school', schools, merge_url) }} +
+
+{% endblock %} diff --git a/e2e/tests/tree/filters.spec.ts b/e2e/tests/tree/filters.spec.ts index 5a0a6a8..bdac06b 100644 --- a/e2e/tests/tree/filters.spec.ts +++ b/e2e/tests/tree/filters.spec.ts @@ -25,6 +25,8 @@ test('tree directory filters: search, year, count', async ({ page }) => { await expect(counter).toHaveText(new RegExp(`^${initialCount.toString()} résultats?$`)); // 3. Filtre année 2021 → nombre réduit (Luka est en 2021) + // Ouvrir le combobox "Promo" puis sélectionner "2021 / 22" + await page.getByPlaceholder('Promo').click(); await page.getByRole('button', { name: /^2021 \/ 22$/ }).click(); const filteredText = (await counter.textContent()) ?? ''; const filteredCount = parseInt(/(\d+)/.exec(filteredText)?.[1] ?? '0', 10); @@ -35,8 +37,8 @@ test('tree directory filters: search, year, count', async ({ page }) => { const cards = page.locator('[data-testid^="person-card-"]'); await expect(cards).toHaveCount(filteredCount); - // 4. Reset filtre années via "Toutes" - await page.getByRole('button', { name: 'Toutes' }).click(); + // 4. Reset filtre années : le dropdown est encore ouvert, cliquer l'option pour désélectionner + await page.getByRole('button', { name: /^2021 \/ 22$/ }).click(); await expect(counter).toHaveText(new RegExp(`^${initialCount.toString()} résultats?$`)); // 5. Tri alphabétique : on toggle, on vérifie que le bouton devient actif (background ink) diff --git a/frontend/src/hooks/usePersonFilter.test.ts b/frontend/src/hooks/usePersonFilter.test.ts index 904b4ce..18c0ba4 100644 --- a/frontend/src/hooks/usePersonFilter.test.ts +++ b/frontend/src/hooks/usePersonFilter.test.ts @@ -11,6 +11,15 @@ function mkPerson(id: number, firstName: string, lastName: string, startYear: nu fullName: `${firstName} ${lastName}`, startYear, picture: null, + birthdate: null, + biography: null, + description: null, + godFathers: [], + godChildren: [], + characteristics: [], + filieres: [], + associations: [], + links: [], }; } diff --git a/frontend/src/hooks/usePersonFilter.ts b/frontend/src/hooks/usePersonFilter.ts index c1b992a..678c76e 100644 --- a/frontend/src/hooks/usePersonFilter.ts +++ b/frontend/src/hooks/usePersonFilter.ts @@ -6,31 +6,53 @@ export interface PersonFilterState { name: string; years: number[]; alphabetical: boolean; + filieres: string[]; + schools: string[]; filtered: Person[]; setName: (name: string) => void; toggleYear: (year: number) => void; clearYears: () => void; toggleAlphabetical: () => void; + toggleFiliere: (filiere: string) => void; + clearFilieres: () => void; + toggleSchool: (school: string) => void; + clearSchools: () => void; } export function usePersonFilter(persons: Person[], initialYears: number[] = []): PersonFilterState { const [name, setName] = useState(''); const [years, setYears] = useState(initialYears); const [alphabetical, setAlphabetical] = useState(false); + const [filieres, setFilieres] = useState([]); + const [schools, setSchools] = useState([]); const filtered = useMemo( - () => filterPersons(persons, { name, years, alphabetical }), - [persons, name, years, alphabetical], + () => filterPersons(persons, { name, years, alphabetical, filieres, schools }), + [persons, name, years, alphabetical, filieres, schools], ); function toggleYear(year: number) { setYears((prev) => (prev.includes(year) ? prev.filter((y) => y !== year) : [...prev, year])); } + function toggleFiliere(filiere: string) { + setFilieres((prev) => + prev.includes(filiere) ? prev.filter((f) => f !== filiere) : [...prev, filiere], + ); + } + + function toggleSchool(school: string) { + setSchools((prev) => + prev.includes(school) ? prev.filter((s) => s !== school) : [...prev, school], + ); + } + return { name, years, alphabetical, + filieres, + schools, filtered, setName, toggleYear, @@ -40,5 +62,13 @@ export function usePersonFilter(persons: Person[], initialYears: number[] = []): toggleAlphabetical: () => { setAlphabetical((prev) => !prev); }, + toggleFiliere, + clearFilieres: () => { + setFilieres([]); + }, + toggleSchool, + clearSchools: () => { + setSchools([]); + }, }; } diff --git a/frontend/src/lib/api/characteristicTypes.ts b/frontend/src/lib/api/characteristicTypes.ts new file mode 100644 index 0000000..9bfbf00 --- /dev/null +++ b/frontend/src/lib/api/characteristicTypes.ts @@ -0,0 +1,13 @@ +import { get } from './client'; +import type { Result } from '../../types/api'; + +export interface CharacteristicType { + id: number; + title: string; + url: string | null; + image: string | null; +} + +export function getCharacteristicTypes(): Promise> { + return get('/api/characteristic-types'); +} diff --git a/frontend/src/lib/persons.test.ts b/frontend/src/lib/persons.test.ts index 26185d0..a6a6e37 100644 --- a/frontend/src/lib/persons.test.ts +++ b/frontend/src/lib/persons.test.ts @@ -11,7 +11,15 @@ function mkPerson(id: number, firstName: string, lastName: string, startYear: nu fullName: `${firstName} ${lastName}`, startYear, picture: null, - color: '#000', + birthdate: null, + biography: null, + description: null, + godFathers: [], + godChildren: [], + characteristics: [], + filieres: [], + associations: [], + links: [], }; } @@ -22,7 +30,7 @@ const people: Person[] = [ mkPerson(4, 'Charles', 'Bernard', 2022), ]; -const base: PersonFilter = { name: '', year: null, alphabetical: false }; +const base: PersonFilter = { name: '', years: [], alphabetical: false, filieres: [], schools: [] }; describe('filterPersons', () => { it('retourne tous les éléments sans filtre', () => { @@ -30,7 +38,7 @@ describe('filterPersons', () => { }); it('filtre par année', () => { - const result = filterPersons(people, { ...base, year: 2020 }); + const result = filterPersons(people, { ...base, years: [2020] }); expect(result).toHaveLength(2); expect(result.map((p) => p.id)).toEqual([1, 3]); }); @@ -64,7 +72,13 @@ describe('filterPersons', () => { }); it('combine filtre nom et année', () => { - const result = filterPersons(people, { name: 'alice', year: 2020, alphabetical: false }); + const result = filterPersons(people, { + name: 'alice', + years: [2020], + alphabetical: false, + filieres: [], + schools: [], + }); expect(result).toHaveLength(1); expect(result[0]?.id).toBe(1); }); diff --git a/frontend/src/lib/persons.ts b/frontend/src/lib/persons.ts index f35ec39..1035208 100644 --- a/frontend/src/lib/persons.ts +++ b/frontend/src/lib/persons.ts @@ -4,6 +4,8 @@ export interface PersonFilter { name: string; years: number[]; alphabetical: boolean; + filieres: string[]; + schools: string[]; } function normalize(s: string): string { @@ -13,9 +15,17 @@ function normalize(s: string): string { export function filterPersons(persons: Person[], filter: PersonFilter): Person[] { const query = normalize(filter.name.trim()); const yearSet = new Set(filter.years); + const filiereSet = new Set(filter.filieres); + const schoolSet = new Set(filter.schools); let result = persons.filter((p) => { if (yearSet.size > 0 && !yearSet.has(p.startYear)) return false; + if (filiereSet.size > 0 && !p.filieres.some((f) => filiereSet.has(f.name))) return false; + if ( + schoolSet.size > 0 && + !p.filieres.some((f) => f.schoolName !== null && schoolSet.has(f.schoolName)) + ) + return false; if (query.length > 0) { const full = normalize(`${p.lastName} ${p.firstName}`); const reversed = normalize(`${p.firstName} ${p.lastName}`); diff --git a/frontend/src/pages/person/EditPersonPage.tsx b/frontend/src/pages/person/EditPersonPage.tsx index 55ab739..2ddabe7 100644 --- a/frontend/src/pages/person/EditPersonPage.tsx +++ b/frontend/src/pages/person/EditPersonPage.tsx @@ -24,15 +24,21 @@ import { SPONSOR_TYPE_ICONS, SPONSOR_TYPE_LABELS } from '../../lib/sponsorTypes' import type { Association, AssociationRequest, + Characteristic, + CharacteristicRequest, Filiere, FiliereRequest, Person, + PersonLink, + PersonLinkRequest, PersonRequest, } from '../../types/person'; import type { Sponsor, SponsorType } from '../../types/sponsor'; import { getFilieres } from '../../lib/api/filieres'; import { getSchools } from '../../lib/api/schools'; import { getAssociations } from '../../lib/api/associations'; +import { getCharacteristicTypes } from '../../lib/api/characteristicTypes'; +import type { CharacteristicType } from '../../lib/api/characteristicTypes'; // ── Skeleton ────────────────────────────────────────────────────────────────── @@ -1068,6 +1074,296 @@ function AssociationsEditor({ ); } +// ── Éditeur de liens (caractéristiques) ────────────────────────────────────── + +const EyeIcon = ({ open }: { open: boolean }) => ( + + {open ? ( + <> + + + + ) : ( + <> + + + + )} + +); + +function CharacteristicRow({ + characteristic, + onUpdate, + onRemove, +}: { + characteristic: Characteristic; + onUpdate: (patch: Partial) => void; + onRemove: () => void; +}) { + const icon = characteristic.typeImage + ? `/images/icons/${characteristic.typeImage}` + : characteristic.typeUrl + ? `https://www.google.com/s2/favicons?domain=${new URL(characteristic.typeUrl).hostname}&sz=16` + : null; + + return ( +
+ + +
+ {icon && ( + { + e.currentTarget.style.display = 'none'; + }} + /> + )} + + {characteristic.typeTitle} + +
+ + { + onUpdate({ value: e.target.value || null }); + }} + placeholder={ + characteristic.typeUrl ? characteristic.typeUrl.replace(/https?:\/\//, '') : 'Valeur…' + } + className="flex-1" + /> + +
+ ); +} + +function CharacteristicsEditor({ + characteristics, + allTypes, + onChange, +}: { + characteristics: Characteristic[]; + allTypes: CharacteristicType[]; + onChange: (updated: Characteristic[]) => void; +}) { + const tempCounter = useRef(-1); + const addableTypes = allTypes.filter( + (t) => !characteristics.some((c) => c.typeTitle === t.title), + ); + + function update(id: number, patch: Partial) { + onChange(characteristics.map((c) => (c.id === id ? { ...c, ...patch } : c))); + } + + function remove(id: number) { + onChange(characteristics.filter((c) => c.id !== id)); + } + + function addType(type: CharacteristicType) { + const tempId = tempCounter.current--; + onChange([ + ...characteristics, + { + id: tempId, + value: null, + visible: true, + typeTitle: type.title, + typeUrl: type.url, + typeImage: type.image, + _typeId: type.id, + } as Characteristic & { _typeId: number }, + ]); + } + + return ( +
+
+ {characteristics.map((c) => ( + { + update(c.id, patch); + }} + onRemove={() => { + remove(c.id); + }} + /> + ))} +
+ + {addableTypes.length > 0 && ( +
+ {addableTypes.map((type) => ( + + ))} +
+ )} +
+ ); +} + +// ── Éditeur de liens libres ─────────────────────────────────────────────────── + +function LinksEditor({ + links, + onChange, +}: { + links: PersonLink[]; + onChange: (updated: PersonLink[]) => void; +}) { + const tempCounter = useRef(-1); + + function addRow() { + onChange([...links, { id: tempCounter.current--, title: '', url: '' }]); + } + + function updateRow(id: number, patch: Partial) { + onChange(links.map((l) => (l.id === id ? { ...l, ...patch } : l))); + } + + function removeRow(id: number) { + onChange(links.filter((l) => l.id !== id)); + } + + const trashIcon = ( + + + + + + + ); + + return ( +
+
+ {links.map((l) => ( +
+
+ { + updateRow(l.id, { title: e.target.value }); + }} + placeholder="Titre (ex: Portfolio)" + /> +
+
+ { + updateRow(l.id, { url: e.target.value }); + }} + placeholder="https://…" + /> +
+
+ ))} +
+ +
+ ); +} + // ── Page ────────────────────────────────────────────────────────────────────── export function EditPersonPage() { @@ -1115,6 +1411,9 @@ export function EditPersonPage() { const [allSchools, setAllSchools] = useState([]); const [associations, setAssociations] = useState([]); const [allAssociations, setAllAssociations] = useState([]); + const [characteristics, setCharacteristics] = useState([]); + const [allCharacteristicTypes, setAllCharacteristicTypes] = useState([]); + const [freeLinks, setFreeLinks] = useState([]); useEffect(() => { if (!person || initialized.current) return; @@ -1140,6 +1439,8 @@ export function EditPersonPage() { endDate: a.endDate ?? null, })), ); + setCharacteristics([...person.characteristics]); + setFreeLinks([...person.links]); }, [person]); useEffect(() => { @@ -1157,6 +1458,9 @@ export function EditPersonPage() { void getAssociations().then((result) => { if (result.ok) setAllAssociations(result.data); }); + void getCharacteristicTypes().then((result) => { + if (result.ok) setAllCharacteristicTypes(result.data); + }); }, [notify]); async function handleSubmit(e: SyntheticEvent) { @@ -1213,6 +1517,17 @@ export function EditPersonPage() { endDate, }), ), + links: freeLinks + .filter((l) => l.title.trim() && l.url.trim()) + .map(({ title, url }): PersonLinkRequest => ({ title, url })), + characteristics: characteristics.map((c): CharacteristicRequest => { + if (c.id < 0) { + // nouvelle caractéristique — on envoie typeId sans id + const typeId = (c as Characteristic & { _typeId?: number })._typeId; + return { id: null, typeId, value: c.value, visible: c.visible }; + } + return { id: c.id, value: c.value, visible: c.visible }; + }), }; const updateResult = await updatePerson(Number(id), data); @@ -1371,6 +1686,28 @@ export function EditPersonPage() { /> + {/* Liens */} + +

Liens

+ {characteristics.length > 0 && ( + <> +

+ Réseaux & services +

+ +
+ + )} +

+ Liens libres +

+ + + {/* Parrainages */}

Parrainages

diff --git a/frontend/src/pages/person/PersonPage.tsx b/frontend/src/pages/person/PersonPage.tsx index 44c94e1..1ccf197 100644 --- a/frontend/src/pages/person/PersonPage.tsx +++ b/frontend/src/pages/person/PersonPage.tsx @@ -315,15 +315,49 @@ export function PersonPage() {
)} - {/* Caractéristiques */} - {visibleCharacteristics.length > 0 && ( + {/* Caractéristiques + Liens libres */} + {(visibleCharacteristics.length > 0 || person.links.length > 0) && (

Liens

-
- {visibleCharacteristics.map((c) => ( - - ))} -
+ {visibleCharacteristics.length > 0 && ( +
+ {visibleCharacteristics.map((c) => ( + + ))} +
+ )} + {person.links.length > 0 && ( +
0 ? 'mt-2' : ''}`} + > + {person.links.map((link) => ( + + + + + + + {link.title} + + ))} +
+ )}
)}
diff --git a/frontend/src/pages/tree/TreePage.tsx b/frontend/src/pages/tree/TreePage.tsx index 3abdd85..fce6cbc 100644 --- a/frontend/src/pages/tree/TreePage.tsx +++ b/frontend/src/pages/tree/TreePage.tsx @@ -1,8 +1,9 @@ -import { useMemo, useState } from 'react'; +import { useMemo, useReducer, useEffect, useState } from 'react'; import { useSearchParams } from 'react-router'; import { usePersonFilter } from '../../hooks/usePersonFilter'; import { DirectoryToolbar } from './toolbar/DirectoryToolbar'; import type { DirectoryView } from './types'; +import type { Person } from '../../types/person'; import { usePersons } from './usePersons'; import { useSponsorsGraph } from './useSponsorsGraph'; import { GridView } from './views/GridView'; @@ -11,10 +12,39 @@ import { TimelineView } from './views/TimelineView'; import { TreeView } from './views/TreeView'; import { EgoGraphView } from './views/EgoGraphView'; +interface ShuffleAction { + type: 'SET'; + persons: Person[]; +} + +function shuffleReducer(state: Person[], action: ShuffleAction): Person[] { + if (state.length > 0) return state; + const copy = [...action.persons]; + for (let i = copy.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const a = copy[i]; + const b = copy[j]; + if (a !== undefined && b !== undefined) { + copy[i] = b; + copy[j] = a; + } + } + return copy; +} + export function TreePage() { - const { persons, loading } = usePersons(); + const { persons, loading, loadingMore } = usePersons(); const { links, loading: linksLoading } = useSponsorsGraph(persons); const [view, setView] = useState('grid'); + const [shuffledPersons, dispatch] = useReducer(shuffleReducer, []); + + useEffect(() => { + if (!loading && !loadingMore && persons.length > 0) { + dispatch({ type: 'SET', persons }); + } + }, [loading, loadingMore, persons]); + + const displayPersons = shuffledPersons.length > 0 ? shuffledPersons : persons; const [searchParams] = useSearchParams(); const initialYear = searchParams.get('year') ? Number(searchParams.get('year')) : null; @@ -22,18 +52,42 @@ export function TreePage() { name, years: selectedYears, alphabetical, + filieres: selectedFilieres, + schools: selectedSchools, filtered, setName, toggleYear, clearYears, toggleAlphabetical, - } = usePersonFilter(persons, initialYear !== null ? [initialYear] : []); + toggleFiliere, + clearFilieres, + toggleSchool, + clearSchools, + } = usePersonFilter(displayPersons, initialYear !== null ? [initialYear] : []); const availableYears = useMemo(() => { const yearSet = new Set(persons.map((p) => p.startYear)); return Array.from(yearSet).sort((a, b) => a - b); }, [persons]); + const availableFilieres = useMemo(() => { + const set = new Set(); + for (const p of persons) { + for (const f of p.filieres) set.add(f.name); + } + return Array.from(set).sort((a, b) => a.localeCompare(b, 'fr')); + }, [persons]); + + const availableSchools = useMemo(() => { + const set = new Set(); + for (const p of persons) { + for (const f of p.filieres) { + if (f.schoolName) set.add(f.schoolName); + } + } + return Array.from(set).sort((a, b) => a.localeCompare(b, 'fr')); + }, [persons]); + return (
{/* En-tête */} @@ -59,6 +113,14 @@ export function TreePage() { selectedYears={selectedYears} onToggleYear={toggleYear} onClearYears={clearYears} + availableFilieres={availableFilieres} + selectedFilieres={selectedFilieres} + onToggleFiliere={toggleFiliere} + onClearFilieres={clearFilieres} + availableSchools={availableSchools} + selectedSchools={selectedSchools} + onToggleSchool={toggleSchool} + onClearSchools={clearSchools} resultCount={filtered.length} loading={loading} /> diff --git a/frontend/src/pages/tree/toolbar/DirectoryToolbar.tsx b/frontend/src/pages/tree/toolbar/DirectoryToolbar.tsx index 6625d79..a99ccee 100644 --- a/frontend/src/pages/tree/toolbar/DirectoryToolbar.tsx +++ b/frontend/src/pages/tree/toolbar/DirectoryToolbar.tsx @@ -2,7 +2,7 @@ import { Input } from '../../../components/ui'; import { cn } from '../../../lib/cn'; import type { DirectoryView } from '../types'; import { ViewSwitcher } from './ViewSwitcher'; -import { YearFilter } from './YearFilter'; +import { FilterCombobox } from './FilterCombobox'; const SearchIcon = () => ( @@ -24,6 +24,14 @@ interface DirectoryToolbarProps { selectedYears: number[]; onToggleYear: (year: number) => void; onClearYears: () => void; + availableFilieres: string[]; + selectedFilieres: string[]; + onToggleFiliere: (f: string) => void; + onClearFilieres: () => void; + availableSchools: string[]; + selectedSchools: string[]; + onToggleSchool: (s: string) => void; + onClearSchools: () => void; resultCount: number; loading: boolean; } @@ -39,12 +47,20 @@ export function DirectoryToolbar({ selectedYears, onToggleYear, onClearYears, + availableFilieres, + selectedFilieres, + onToggleFiliere, + onClearFilieres, + availableSchools, + selectedSchools, + onToggleSchool, + onClearSchools, resultCount, loading, }: DirectoryToolbarProps) { return (
- {/* Ligne 1 : recherche + sélecteurs */} + {/* Ligne 1 : recherche + sélecteurs de vue */}
} @@ -84,23 +100,41 @@ export function DirectoryToolbar({
- {/* Ligne 2 : filtre années + compteur */} -
+ {/* Ligne 2 : filtres + compteur */} +
{loading ? ( -
+
) : ( - - )} + <> + { + onToggleYear(Number(y)); + }} + onClear={onClearYears} + formatItem={(y) => `${y} / ${String(Number(y) + 1).slice(2)}`} + /> + + - {!loading && ( - - {resultCount} résultat{resultCount > 1 ? 's' : ''} - + + {resultCount} résultat{resultCount > 1 ? 's' : ''} + + )}
diff --git a/frontend/src/pages/tree/toolbar/FilterCombobox.tsx b/frontend/src/pages/tree/toolbar/FilterCombobox.tsx new file mode 100644 index 0000000..ac938f9 --- /dev/null +++ b/frontend/src/pages/tree/toolbar/FilterCombobox.tsx @@ -0,0 +1,215 @@ +import { useEffect, useRef, useState } from 'react'; +import { cn } from '../../../lib/cn'; + +const MAX_CHIPS = 2; + +interface FilterComboboxProps { + label: string; + items: string[]; + selected: string[]; + onToggle: (item: string) => void; + onClear: () => void; + formatItem?: (item: string) => string; +} + +export function FilterCombobox({ + label, + items, + selected, + onToggle, + onClear, + formatItem, +}: FilterComboboxProps) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const ref = useRef(null); + const inputRef = useRef(null); + + const fmt = formatItem ?? ((s) => s); + const normalize = (s: string) => s.toLowerCase().normalize('NFD').replace(/\p{M}/gu, ''); + + const filtered = query.trim() + ? items.filter((item) => normalize(fmt(item)).includes(normalize(query))) + : items; + + useEffect(() => { + if (!open) return; + inputRef.current?.focus(); + + function onPointerDown(e: PointerEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + } + function onKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') setOpen(false); + } + document.addEventListener('pointerdown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('pointerdown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + setQuery(''); + }; + }, [open]); + + if (items.length === 0) return null; + + const active = selected.length > 0; + const compact = selected.length > MAX_CHIPS; + + return ( +
+ {/* Trigger — largeur fixe */} +
{ + setOpen(true); + }} + className={cn( + 'flex h-9 w-full cursor-text items-center gap-1 overflow-hidden rounded-[9px] border px-2 transition-all duration-150', + open + ? 'border-ink-2 bg-surface' + : active + ? 'border-ink bg-surface' + : 'border-line bg-surface hover:border-ink-3', + )} + > + {compact ? ( + /* Mode compact : label · N */ + <> + + {label} · {selected.length} + + + + ) : active ? ( + /* 1 ou 2 chips */ + <> + {selected.map((item) => ( + + {fmt(item)} + + + ))} + + ) : ( + /* Placeholder / input de recherche */ + { + setQuery(e.target.value); + setOpen(true); + }} + onFocus={() => { + setOpen(true); + }} + placeholder={label} + className="w-full bg-transparent text-[13px] text-ink placeholder:text-ink-4 outline-none" + /> + )} +
+ + {/* Dropdown */} + {open && ( +
+ {/* Input de recherche quand des chips sont affichées */} + {active && ( +
+ { + setQuery(e.target.value); + }} + placeholder="Rechercher…" + className="w-full rounded-[7px] border border-line bg-bg px-2.5 py-1.5 text-[12.5px] text-ink placeholder:text-ink-4 outline-none" + /> +
+ )} + +
+ {filtered.length === 0 ? ( +

Aucun résultat

+ ) : ( + filtered.map((item) => { + const isSelected = selected.includes(item); + return ( + + ); + }) + )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/tree/toolbar/FilterDropdown.tsx b/frontend/src/pages/tree/toolbar/FilterDropdown.tsx new file mode 100644 index 0000000..2a521b6 --- /dev/null +++ b/frontend/src/pages/tree/toolbar/FilterDropdown.tsx @@ -0,0 +1,136 @@ +import { useEffect, useRef, useState } from 'react'; +import { cn } from '../../../lib/cn'; + +interface FilterDropdownProps { + label: string; + items: string[]; + selected: string[]; + onToggle: (item: string) => void; + onClear: () => void; + formatItem?: (item: string) => string; +} + +export function FilterDropdown({ + label, + items, + selected, + onToggle, + onClear, + formatItem, +}: FilterDropdownProps) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + function onPointerDown(e: PointerEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + } + function onKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') setOpen(false); + } + document.addEventListener('pointerdown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('pointerdown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [open]); + + if (items.length === 0) return null; + + const active = selected.length > 0; + const fmt = formatItem ?? ((s) => s); + + return ( +
+ + + {open && ( +
+ {/* Tout sélectionner / effacer */} + + +
+ +
+ {items.map((item) => { + const isSelected = selected.includes(item); + return ( + + ); + })} +
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/tree/toolbar/TagFilter.tsx b/frontend/src/pages/tree/toolbar/TagFilter.tsx new file mode 100644 index 0000000..48c88a0 --- /dev/null +++ b/frontend/src/pages/tree/toolbar/TagFilter.tsx @@ -0,0 +1,54 @@ +import { cn } from '../../../lib/cn'; + +interface TagFilterProps { + label: string; + items: string[]; + selected: string[]; + onToggle: (item: string) => void; + onClear: () => void; +} + +export function TagFilter({ label, items, selected, onToggle, onClear }: TagFilterProps) { + if (items.length === 0) return null; + const allSelected = selected.length === 0; + + return ( +
+ + {label} + + + {items.map((item) => { + const active = selected.includes(item); + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/types/person.ts b/frontend/src/types/person.ts index 9cc2e50..0d8f573 100644 --- a/frontend/src/types/person.ts +++ b/frontend/src/types/person.ts @@ -24,6 +24,7 @@ export interface Person { characteristics: Characteristic[]; filieres: Filiere[]; associations: Association[]; + links: PersonLink[]; } export interface FiliereRequest { @@ -41,6 +42,24 @@ export interface AssociationRequest { endDate: string | null; } +export interface CharacteristicRequest { + id: number | null; + typeId?: number; + value: string | null; + visible: boolean; +} + +export interface PersonLink { + id: number; + title: string; + url: string; +} + +export interface PersonLinkRequest { + title: string; + url: string; +} + export interface PersonRequest { firstName: string; lastName: string; @@ -49,6 +68,8 @@ export interface PersonRequest { description: string | null; filieres: FiliereRequest[]; associations: AssociationRequest[]; + characteristics?: CharacteristicRequest[]; + links?: PersonLinkRequest[]; } export interface Filiere {